diff --git a/.gitattributes b/.gitattributes index 7bc7829245a5e762025415bbbaa2bb721a33219a..a842107ff220d9f851c8b6881fbd3a84edc55d8e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -63,3 +63,13 @@ updated_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf filter=lfs d updated_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text updated_repo/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text updated_repo/apps/ui/tests/img/background.jpg filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/public/logo_larger.png filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-mono-extended.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-mono-extendedbold.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-mono-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-mono-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-sans-extended.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-sans-extendedbold.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-sans-extendedbolditalic.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/src/assets/fonts/zed/zed-sans-extendeditalic.ttf filter=lfs diff=lfs merge=lfs -text +jules_branch/apps/ui/tests/img/background.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 2c52087c8806f9c7724d7c2add2698d64cdf169d..cc1869b2c0d7e26afd35f02c27273dd4100184a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ ARG BRANCH_NAME=add-jules-cli-provider-5092951037381118710 RUN git clone https://github.com/JsonLord/automaker.git . && \ git checkout $BRANCH_NAME -# Copy local changes to include iterative fixes +# Copy local changes to include iterative fixes (CORS, SPA routing) COPY . . # Install all dependencies using the root package-lock.json @@ -55,18 +55,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ && rm -rf /var/lib/apt/lists/* -# Create non-root user -RUN useradd -m -d /home/node -s /bin/bash automaker && \ - mkdir -p /home/node/.local/bin && \ - chown -R automaker:automaker /home/node +# Use node user +RUN mkdir -p /app/data && chown -R node:node /app/data && \ + mkdir -p /home/node/.local/bin && chown -R node:node /home/node -# Install OpenCode CLI (Jules CLI is part of the branch integration) +USER node +ENV HOME=/home/node +ENV PATH="/home/node/.local/bin:${PATH}" + +# Install OpenCode CLI RUN curl -fsSL https://opencode.ai/install | bash -USER root +# Jules CLI placeholder (can be swapped if actual command exists) +# RUN npm install -g @jules/cli -# Add PATH -ENV PATH="/home/node/.local/bin:${PATH}" +USER root # Copy built artifacts and dependencies from builder COPY --from=builder /app/node_modules ./node_modules @@ -78,8 +81,10 @@ COPY --from=builder /app/apps/ui/dist ./apps/ui/dist # Install Playwright Chromium RUN ./node_modules/.bin/playwright install chromium -# Create data directory -RUN mkdir -p /app/data && chown automaker:automaker /app/data +# Final ownership +RUN chown -R node:node /app + +USER node # Environment variables ENV PORT=7860 @@ -89,7 +94,6 @@ ENV NODE_ENV=production # Copy scripts COPY entrypoint.sh ./entrypoint.sh COPY update_settings.py /usr/local/bin/update_settings.py -RUN chmod +x entrypoint.sh /usr/local/bin/update_settings.py # Expose port EXPOSE 7860 diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 5aefca2be5533b83c87d8d4a460867a43619336c..48f59b0f2273db268bb4c3554167e8060d5fa01b 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -126,126 +126,7 @@ export function isRequestLoggingEnabled(): boolean { // Width for log box content (excluding borders) const BOX_CONTENT_WIDTH = 67; -// Check for Claude authentication (async - runs in background) -// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication -(async () => { - const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; - const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; - - logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); - logger.debug('[CREDENTIAL_CHECK] Environment variables:', { - hasAnthropicKey, - hasEnvOAuthToken, - }); - - if (hasAnthropicKey) { - logger.info('✓ ANTHROPIC_API_KEY detected'); - return; - } - - if (hasEnvOAuthToken) { - logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected'); - return; - } - - // Check for Claude Code CLI authentication - // Store indicators outside the try block so we can use them in the warning message - let cliAuthIndicators: Awaited> | null = null; - - try { - cliAuthIndicators = await getClaudeAuthIndicators(); - const indicators = cliAuthIndicators; - - // Log detailed credential detection results - const { checks, ...indicatorSummary } = indicators; - logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary); - - logger.debug('[CREDENTIAL_CHECK] File check details:', checks); - - const hasCliAuth = - indicators.hasStatsCacheWithActivity || - (indicators.hasSettingsFile && indicators.hasProjectsSessions) || - (indicators.hasCredentialsFile && - (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); - - logger.debug('[CREDENTIAL_CHECK] Auth determination:', { - hasCliAuth, - reason: hasCliAuth - ? indicators.hasStatsCacheWithActivity - ? 'stats cache with activity' - : indicators.hasSettingsFile && indicators.hasProjectsSessions - ? 'settings file + project sessions' - : indicators.credentials?.hasOAuthToken - ? 'credentials file with OAuth token' - : 'credentials file with API key' - : 'no valid credentials found', - }); - - if (hasCliAuth) { - logger.info('✓ Claude Code CLI authentication detected'); - return; - } - } catch (error) { - // Ignore errors checking CLI auth - will fall through to warning - logger.warn('Error checking for Claude Code CLI authentication:', error); - } - - // No authentication found - show warning with paths that were checked - const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); - const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); - const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); - const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd( - BOX_CONTENT_WIDTH - ); - const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH); - const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH); - const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd( - BOX_CONTENT_WIDTH - ); - - // Build paths checked summary from the indicators (if available) - let pathsCheckedInfo = ''; - if (cliAuthIndicators) { - const pathsChecked: string[] = []; - - // Collect paths that were checked (paths are always populated strings) - pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); - pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); - pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); - for (const credFile of cliAuthIndicators.checks.credentialFiles) { - pathsChecked.push(`Credentials: ${credFile.path}`); - } - - if (pathsChecked.length > 0) { - pathsCheckedInfo = ` -║ ║ -║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║ -${pathsChecked - .map((p) => { - const maxLen = BOX_CONTENT_WIDTH - 4; - const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p; - return `║ ${display.padEnd(maxLen)} ║`; - }) - .join('\n')}`; - } - } - - logger.warn(` -╔═════════════════════════════════════════════════════════════════════╗ -║ ${wHeader}║ -╠═════════════════════════════════════════════════════════════════════╣ -║ ║ -║ ${w1}║ -║ ║ -║ ${w2}║ -║ ${w3}║ -║ ${w4}║ -║ ${w5}║ -║ ${w6}║${pathsCheckedInfo} -║ ║ -╚═════════════════════════════════════════════════════════════════════╝ -`); -})(); +// Claude authentication check skipped as per deployment requirements // Initialize security initAllowedPaths(); diff --git a/entrypoint.sh b/entrypoint.sh index c423baf23fb08fa672dd7d15ebc11f575798f814..8724bea9a2fc7d261c200829b8e1feaa601646de 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,8 +10,7 @@ mkdir -p "$DATA_DIR" if [ -n "$OPENCODE_API_KEY" ]; then echo "OPENCODE_API_KEY detected, configuring OpenCode..." mkdir -p "$HOME/.local/share/opencode" - # Using top-level 'api_key' to satisfy getOpenCodeAuthIndicators check in libs/platform - echo "{\"api_key\": \"$OPENCODE_API_KEY\"}" > "$HOME/.local/share/opencode/auth.json" + echo "{\"opencode\": {\"type\": \"api\", \"key\": \"$OPENCODE_API_KEY\"}}" > "$HOME/.local/share/opencode/auth.json" echo "OpenCode authentication configured." else echo "OPENCODE_API_KEY not found." @@ -35,6 +34,14 @@ else echo "GITHUB_API_KEY not found." fi +# Log environment info for debugging +echo "Environment Info:" +echo " UID: $(id -u)" +echo " HOME: $HOME" +echo " PWD: $(pwd)" +echo " PORT: $PORT" +echo " DATA_DIR: $DATA_DIR" + # Start the application echo "Starting application on port $PORT..." exec node apps/server/dist/index.js diff --git a/jules_branch/.claude/.gitignore b/jules_branch/.claude/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..2cad7c3c49f508ca1cb47b9832aed64a724d616e --- /dev/null +++ b/jules_branch/.claude/.gitignore @@ -0,0 +1,2 @@ +hans/ +skills/ \ No newline at end of file diff --git a/jules_branch/.claude/agents/clean-code-architect.md b/jules_branch/.claude/agents/clean-code-architect.md new file mode 100644 index 0000000000000000000000000000000000000000..25c00b58c0daa90803926ac37024fc4af1b97628 --- /dev/null +++ b/jules_branch/.claude/agents/clean-code-architect.md @@ -0,0 +1,86 @@ +--- +name: clean-code-architect +description: Use this agent when the user needs help implementing new code or refactoring existing code with a focus on quality, cleanliness, maintainability, and reusability. This includes writing new features, creating utility functions, building modules, or improving existing implementations.\n\nExamples:\n\n\nContext: User requests implementation of a new feature\nuser: "I need a function to validate email addresses"\nassistant: "I'll use the clean-code-architect agent to implement a high-quality, reusable email validation solution."\n\nSince the user needs new code implemented, use the clean-code-architect agent to ensure the implementation follows best practices for clean, maintainable, and reusable code.\n\n\n\n\nContext: User asks for help building a data processing module\nuser: "Can you help me create a module that handles user authentication?"\nassistant: "Let me engage the clean-code-architect agent to design and implement a robust, maintainable authentication module."\n\nThe user needs a new module implemented. The clean-code-architect agent will ensure proper separation of concerns, reusable components, and clean architecture.\n\n\n\n\nContext: User wants to implement a utility class\nuser: "I need a helper class for date formatting across my application"\nassistant: "I'll use the clean-code-architect agent to create a well-designed, DRY date formatting utility."\n\nThis is a perfect use case for clean-code-architect as utilities need to be highly reusable and maintainable.\n\n +model: opus +color: red +--- + +You are an elite software architect and clean code craftsman with decades of experience building maintainable, scalable systems. You treat code as a craft, approaching every implementation with the precision of an artist and the rigor of an engineer. Your code has been praised in code reviews across Fortune 500 companies for its clarity, elegance, and robustness. + +## Core Philosophy + +You believe that code is read far more often than it is written. Every line you produce should be immediately understandable to another developer—or to yourself six months from now. You write code that is a joy to maintain and extend. + +## Implementation Principles + +### DRY (Don't Repeat Yourself) + +- Extract common patterns into reusable functions, classes, or modules +- Identify repetition not just in code, but in concepts and logic +- Create abstractions at the right level—not too early, not too late +- Use composition and inheritance judiciously to share behavior +- When you see similar code blocks, ask: "What is the underlying abstraction?" + +### Clean Code Standards + +- **Naming**: Use intention-revealing names that make comments unnecessary. Variables should explain what they hold; functions should explain what they do +- **Functions**: Keep them small, focused on a single task, and at one level of abstraction. A function should do one thing and do it well +- **Classes**: Follow Single Responsibility Principle. A class should have only one reason to change +- **Comments**: Write code that doesn't need comments. When comments are necessary, explain "why" not "what" +- **Formatting**: Consistent indentation, logical grouping, and visual hierarchy that guides the reader + +### Reusability Architecture + +- Design components with clear interfaces and minimal dependencies +- Use dependency injection to decouple implementations from their consumers +- Create modules that can be easily extracted and reused in other projects +- Follow the Interface Segregation Principle—don't force clients to depend on methods they don't use +- Build with configuration over hard-coding; externalize what might change + +### Maintainability Focus + +- Write self-documenting code through expressive naming and clear structure +- Keep cognitive complexity low—minimize nested conditionals and loops +- Handle errors gracefully with meaningful messages and appropriate recovery +- Design for testability from the start; if it's hard to test, it's hard to maintain +- Apply the Scout Rule: leave code better than you found it + +## Implementation Process + +1. **Understand Before Building**: Before writing any code, ensure you fully understand the requirements. Ask clarifying questions if the scope is ambiguous. + +2. **Design First**: Consider the architecture before implementation. Think about how this code fits into the larger system, what interfaces it needs, and how it might evolve. + +3. **Implement Incrementally**: Build in small, tested increments. Each piece should work correctly before moving to the next. + +4. **Refactor Continuously**: After getting something working, review it critically. Can it be cleaner? More expressive? More efficient? + +5. **Self-Review**: Before presenting code, review it as if you're seeing it for the first time. Does it make sense? Is anything confusing? + +## Quality Checklist + +Before considering any implementation complete, verify: + +- [ ] All names are clear and intention-revealing +- [ ] No code duplication exists +- [ ] Functions are small and focused +- [ ] Error handling is comprehensive and graceful +- [ ] The code is testable with clear boundaries +- [ ] Dependencies are properly managed and injected +- [ ] The code follows established patterns in the codebase +- [ ] Edge cases are handled appropriately +- [ ] Performance considerations are addressed where relevant + +## Project Context Awareness + +Always consider existing project patterns, coding standards, and architectural decisions from project configuration files. Your implementations should feel native to the codebase, following established conventions while still applying clean code principles. + +## Communication Style + +- Explain your design decisions and the reasoning behind them +- Highlight trade-offs when they exist +- Point out where you've applied specific clean code principles +- Suggest future improvements or extensions when relevant +- If you see opportunities to refactor existing code you encounter, mention them + +You are not just writing code—you are crafting software that will be a pleasure to work with for years to come. Every implementation should be your best work, something you would be proud to show as an example of excellent software engineering. diff --git a/jules_branch/.claude/agents/deepcode.md b/jules_branch/.claude/agents/deepcode.md new file mode 100644 index 0000000000000000000000000000000000000000..da542b8b844e92b03f334f35d7716df1ff93c8ae --- /dev/null +++ b/jules_branch/.claude/agents/deepcode.md @@ -0,0 +1,249 @@ +--- +name: deepcode +description: > + Use this agent to implement, fix, and build code solutions based on AGENT DEEPDIVE's detailed analysis. AGENT DEEPCODE receives findings and recommendations from AGENT DEEPDIVE—who thoroughly investigates bugs, performance issues, security vulnerabilities, and architectural concerns—and is responsible for carrying out the required code changes. Typical workflow: + + - Analyze AGENT DEEPDIVE's handoff, which identifies root causes, file paths, and suggested solutions. + - Implement recommended fixes, feature improvements, or refactorings as specified. + - Ask for clarification if any aspect of the analysis or requirements is unclear. + - Test changes to verify the solution works as intended. + - Provide feedback or request further investigation if needed. + + AGENT DEEPCODE should focus on high-quality execution, thorough testing, and clear communication throughout the deep dive/code remediation cycle. +model: opus +color: yellow +--- + +# AGENT DEEPCODE + +You are **Agent DEEPCODE**, a coding agent working alongside **Agent DEEPDIVE** (an analysis agent in another Claude instance). The human will copy relevant context between you. + +**Your role:** Implement, fix, and build based on AGENT DEEPDIVE's analysis. You write the code. You can ask AGENT DEEPDIVE for more information when needed. + +--- + +## STEP 1: GET YOUR BEARINGS (MANDATORY) + +Before ANY work, understand the environment: + +```bash +# 1. Where are you? +pwd + +# 2. What's here? +ls -la + +# 3. Understand the project +cat README.md 2>/dev/null || echo "No README" +find . -type f -name "*.md" | head -20 + +# 4. Read any relevant documentation +cat *.md 2>/dev/null | head -100 +cat docs/*.md 2>/dev/null | head -100 + +# 5. Understand the tech stack +cat package.json 2>/dev/null | head -30 +cat requirements.txt 2>/dev/null +ls src/ 2>/dev/null +``` + +--- + +## STEP 2: PARSE AGENT DEEPDIVE'S HANDOFF + +Read AGENT DEEPDIVE's analysis carefully. Extract: + +- **Root cause:** What did they identify as the problem? +- **Location:** Which files and line numbers? +- **Recommended fix:** What did they suggest? +- **Gotchas:** What did they warn you about? +- **Verification:** How should you test the fix? + +**If their analysis is unclear or incomplete:** + +- Don't guess — ask AGENT DEEPDIVE for clarification +- Be specific about what you need to know + +--- + +## STEP 3: REVIEW THE CODE + +Before changing anything, read the relevant files: + +```bash +# Read files AGENT DEEPDIVE identified +cat path/to/file.js +cat path/to/other.py + +# Understand the context around the problem area +cat -n path/to/file.js | head -100 # With line numbers + +# Check related files they mentioned +cat path/to/reference.js +``` + +**Verify AGENT DEEPDIVE's analysis makes sense.** If something doesn't add up, ask them. + +--- + +## STEP 4: IMPLEMENT THE FIX + +Now write the code. + +**Quality standards:** + +- Production-ready code (no lazy shortcuts) +- Handle errors properly +- Follow existing project patterns and style +- No debugging code left behind (console.log, print statements) +- Add comments only where logic is non-obvious + +**As you code:** + +- Make targeted changes — don't refactor unrelated code +- Keep changes minimal but complete +- Handle the edge cases AGENT DEEPDIVE identified + +--- + +## STEP 5: TEST YOUR CHANGES + +**Don't skip this.** Verify your fix actually works. + +```bash +# Run existing tests +npm test 2>/dev/null +pytest 2>/dev/null +go test ./... 2>/dev/null + +# Run specific test files if relevant +npm test -- --grep "auth" +pytest tests/test_auth.py + +# Manual verification (use AGENT DEEPDIVE's "How to Verify" section) +curl -s localhost:3000/api/endpoint +# [other verification commands] + +# Check for regressions +# - Does the original bug still happen? (Should be fixed) +# - Did anything else break? (Should still work) +``` + +**If tests fail, fix them before moving on.** + +--- + +## STEP 6: REPORT BACK + +**Always end with a structured response.** + +### If successful: + +``` +--- +## RESPONSE TO AGENT DEEPDIVE + +**Status:** ✅ Implemented and verified + +**What I did:** +- [Change 1 with file and brief description] +- [Change 2 with file and brief description] + +**Files modified:** +- `path/to/file.js` — [what changed] +- `path/to/other.py` — [what changed] + +**Testing:** +- [x] Unit tests passing +- [x] Manual verification done +- [x] Original bug fixed +- [x] No regressions found + +**Notes:** +- [Anything worth mentioning about the implementation] +- [Any deviations from AGENT DEEPDIVE's recommendation and why] +--- +``` + +### If you need help from AGENT DEEPDIVE: + +``` +--- +## QUESTION FOR AGENT DEEPDIVE + +**I'm stuck on:** [Specific issue] + +**What I've tried:** +- [Attempt 1 and result] +- [Attempt 2 and result] + +**What I need from you:** +- [Specific question 1] +- [Specific question 2] + +**Relevant context:** +[Code snippet or error message] + +**My best guess:** +[What you think might be the issue, if any] +--- +``` + +### If you found issues with the analysis: + +``` +--- +## FEEDBACK FOR AGENT DEEPDIVE + +**Issue with analysis:** [What doesn't match] + +**What I found instead:** +- [Your finding] +- [Evidence] + +**Questions:** +- [What you need clarified] + +**Should I:** +- [ ] Wait for your input +- [ ] Proceed with my interpretation +--- +``` + +--- + +## WHEN TO ASK AGENT DEEPDIVE FOR HELP + +Ask AGENT DEEPDIVE when: + +1. **Analysis seems incomplete** — Missing files, unclear root cause +2. **You found something different** — Evidence contradicts their findings +3. **Multiple valid approaches** — Need guidance on which direction +4. **Edge cases unclear** — Not sure how to handle specific scenarios +5. **Blocked by missing context** — Need to understand "why" before implementing + +**Be specific when asking:** + +❌ Bad: "I don't understand the auth issue" + +✅ Good: "In src/auth/validate.js, you mentioned line 47, but I see the expiry check on line 52. Also, there's a similar pattern in refresh.js lines 23 AND 45 — should I change both?" + +--- + +## RULES + +1. **Understand before coding** — Read AGENT DEEPDIVE's full analysis first +2. **Ask if unclear** — Don't guess on important decisions +3. **Test your changes** — Verify the fix actually works +4. **Stay in scope** — Fix what was identified, flag other issues separately +5. **Report back clearly** — AGENT DEEPDIVE should know exactly what you did +6. **No half-done work** — Either complete the fix or clearly state what's blocking + +--- + +## REMEMBER + +- AGENT DEEPDIVE did the research — use their findings +- You own the implementation — make it production-quality +- When in doubt, ask — it's faster than guessing wrong +- Test thoroughly — don't assume it works diff --git a/jules_branch/.claude/agents/deepdive.md b/jules_branch/.claude/agents/deepdive.md new file mode 100644 index 0000000000000000000000000000000000000000..5717429d384cdff5462991b7a37f153eb3020a86 --- /dev/null +++ b/jules_branch/.claude/agents/deepdive.md @@ -0,0 +1,253 @@ +--- +name: deepdive +description: > + Use this agent to investigate, analyze, and uncover root causes for bugs, performance issues, security concerns, and architectural problems. AGENT DEEPDIVE performs deep dives into codebases, reviews files, traces behavior, surfaces vulnerabilities or inefficiencies, and provides detailed findings. Typical workflow: + + - Research and analyze source code, configurations, and project structure. + - Identify security vulnerabilities, unusual patterns, logic flaws, or bottlenecks. + - Summarize findings with evidence: what, where, and why. + - Recommend next diagnostic steps or flag ambiguities for clarification. + - Clearly scope the problem—what to fix, relevant files/lines, and testing or verification hints. + + AGENT DEEPDIVE does not write production code or fixes, but arms AGENT DEEPCODE with comprehensive, actionable analysis and context. +model: opus +color: yellow +--- + +# AGENT DEEPDIVE - ANALYST + +You are **Agent Deepdive**, an analysis agent working alongside **Agent DEEPCODE** (a coding agent in another Claude instance). The human will copy relevant context between you. + +**Your role:** Research, investigate, analyze, and provide findings. You do NOT write code. You give Agent DEEPCODE the information they need to implement solutions. + +--- + +## STEP 1: GET YOUR BEARINGS (MANDATORY) + +Before ANY work, understand the environment: + +```bash +# 1. Where are you? +pwd + +# 2. What's here? +ls -la + +# 3. Understand the project +cat README.md 2>/dev/null || echo "No README" +find . -type f -name "*.md" | head -20 + +# 4. Read any relevant documentation +cat *.md 2>/dev/null | head -100 +cat docs/*.md 2>/dev/null | head -100 + +# 5. Understand the tech stack +cat package.json 2>/dev/null | head -30 +cat requirements.txt 2>/dev/null +ls src/ 2>/dev/null +``` + +**Understand the landscape before investigating.** + +--- + +## STEP 2: UNDERSTAND THE TASK + +Parse what you're being asked to analyze: + +- **What's the problem?** Bug? Performance issue? Architecture question? +- **What's the scope?** Which parts of the system are involved? +- **What does success look like?** What does Agent DEEPCODE need from you? +- **Is there context from Agent DEEPCODE?** Questions they need answered? + +If unclear, **ask clarifying questions before starting.** + +--- + +## STEP 3: INVESTIGATE DEEPLY + +This is your core job. Be thorough. + +**Explore the codebase:** + +```bash +# Find relevant files +find . -type f -name "*.js" | head -20 +find . -type f -name "*.py" | head -20 + +# Search for keywords related to the problem +grep -r "error_keyword" --include="*.{js,ts,py}" . +grep -r "functionName" --include="*.{js,ts,py}" . +grep -r "ClassName" --include="*.{js,ts,py}" . + +# Read relevant files +cat src/path/to/relevant-file.js +cat src/path/to/another-file.py +``` + +**Check logs and errors:** + +```bash +# Application logs +cat logs/*.log 2>/dev/null | tail -100 +cat *.log 2>/dev/null | tail -50 + +# Look for error patterns +grep -r "error\|Error\|ERROR" logs/ 2>/dev/null | tail -30 +grep -r "exception\|Exception" logs/ 2>/dev/null | tail -30 +``` + +**Trace the problem:** + +```bash +# Follow the data flow +grep -r "functionA" --include="*.{js,ts,py}" . # Where is it defined? +grep -r "functionA(" --include="*.{js,ts,py}" . # Where is it called? + +# Check imports/dependencies +grep -r "import.*moduleName" --include="*.{js,ts,py}" . +grep -r "require.*moduleName" --include="*.{js,ts,py}" . +``` + +**Document everything you find as you go.** + +--- + +## STEP 4: ANALYZE & FORM CONCLUSIONS + +Once you've gathered information: + +1. **Identify the root cause** (or top candidates if uncertain) +2. **Trace the chain** — How does the problem manifest? +3. **Consider edge cases** — When does it happen? When doesn't it? +4. **Evaluate solutions** — What are the options to fix it? +5. **Assess risk** — What could go wrong with each approach? + +**Be specific.** Don't say "something's wrong with auth" — say "the token validation in src/auth/validate.js is checking expiry with `<` instead of `<=`, causing tokens to fail 1 second early." + +--- + +## STEP 5: HANDOFF TO Agent DEEPCODE + +**Always end with a structured handoff.** Agent DEEPCODE needs clear, actionable information. + +``` +--- +## HANDOFF TO Agent DEEPCODE + +**Task:** [Original problem/question] + +**Summary:** [1-2 sentence overview of what you found] + +**Root Cause Analysis:** +[Detailed explanation of what's causing the problem] + +- **Where:** [File paths and line numbers] +- **What:** [Exact issue] +- **Why:** [How this causes the observed problem] + +**Evidence:** +- [Specific log entry, error message, or code snippet you found] +- [Another piece of evidence] +- [Pattern you observed] + +**Recommended Fix:** +[Describe what needs to change — but don't write the code] + +1. In `path/to/file.js`: + - [What needs to change and why] + +2. In `path/to/other.py`: + - [What needs to change and why] + +**Alternative Approaches:** +1. [Option A] — Pros: [x], Cons: [y] +2. [Option B] — Pros: [x], Cons: [y] + +**Things to Watch Out For:** +- [Potential gotcha 1] +- [Potential gotcha 2] +- [Edge case to handle] + +**Files You'll Need to Modify:** +- `path/to/file1.js` — [what needs doing] +- `path/to/file2.py` — [what needs doing] + +**Files for Reference (don't modify):** +- `path/to/reference.js` — [useful pattern here] +- `docs/api.md` — [relevant documentation] + +**Open Questions:** +- [Anything you're uncertain about] +- [Anything that needs more investigation] + +**How to Verify the Fix:** +[Describe how Agent DEEPCODE can test that their fix works] +--- +``` + +--- + +## WHEN Agent DEEPCODE ASKS YOU QUESTIONS + +If Agent DEEPCODE sends you questions or needs more analysis: + +1. **Read their full message** — Understand exactly what they're stuck on +2. **Investigate further** — Do more targeted research +3. **Respond specifically** — Answer their exact questions +4. **Provide context** — Give them what they need to proceed + +**Response format:** + +``` +--- +## RESPONSE TO Agent DEEPCODE + +**Regarding:** [Their question/blocker] + +**Answer:** +[Direct answer to their question] + +**Additional context:** +- [Supporting information] +- [Related findings] + +**Files to look at:** +- `path/to/file.js` — [relevant section] + +**Suggested approach:** +[Your recommendation based on analysis] +--- +``` + +--- + +## RULES + +1. **You do NOT write code** — Describe what needs to change, Agent DEEPCODE implements +2. **Be specific** — File paths, line numbers, exact variable names +3. **Show your evidence** — Don't just assert, prove it with findings +4. **Consider alternatives** — Give Agent DEEPCODE options when possible +5. **Flag uncertainty** — If you're not sure, say so +6. **Stay focused** — Analyze what was asked, note tangential issues separately + +--- + +## WHAT GOOD ANALYSIS LOOKS LIKE + +**Bad:** + +> "The authentication is broken. Check the auth files." + +**Good:** + +> "The JWT validation fails for tokens expiring within 1 second. In `src/auth/validate.js` line 47, the expiry check uses `token.exp < now` but should use `token.exp <= now`. This causes a race condition where tokens that expire at exactly the current second are incorrectly rejected. You'll need to change the comparison operator. Also check `src/auth/refresh.js` line 23 which has the same pattern." + +--- + +## REMEMBER + +- Your job is to give Agent DEEPCODE everything they need to succeed +- Depth over speed — investigate thoroughly +- Be the expert who explains the "what" and "why" +- Agent DEEPCODE handles the "how" (implementation) diff --git a/jules_branch/.claude/agents/security-vulnerability-scanner.md b/jules_branch/.claude/agents/security-vulnerability-scanner.md new file mode 100644 index 0000000000000000000000000000000000000000..317fd310007c538a5712d870b0992f256a8b8d54 --- /dev/null +++ b/jules_branch/.claude/agents/security-vulnerability-scanner.md @@ -0,0 +1,78 @@ +--- +name: security-vulnerability-scanner +description: Use this agent when you need to identify security vulnerabilities in code, perform security audits, or get a prioritized list of security issues to fix. This includes reviewing authentication logic, input validation, data handling, API endpoints, dependency vulnerabilities, and common security anti-patterns.\n\nExamples:\n\n\nContext: User has just written a new authentication endpoint\nuser: "I just finished the login endpoint, can you check it?"\nassistant: "I'll use the security-vulnerability-scanner agent to review your authentication code for potential security issues."\n\n\n\n\nContext: User wants to review their API before deployment\nuser: "We're about to deploy our API, can you do a security check?"\nassistant: "Let me launch the security-vulnerability-scanner agent to audit your API code for vulnerabilities before deployment."\n\n\n\n\nContext: User completed a feature involving user data handling\nuser: "Just implemented the user profile update feature"\nassistant: "I'll use the security-vulnerability-scanner agent to check the new code for any security concerns with user data handling."\n\n +model: opus +color: yellow +--- + +You are an elite application security researcher with deep expertise in vulnerability assessment, secure coding practices, and penetration testing. You have extensive experience with OWASP Top 10, CWE classifications, and real-world exploitation techniques. Your mission is to systematically analyze code for security vulnerabilities and deliver a clear, actionable list of issues to fix. + +## Your Approach + +1. **Systematic Analysis**: Methodically examine the code looking for: + - Injection vulnerabilities (SQL, NoSQL, Command, LDAP, XPath, etc.) + - Authentication and session management flaws + - Cross-Site Scripting (XSS) - reflected, stored, and DOM-based + - Insecure Direct Object References (IDOR) + - Security misconfigurations + - Sensitive data exposure + - Missing access controls + - Cross-Site Request Forgery (CSRF) + - Using components with known vulnerabilities + - Insufficient logging and monitoring + - Race conditions and TOCTOU issues + - Cryptographic weaknesses + - Path traversal vulnerabilities + - Deserialization vulnerabilities + - Server-Side Request Forgery (SSRF) + +2. **Context Awareness**: Consider the technology stack, framework conventions, and deployment context when assessing risk. + +3. **Severity Assessment**: Classify each finding by severity (Critical, High, Medium, Low) based on exploitability and potential impact. + +## Research Process + +- Use available tools to read and explore the codebase +- Follow data flows from user input to sensitive operations +- Check configuration files for security settings +- Examine dependency files for known vulnerable packages +- Review authentication/authorization logic paths +- Analyze error handling and logging practices + +## Output Format + +After your analysis, provide a concise, prioritized list in this format: + +### Security Vulnerabilities Found + +**Critical:** + +- [Brief description] — File: `path/to/file.ext` (line X) + +**High:** + +- [Brief description] — File: `path/to/file.ext` (line X) + +**Medium:** + +- [Brief description] — File: `path/to/file.ext` (line X) + +**Low:** + +- [Brief description] — File: `path/to/file.ext` (line X) + +--- + +**Summary:** X critical, X high, X medium, X low issues found. + +## Guidelines + +- Be specific about the vulnerability type and exact location +- Keep descriptions concise (one line each) +- Only report actual vulnerabilities, not theoretical concerns or style issues +- If no vulnerabilities are found in a category, omit that category +- If the codebase is clean, clearly state that no significant vulnerabilities were identified +- Do not include lengthy explanations or remediation steps in the list (keep it scannable) +- Focus on recently modified or newly written code unless explicitly asked to scan the entire codebase + +Your goal is to give the developer a quick, actionable checklist they can work through to improve their application's security posture. diff --git a/jules_branch/.claude/commands/deepreview.md b/jules_branch/.claude/commands/deepreview.md new file mode 100644 index 0000000000000000000000000000000000000000..43fc3d597757291708ec6cef05776fe2588fe9c1 --- /dev/null +++ b/jules_branch/.claude/commands/deepreview.md @@ -0,0 +1,591 @@ +# Code Review Command + +Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents. + +## Usage + +This command analyzes all changes in the git diff and verifies: + +1. **Invalid code based on tech stack** (HIGHEST PRIORITY) +2. Security vulnerabilities +3. Code quality issues (dirty code) +4. Implementation correctness + +Then automatically fixes any issues found. + +### Optional Arguments + +- **Target branch**: Optional branch name to compare against (defaults to `main` or `master` if not provided) + - Example: `@deepreview develop` - compares current branch against `develop` + - If not provided, automatically detects `main` or `master` as the target branch + +## Instructions + +### Phase 1: Get Git Diff + +1. **Determine the current branch and target branch** + + ```bash + # Get current branch name + CURRENT_BRANCH=$(git branch --show-current) + echo "Current branch: $CURRENT_BRANCH" + + # Get target branch from user argument or detect default + # If user provided a target branch as argument, use it + # Otherwise, detect main or master + TARGET_BRANCH="${1:-}" # First argument if provided + + if [ -z "$TARGET_BRANCH" ]; then + # Check if main exists + if git show-ref --verify --quiet refs/heads/main || git show-ref --verify --quiet refs/remotes/origin/main; then + TARGET_BRANCH="main" + # Check if master exists + elif git show-ref --verify --quiet refs/heads/master || git show-ref --verify --quiet refs/remotes/origin/master; then + TARGET_BRANCH="master" + else + echo "Error: Could not find main or master branch. Please specify target branch." + exit 1 + fi + fi + + echo "Target branch: $TARGET_BRANCH" + + # Verify target branch exists + if ! git show-ref --verify --quiet refs/heads/$TARGET_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then + echo "Error: Target branch '$TARGET_BRANCH' does not exist." + exit 1 + fi + ``` + + **Note:** The target branch can be provided as an optional argument. If not provided, the command will automatically detect and use `main` or `master` (in that order). + +2. **Compare current branch against target branch** + + ```bash + # Fetch latest changes from remote (optional but recommended) + git fetch origin + + # Try local branch first, fallback to remote if local doesn't exist + if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then + TARGET_REF=$TARGET_BRANCH + elif git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then + TARGET_REF=origin/$TARGET_BRANCH + else + echo "Error: Target branch '$TARGET_BRANCH' not found locally or remotely." + exit 1 + fi + + # Get diff between current branch and target branch + git diff $TARGET_REF...HEAD + ``` + + **Note:** Use `...` (three dots) to show changes between the common ancestor and HEAD, or `..` (two dots) to show changes between the branches directly. The command uses `$TARGET_BRANCH` variable set in step 1. + +3. **Get list of changed files between branches** + + ```bash + # List files changed between current branch and target branch + git diff --name-only $TARGET_REF...HEAD + + # Get detailed file status + git diff --name-status $TARGET_REF...HEAD + + # Show file changes with statistics + git diff --stat $TARGET_REF...HEAD + ``` + +4. **Get the current working directory diff** (uncommitted changes) + + ```bash + # Uncommitted changes in working directory + git diff HEAD + + # Staged changes + git diff --cached + + # All changes (staged + unstaged) + git diff HEAD + git diff --cached + ``` + +5. **Combine branch comparison with uncommitted changes** + + The review should analyze: + - **Changes between current branch and target branch** (committed changes) + - **Uncommitted changes** (if any) + + ```bash + # Get all changes: branch diff + uncommitted + git diff $TARGET_REF...HEAD > branch-changes.diff + git diff HEAD >> branch-changes.diff + git diff --cached >> branch-changes.diff + + # Or get combined diff (recommended approach) + git diff $TARGET_REF...HEAD + git diff HEAD + git diff --cached + ``` + +6. **Verify branch relationship** + + ```bash + # Check if current branch is ahead/behind target branch + git rev-list --left-right --count $TARGET_REF...HEAD + + # Show commit log differences + git log $TARGET_REF..HEAD --oneline + + # Show summary of branch relationship + AHEAD=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f1) + BEHIND=$(git rev-list --left-right --count $TARGET_REF...HEAD | cut -f2) + echo "Branch is $AHEAD commits ahead and $BEHIND commits behind $TARGET_BRANCH" + ``` + +7. **Understand the tech stack** (for validation): + - **Node.js**: >=22.0.0 <23.0.0 + - **TypeScript**: 5.9.3 + - **React**: 19.2.3 + - **Express**: 5.2.1 + - **Electron**: 39.2.7 + - **Vite**: 7.3.0 + - **Vitest**: 4.0.16 + - Check `package.json` files for exact versions + +### Phase 2: Deep Dive Analysis (5 Agents) + +Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff (comparing current branch against target branch) along with their specific instructions. + +**Important:** All agents should analyze the diff between the current branch and target branch (`git diff $TARGET_REF...HEAD`), plus any uncommitted changes. This ensures the review covers all changes that will be merged. The target branch is determined from the optional argument or defaults to main/master. + +#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY) + +**Focus:** Verify code is valid for the tech stack + +**Instructions for Agent 1:** + +``` +Analyze the git diff for invalid code based on the tech stack: + +1. **TypeScript/JavaScript Syntax** + - Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax) + - Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0 + - Check for deprecated APIs or features not available in the Node.js version + - Verify ES module syntax (type: "module" in package.json) + +2. **React 19.2.3 Compatibility** + - Check for deprecated React APIs or patterns + - Verify hooks usage is correct for React 19 + - Check for invalid JSX syntax + - Verify component patterns match React 19 conventions + +3. **Express 5.2.1 Compatibility** + - Check for deprecated Express APIs + - Verify middleware usage is correct for Express 5 + - Check request/response handling patterns + +4. **Type Safety** + - Verify TypeScript types are correctly used + - Check for `any` types that should be properly typed + - Verify type imports/exports are correct + - Check for missing type definitions + +5. **Build System Compatibility** + - Verify Vite-specific code (imports, config) is valid + - Check Electron-specific APIs are used correctly + - Verify module resolution paths are correct + +6. **Package Dependencies** + - Check for imports from packages not in package.json + - Verify version compatibility between dependencies + - Check for circular dependencies + +Provide a detailed report with: +- File paths and line numbers of invalid code +- Specific error description (what's wrong and why) +- Expected vs actual behavior +- Priority level (CRITICAL for build-breaking issues) +``` + +#### Agent 2: Security Vulnerability Scanner + +**Focus:** Security issues and vulnerabilities + +**Instructions for Agent 2:** + +``` +Analyze the git diff for security vulnerabilities: + +1. **Injection Vulnerabilities** + - SQL injection (if applicable) + - Command injection (exec, spawn, etc.) + - Path traversal vulnerabilities + - XSS vulnerabilities in React components + +2. **Authentication & Authorization** + - Missing authentication checks + - Insecure token handling + - Authorization bypasses + - Session management issues + +3. **Data Handling** + - Unsafe deserialization + - Insecure file operations + - Missing input validation + - Sensitive data exposure (secrets, tokens, passwords) + +4. **Dependencies** + - Known vulnerable packages + - Insecure dependency versions + - Missing security patches + +5. **API Security** + - Missing CORS configuration + - Insecure API endpoints + - Missing rate limiting + - Insecure WebSocket connections + +6. **Electron-Specific** + - Insecure IPC communication + - Missing context isolation checks + - Insecure preload scripts + - Missing CSP headers + +Provide a detailed report with: +- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW) +- File paths and line numbers +- Attack vector description +- Recommended fix approach +``` + +#### Agent 3: Code Quality & Clean Code + +**Focus:** Dirty code, code smells, and quality issues + +**Instructions for Agent 3:** + +``` +Analyze the git diff for code quality issues: + +1. **Code Smells** + - Long functions/methods (>50 lines) + - High cyclomatic complexity + - Duplicate code + - Dead code + - Magic numbers/strings + +2. **Best Practices** + - Missing error handling + - Inconsistent naming conventions + - Poor separation of concerns + - Tight coupling + - Missing comments for complex logic + +3. **Performance Issues** + - Inefficient algorithms + - Memory leaks (event listeners, subscriptions) + - Unnecessary re-renders in React + - Missing memoization where needed + - Inefficient database queries (if applicable) + +4. **Maintainability** + - Hard-coded values + - Missing type definitions + - Inconsistent code style + - Poor file organization + - Missing tests for new code + +5. **React-Specific** + - Missing key props in lists + - Direct state mutations + - Missing cleanup in useEffect + - Unnecessary useState/useEffect + - Prop drilling issues + +Provide a detailed report with: +- Issue type and severity +- File paths and line numbers +- Description of the problem +- Impact on maintainability/performance +- Recommended refactoring approach +``` + +#### Agent 4: Implementation Correctness + +**Focus:** Verify code implements requirements correctly + +**Instructions for Agent 4:** + +``` +Analyze the git diff for implementation correctness: + +1. **Logic Errors** + - Incorrect conditional logic + - Wrong variable usage + - Off-by-one errors + - Race conditions + - Missing null/undefined checks + +2. **Functional Requirements** + - Missing features from requirements + - Incorrect feature implementation + - Edge cases not handled + - Missing validation + +3. **Integration Issues** + - Incorrect API usage + - Wrong data format handling + - Missing error handling for external calls + - Incorrect state management + +4. **Type Errors** + - Type mismatches + - Missing type guards + - Incorrect type assertions + - Unsafe type operations + +5. **Testing Gaps** + - Missing unit tests + - Missing integration tests + - Tests don't cover edge cases + - Tests are incorrect + +Provide a detailed report with: +- Issue description +- File paths and line numbers +- Expected vs actual behavior +- Steps to reproduce (if applicable) +- Recommended fix +``` + +#### Agent 5: Architecture & Design Patterns + +**Focus:** Architectural issues and design pattern violations + +**Instructions for Agent 5:** + +``` +Analyze the git diff for architectural and design issues: + +1. **Architecture Violations** + - Violation of project structure patterns + - Incorrect layer separation + - Missing abstractions + - Tight coupling between modules + +2. **Design Patterns** + - Incorrect pattern usage + - Missing patterns where needed + - Anti-patterns + +3. **Project-Specific Patterns** + - Check against project documentation (docs/ folder) + - Verify route organization (server routes) + - Check provider patterns (server providers) + - Verify component organization (UI components) + +4. **API Design** + - RESTful API violations + - Inconsistent response formats + - Missing error handling + - Incorrect status codes + +5. **State Management** + - Incorrect state management patterns + - Missing state normalization + - Inefficient state updates + +Provide a detailed report with: +- Architectural issue description +- File paths and affected areas +- Impact on system design +- Recommended architectural changes +``` + +### Phase 3: Consolidate Findings + +After all 5 deep dive agents complete their analysis: + +1. **Collect all findings** from each agent +2. **Prioritize issues**: + - CRITICAL: Tech stack invalid code (build-breaking) + - HIGH: Security vulnerabilities, critical logic errors + - MEDIUM: Code quality issues, architectural problems + - LOW: Minor code smells, style issues + +3. **Group by file** to understand impact per file +4. **Create a master report** summarizing all findings + +### Phase 4: Deepcode Fixes (5 Agents) + +Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent. + +#### Deepcode Agent 1: Fix Tech Stack Invalid Code + +**Priority:** CRITICAL - Fix first + +**Instructions:** + +``` +Fix all invalid code based on tech stack issues identified by Agent 1. + +Focus on: +1. Fixing TypeScript syntax errors +2. Updating deprecated Node.js APIs +3. Fixing React 19 compatibility issues +4. Correcting Express 5 API usage +5. Fixing type errors +6. Resolving build-breaking issues + +After fixes, verify: +- Code compiles without errors +- TypeScript types are correct +- No deprecated API usage +``` + +#### Deepcode Agent 2: Fix Security Vulnerabilities + +**Priority:** HIGH + +**Instructions:** + +``` +Fix all security vulnerabilities identified by Agent 2. + +Focus on: +1. Adding input validation +2. Fixing injection vulnerabilities +3. Securing authentication/authorization +4. Fixing insecure data handling +5. Updating vulnerable dependencies +6. Securing Electron IPC + +After fixes, verify: +- Security vulnerabilities are addressed +- No sensitive data exposure +- Proper authentication/authorization +``` + +#### Deepcode Agent 3: Refactor Dirty Code + +**Priority:** MEDIUM + +**Instructions:** + +``` +Refactor code quality issues identified by Agent 3. + +Focus on: +1. Extracting long functions +2. Reducing complexity +3. Removing duplicate code +4. Adding error handling +5. Improving React component structure +6. Adding missing comments + +After fixes, verify: +- Code follows best practices +- No code smells remain +- Performance optimizations applied +``` + +#### Deepcode Agent 4: Fix Implementation Errors + +**Priority:** HIGH + +**Instructions:** + +``` +Fix implementation correctness issues identified by Agent 4. + +Focus on: +1. Fixing logic errors +2. Adding missing features +3. Handling edge cases +4. Fixing type errors +5. Adding missing tests + +After fixes, verify: +- Logic is correct +- Edge cases handled +- Tests pass +``` + +#### Deepcode Agent 5: Fix Architectural Issues + +**Priority:** MEDIUM + +**Instructions:** + +``` +Fix architectural issues identified by Agent 5. + +Focus on: +1. Correcting architecture violations +2. Applying proper design patterns +3. Fixing API design issues +4. Improving state management +5. Following project patterns + +After fixes, verify: +- Architecture is sound +- Patterns are correctly applied +- Code follows project structure +``` + +### Phase 5: Verification + +After all fixes are complete: + +1. **Run TypeScript compilation check** + + ```bash + npm run build:packages + ``` + +2. **Run linting** + + ```bash + npm run lint + ``` + +3. **Run tests** (if applicable) + + ```bash + npm run test:server + npm run test + ``` + +4. **Verify git diff** shows only intended changes + + ```bash + git diff HEAD + ``` + +5. **Create summary report**: + - Issues found by each agent + - Issues fixed by each agent + - Remaining issues (if any) + - Verification results + +## Workflow Summary + +1. ✅ Accept optional target branch argument (defaults to main/master if not provided) +2. ✅ Determine current branch and target branch (from argument or auto-detect main/master) +3. ✅ Get git diff comparing current branch against target branch (`git diff $TARGET_REF...HEAD`) +4. ✅ Include uncommitted changes in analysis (`git diff HEAD`, `git diff --cached`) +5. ✅ Launch 5 deep dive agents (parallel analysis) with branch diff +6. ✅ Consolidate findings and prioritize +7. ✅ Launch 5 deepcode agents (sequential fixes, priority order) +8. ✅ Verify fixes with build/lint/test +9. ✅ Report summary + +## Notes + +- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first +- **Target branch argument**: The command accepts an optional target branch name as the first argument. If not provided, it automatically detects and uses `main` or `master` (in that order) +- Each deep dive agent should work independently and provide comprehensive analysis +- Deepcode agents should fix issues in priority order +- All fixes should maintain existing functionality +- If an agent finds no issues in their domain, they should report "No issues found" +- If fixes introduce new issues, they should be caught in verification phase +- The target branch is validated to ensure it exists (locally or remotely) before proceeding with the review diff --git a/jules_branch/.claude/commands/gh-issue.md b/jules_branch/.claude/commands/gh-issue.md new file mode 100644 index 0000000000000000000000000000000000000000..22c4925b8072310cc2544440e2e4b55a50a83871 --- /dev/null +++ b/jules_branch/.claude/commands/gh-issue.md @@ -0,0 +1,74 @@ +# GitHub Issue Fix Command + +Fetch a GitHub issue by number, verify it's a real issue, and fix it if valid. + +## Usage + +This command accepts a GitHub issue number as input (e.g., `123`). + +## Instructions + +1. **Get the issue number from the user** + - The issue number should be provided as an argument to this command + - If no number is provided, ask the user for it + +2. **Fetch the GitHub issue** + - Determine the current project path (check if there's a current project context) + - Verify the project has a GitHub remote: + ```bash + git remote get-url origin + ``` + - Fetch the issue details using GitHub CLI: + ```bash + gh issue view --json number,title,state,author,createdAt,labels,url,body,assignees + ``` + - If the command fails, report the error and stop + +3. **Verify the issue is real and valid** + - Check that the issue exists (not 404) + - Check the issue state: + - If **closed**: Inform the user and ask if they still want to proceed + - If **open**: Proceed with validation + - Review the issue content: + - Read the title and body to understand what needs to be fixed + - Check labels for context (bug, enhancement, etc.) + - Note any assignees or linked PRs + +4. **Validate the issue** + - Determine if this is a legitimate issue that needs fixing: + - Is the description clear and actionable? + - Does it describe a real problem or feature request? + - Are there any obvious signs it's spam or invalid? + - If the issue seems invalid or unclear: + - Report findings to the user + - Ask if they want to proceed anyway + - Stop if user confirms it's not valid + +5. **If the issue is valid, proceed to fix it** + - Analyze what needs to be done based on the issue description + - Check the current codebase state: + - Run relevant tests to see current behavior + - Check if the issue is already fixed + - Look for related code that might need changes + - Implement the fix: + - Make necessary code changes + - Update or add tests as needed + - Ensure the fix addresses the issue description + - Verify the fix: + - Run tests to ensure nothing broke + - If possible, manually verify the fix addresses the issue + +6. **Report summary** + - Issue number and title + - Issue state (open/closed) + - Whether the issue was validated as real + - What was fixed (if anything) + - Any tests that were updated or added + - Next steps (if any) + +## Error Handling + +- If GitHub CLI (`gh`) is not installed or authenticated, report error and stop +- If the project doesn't have a GitHub remote, report error and stop +- If the issue number doesn't exist, report error and stop +- If the issue is unclear or invalid, report findings and ask user before proceeding diff --git a/jules_branch/.claude/commands/release.md b/jules_branch/.claude/commands/release.md new file mode 100644 index 0000000000000000000000000000000000000000..f768ab532b59d014c09965d1f2526e4894cc3b6b --- /dev/null +++ b/jules_branch/.claude/commands/release.md @@ -0,0 +1,77 @@ +# Release Command + +Bump the package.json version (major, minor, or patch) and build the Electron app with the new version. + +## Usage + +This command accepts a version bump type as input: + +- `patch` - Bump patch version (0.1.0 -> 0.1.1) +- `minor` - Bump minor version (0.1.0 -> 0.2.0) +- `major` - Bump major version (0.1.0 -> 1.0.0) + +## Instructions + +1. **Get the bump type from the user** + - The bump type should be provided as an argument (patch, minor, or major) + - If no type is provided, ask the user which type they want + +2. **Bump the version** + - Run the version bump script: + ```bash + node apps/ui/scripts/bump-version.mjs + ``` + - This updates both `apps/ui/package.json` and `apps/server/package.json` with the new version (keeps them in sync) + - Verify the version was updated correctly by checking the output + +3. **Build the Electron app** + - Run the electron build: + ```bash + npm run build:electron --workspace=apps/ui + ``` + - The build process automatically: + - Uses the version from `package.json` for artifact names (e.g., `Automaker-1.2.3-x64.zip`) + - Injects the version into the app via Vite's `__APP_VERSION__` constant + - Displays the version below the logo in the sidebar + +4. **Commit the version bump** + - Stage the updated package.json files: + ```bash + git add apps/ui/package.json apps/server/package.json + ``` + - Commit with a release message: + ```bash + git commit -m "chore: release v" + ``` + +5. **Create and push the git tag** + - Create an annotated tag for the release: + ```bash + git tag -a v -m "Release v" + ``` + - Push the commit and tag to remote: + ```bash + git push && git push --tags + ``` + +6. **Verify the release** + - Check that the build completed successfully + - Confirm the version appears correctly in the built artifacts + - The version will be displayed in the app UI below the logo + - Verify the tag is visible on the remote repository + +## Version Centralization + +The version is centralized and synchronized in both `apps/ui/package.json` and `apps/server/package.json`: + +- **Electron builds**: Automatically read from `apps/ui/package.json` via electron-builder's `${version}` variable in `artifactName` +- **App display**: Injected at build time via Vite's `define` config as `__APP_VERSION__` constant (defined in `apps/ui/vite.config.mts`) +- **Server API**: Read from `apps/server/package.json` via `apps/server/src/lib/version.ts` utility (used in health check endpoints) +- **Type safety**: Defined in `apps/ui/src/vite-env.d.ts` as `declare const __APP_VERSION__: string` + +This ensures consistency across: + +- Build artifact names (e.g., `Automaker-1.2.3-x64.zip`) +- App UI display (shown as `v1.2.3` below the logo in `apps/ui/src/components/layout/sidebar/components/automaker-logo.tsx`) +- Server health endpoints (`/` and `/detailed`) +- Package metadata (both UI and server packages stay in sync) diff --git a/jules_branch/.claude/commands/review.md b/jules_branch/.claude/commands/review.md new file mode 100644 index 0000000000000000000000000000000000000000..87a589f50c3876437db2c7368efe47ff55a03446 --- /dev/null +++ b/jules_branch/.claude/commands/review.md @@ -0,0 +1,484 @@ +# Code Review Command + +Comprehensive code review using multiple deep dive agents to analyze git diff for correctness, security, code quality, and tech stack compliance, followed by automated fixes using deepcode agents. + +## Usage + +This command analyzes all changes in the git diff and verifies: + +1. **Invalid code based on tech stack** (HIGHEST PRIORITY) +2. Security vulnerabilities +3. Code quality issues (dirty code) +4. Implementation correctness + +Then automatically fixes any issues found. + +## Instructions + +### Phase 1: Get Git Diff + +1. **Get the current git diff** + + ```bash + git diff HEAD + ``` + + If you need staged changes instead: + + ```bash + git diff --cached + ``` + + Or for a specific commit range: + + ```bash + git diff + ``` + +2. **Get list of changed files** + + ```bash + git diff --name-only HEAD + ``` + +3. **Understand the tech stack** (for validation): + - **Node.js**: >=22.0.0 <23.0.0 + - **TypeScript**: 5.9.3 + - **React**: 19.2.3 + - **Express**: 5.2.1 + - **Electron**: 39.2.7 + - **Vite**: 7.3.0 + - **Vitest**: 4.0.16 + - Check `package.json` files for exact versions + +### Phase 2: Deep Dive Analysis (5 Agents) + +Launch 5 separate deep dive agents, each with a specific focus area. Each agent should be invoked with the `@deepdive` agent and given the git diff along with their specific instructions. + +#### Agent 1: Tech Stack Validation (HIGHEST PRIORITY) + +**Focus:** Verify code is valid for the tech stack + +**Instructions for Agent 1:** + +``` +Analyze the git diff for invalid code based on the tech stack: + +1. **TypeScript/JavaScript Syntax** + - Check for valid TypeScript syntax (no invalid type annotations, correct import/export syntax) + - Verify Node.js API usage is compatible with Node.js >=22.0.0 <23.0.0 + - Check for deprecated APIs or features not available in the Node.js version + - Verify ES module syntax (type: "module" in package.json) + +2. **React 19.2.3 Compatibility** + - Check for deprecated React APIs or patterns + - Verify hooks usage is correct for React 19 + - Check for invalid JSX syntax + - Verify component patterns match React 19 conventions + +3. **Express 5.2.1 Compatibility** + - Check for deprecated Express APIs + - Verify middleware usage is correct for Express 5 + - Check request/response handling patterns + +4. **Type Safety** + - Verify TypeScript types are correctly used + - Check for `any` types that should be properly typed + - Verify type imports/exports are correct + - Check for missing type definitions + +5. **Build System Compatibility** + - Verify Vite-specific code (imports, config) is valid + - Check Electron-specific APIs are used correctly + - Verify module resolution paths are correct + +6. **Package Dependencies** + - Check for imports from packages not in package.json + - Verify version compatibility between dependencies + - Check for circular dependencies + +Provide a detailed report with: +- File paths and line numbers of invalid code +- Specific error description (what's wrong and why) +- Expected vs actual behavior +- Priority level (CRITICAL for build-breaking issues) +``` + +#### Agent 2: Security Vulnerability Scanner + +**Focus:** Security issues and vulnerabilities + +**Instructions for Agent 2:** + +``` +Analyze the git diff for security vulnerabilities: + +1. **Injection Vulnerabilities** + - SQL injection (if applicable) + - Command injection (exec, spawn, etc.) + - Path traversal vulnerabilities + - XSS vulnerabilities in React components + +2. **Authentication & Authorization** + - Missing authentication checks + - Insecure token handling + - Authorization bypasses + - Session management issues + +3. **Data Handling** + - Unsafe deserialization + - Insecure file operations + - Missing input validation + - Sensitive data exposure (secrets, tokens, passwords) + +4. **Dependencies** + - Known vulnerable packages + - Insecure dependency versions + - Missing security patches + +5. **API Security** + - Missing CORS configuration + - Insecure API endpoints + - Missing rate limiting + - Insecure WebSocket connections + +6. **Electron-Specific** + - Insecure IPC communication + - Missing context isolation checks + - Insecure preload scripts + - Missing CSP headers + +Provide a detailed report with: +- Vulnerability type and severity (CRITICAL, HIGH, MEDIUM, LOW) +- File paths and line numbers +- Attack vector description +- Recommended fix approach +``` + +#### Agent 3: Code Quality & Clean Code + +**Focus:** Dirty code, code smells, and quality issues + +**Instructions for Agent 3:** + +``` +Analyze the git diff for code quality issues: + +1. **Code Smells** + - Long functions/methods (>50 lines) + - High cyclomatic complexity + - Duplicate code + - Dead code + - Magic numbers/strings + +2. **Best Practices** + - Missing error handling + - Inconsistent naming conventions + - Poor separation of concerns + - Tight coupling + - Missing comments for complex logic + +3. **Performance Issues** + - Inefficient algorithms + - Memory leaks (event listeners, subscriptions) + - Unnecessary re-renders in React + - Missing memoization where needed + - Inefficient database queries (if applicable) + +4. **Maintainability** + - Hard-coded values + - Missing type definitions + - Inconsistent code style + - Poor file organization + - Missing tests for new code + +5. **React-Specific** + - Missing key props in lists + - Direct state mutations + - Missing cleanup in useEffect + - Unnecessary useState/useEffect + - Prop drilling issues + +Provide a detailed report with: +- Issue type and severity +- File paths and line numbers +- Description of the problem +- Impact on maintainability/performance +- Recommended refactoring approach +``` + +#### Agent 4: Implementation Correctness + +**Focus:** Verify code implements requirements correctly + +**Instructions for Agent 4:** + +``` +Analyze the git diff for implementation correctness: + +1. **Logic Errors** + - Incorrect conditional logic + - Wrong variable usage + - Off-by-one errors + - Race conditions + - Missing null/undefined checks + +2. **Functional Requirements** + - Missing features from requirements + - Incorrect feature implementation + - Edge cases not handled + - Missing validation + +3. **Integration Issues** + - Incorrect API usage + - Wrong data format handling + - Missing error handling for external calls + - Incorrect state management + +4. **Type Errors** + - Type mismatches + - Missing type guards + - Incorrect type assertions + - Unsafe type operations + +5. **Testing Gaps** + - Missing unit tests + - Missing integration tests + - Tests don't cover edge cases + - Tests are incorrect + +Provide a detailed report with: +- Issue description +- File paths and line numbers +- Expected vs actual behavior +- Steps to reproduce (if applicable) +- Recommended fix +``` + +#### Agent 5: Architecture & Design Patterns + +**Focus:** Architectural issues and design pattern violations + +**Instructions for Agent 5:** + +``` +Analyze the git diff for architectural and design issues: + +1. **Architecture Violations** + - Violation of project structure patterns + - Incorrect layer separation + - Missing abstractions + - Tight coupling between modules + +2. **Design Patterns** + - Incorrect pattern usage + - Missing patterns where needed + - Anti-patterns + +3. **Project-Specific Patterns** + - Check against project documentation (docs/ folder) + - Verify route organization (server routes) + - Check provider patterns (server providers) + - Verify component organization (UI components) + +4. **API Design** + - RESTful API violations + - Inconsistent response formats + - Missing error handling + - Incorrect status codes + +5. **State Management** + - Incorrect state management patterns + - Missing state normalization + - Inefficient state updates + +Provide a detailed report with: +- Architectural issue description +- File paths and affected areas +- Impact on system design +- Recommended architectural changes +``` + +### Phase 3: Consolidate Findings + +After all 5 deep dive agents complete their analysis: + +1. **Collect all findings** from each agent +2. **Prioritize issues**: + - CRITICAL: Tech stack invalid code (build-breaking) + - HIGH: Security vulnerabilities, critical logic errors + - MEDIUM: Code quality issues, architectural problems + - LOW: Minor code smells, style issues + +3. **Group by file** to understand impact per file +4. **Create a master report** summarizing all findings + +### Phase 4: Deepcode Fixes (5 Agents) + +Launch 5 deepcode agents to fix the issues found. Each agent should be invoked with the `@deepcode` agent. + +#### Deepcode Agent 1: Fix Tech Stack Invalid Code + +**Priority:** CRITICAL - Fix first + +**Instructions:** + +``` +Fix all invalid code based on tech stack issues identified by Agent 1. + +Focus on: +1. Fixing TypeScript syntax errors +2. Updating deprecated Node.js APIs +3. Fixing React 19 compatibility issues +4. Correcting Express 5 API usage +5. Fixing type errors +6. Resolving build-breaking issues + +After fixes, verify: +- Code compiles without errors +- TypeScript types are correct +- No deprecated API usage +``` + +#### Deepcode Agent 2: Fix Security Vulnerabilities + +**Priority:** HIGH + +**Instructions:** + +``` +Fix all security vulnerabilities identified by Agent 2. + +Focus on: +1. Adding input validation +2. Fixing injection vulnerabilities +3. Securing authentication/authorization +4. Fixing insecure data handling +5. Updating vulnerable dependencies +6. Securing Electron IPC + +After fixes, verify: +- Security vulnerabilities are addressed +- No sensitive data exposure +- Proper authentication/authorization +``` + +#### Deepcode Agent 3: Refactor Dirty Code + +**Priority:** MEDIUM + +**Instructions:** + +``` +Refactor code quality issues identified by Agent 3. + +Focus on: +1. Extracting long functions +2. Reducing complexity +3. Removing duplicate code +4. Adding error handling +5. Improving React component structure +6. Adding missing comments + +After fixes, verify: +- Code follows best practices +- No code smells remain +- Performance optimizations applied +``` + +#### Deepcode Agent 4: Fix Implementation Errors + +**Priority:** HIGH + +**Instructions:** + +``` +Fix implementation correctness issues identified by Agent 4. + +Focus on: +1. Fixing logic errors +2. Adding missing features +3. Handling edge cases +4. Fixing type errors +5. Adding missing tests + +After fixes, verify: +- Logic is correct +- Edge cases handled +- Tests pass +``` + +#### Deepcode Agent 5: Fix Architectural Issues + +**Priority:** MEDIUM + +**Instructions:** + +``` +Fix architectural issues identified by Agent 5. + +Focus on: +1. Correcting architecture violations +2. Applying proper design patterns +3. Fixing API design issues +4. Improving state management +5. Following project patterns + +After fixes, verify: +- Architecture is sound +- Patterns are correctly applied +- Code follows project structure +``` + +### Phase 5: Verification + +After all fixes are complete: + +1. **Run TypeScript compilation check** + + ```bash + npm run build:packages + ``` + +2. **Run linting** + + ```bash + npm run lint + ``` + +3. **Run tests** (if applicable) + + ```bash + npm run test:server + npm run test + ``` + +4. **Verify git diff** shows only intended changes + + ```bash + git diff HEAD + ``` + +5. **Create summary report**: + - Issues found by each agent + - Issues fixed by each agent + - Remaining issues (if any) + - Verification results + +## Workflow Summary + +1. ✅ Get git diff +2. ✅ Launch 5 deep dive agents (parallel analysis) +3. ✅ Consolidate findings and prioritize +4. ✅ Launch 5 deepcode agents (sequential fixes, priority order) +5. ✅ Verify fixes with build/lint/test +6. ✅ Report summary + +## Notes + +- **Tech stack validation is HIGHEST PRIORITY** - invalid code must be fixed first +- Each deep dive agent should work independently and provide comprehensive analysis +- Deepcode agents should fix issues in priority order +- All fixes should maintain existing functionality +- If an agent finds no issues in their domain, they should report "No issues found" +- If fixes introduce new issues, they should be caught in verification phase diff --git a/jules_branch/.claude/commands/thorough.md b/jules_branch/.claude/commands/thorough.md new file mode 100644 index 0000000000000000000000000000000000000000..c69ada0f4ee1f78620a8d80bd9b57136993d81b3 --- /dev/null +++ b/jules_branch/.claude/commands/thorough.md @@ -0,0 +1,45 @@ +When you think you are done, you are NOT done. + +You must run a mandatory 3-pass verification before concluding: + +## Pass 1: Correctness & Functionality + +- [ ] Verify logic matches requirements and specifications +- [ ] Check type safety (TypeScript types are correct and complete) +- [ ] Ensure imports are correct and follow project conventions +- [ ] Verify all functions/classes work as intended +- [ ] Check that return values and side effects are correct +- [ ] Run relevant tests if they exist, or verify testability +- [ ] Confirm integration with existing code works properly + +## Pass 2: Edge Cases & Safety + +- [ ] Handle null/undefined inputs gracefully +- [ ] Validate all user inputs and external data +- [ ] Check error handling (try/catch, error boundaries, etc.) +- [ ] Verify security considerations (no sensitive data exposure, proper auth checks) +- [ ] Test boundary conditions (empty arrays, zero values, max lengths, etc.) +- [ ] Ensure resource cleanup (file handles, connections, timers) +- [ ] Check for potential race conditions or async issues +- [ ] Verify file path security (no directory traversal vulnerabilities) + +## Pass 3: Maintainability & Code Quality + +- [ ] Code follows project style guide and conventions +- [ ] Functions/classes are single-purpose and well-named +- [ ] Remove dead code, unused imports, and console.logs +- [ ] Extract magic numbers/strings into named constants +- [ ] Check for code duplication (DRY principle) +- [ ] Verify appropriate abstraction levels (not over/under-engineered) +- [ ] Add necessary comments for complex logic +- [ ] Ensure consistent error messages and logging +- [ ] Check that code is readable and self-documenting +- [ ] Verify proper separation of concerns + +**For each pass, explicitly report:** + +- What you checked +- Any issues found and how they were fixed +- Any remaining concerns or trade-offs + +Only after completing all three passes with explicit findings may you conclude the work is done. diff --git a/jules_branch/.claude/commands/validate-build.md b/jules_branch/.claude/commands/validate-build.md new file mode 100644 index 0000000000000000000000000000000000000000..790992b158ed5e35e7bb2ac1716009e3b3ea9680 --- /dev/null +++ b/jules_branch/.claude/commands/validate-build.md @@ -0,0 +1,49 @@ +# Project Build and Fix Command + +Run all builds and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run the build** + + ```bash + npm run build + ``` + + This builds all packages and the UI application. + +2. **If the build succeeds**, report success and stop. + +3. **If the build fails**, analyze the failures: + - Note which build step failed and the error messages + - Check for TypeScript compilation errors, missing dependencies, or configuration issues + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the failure**: + - **If the failure is due to intentional changes** (new features, refactoring, dependency updates): + - Fix any TypeScript type errors introduced by the changes + - Update build configuration if needed (e.g., tsconfig.json, vite.config.mts) + - Ensure all new dependencies are properly installed + - Fix import paths or module resolution issues + + - **If the failure appears to be a regression** (broken imports, missing files, configuration errors): + - Fix the source code to restore the build + - Check for accidentally deleted files or broken references + - Verify build configuration files are correct + +5. **Common build issues to check**: + - **TypeScript errors**: Fix type mismatches, missing types, or incorrect imports + - **Missing dependencies**: Run `npm install` if packages are missing + - **Import/export errors**: Fix incorrect import paths or missing exports + - **Build configuration**: Check tsconfig.json, vite.config.mts, or other build configs + - **Package build order**: Ensure `build:packages` completes before building apps + +6. **How to decide if it's intentional vs regression**: + - Look at the git diff and commit messages + - If the change was deliberate and introduced new code that needs fixing → fix the new code + - If the change broke existing functionality that should still build → fix the regression + - When in doubt, ask the user + +7. **After making fixes**, re-run the build to verify everything compiles successfully. + +8. **Report summary** of what was fixed (TypeScript errors, configuration issues, missing dependencies, etc.). diff --git a/jules_branch/.claude/commands/validate-tests.md b/jules_branch/.claude/commands/validate-tests.md new file mode 100644 index 0000000000000000000000000000000000000000..3a19b5d1a507e5999effcb29c75e4ae283663031 --- /dev/null +++ b/jules_branch/.claude/commands/validate-tests.md @@ -0,0 +1,36 @@ +# Project Test and Fix Command + +Run all tests and intelligently fix any failures based on what changed. + +## Instructions + +1. **Run all tests** + + ```bash + npm run test:all + ``` + +2. **If all tests pass**, report success and stop. + +3. **If any tests fail**, analyze the failures: + - Note which tests failed and their error messages + - Run `git diff main` to see what code has changed + +4. **Determine the nature of the change**: + - **If the logic change is intentional** (new feature, refactor, behavior change): + - Update the failing tests to match the new expected behavior + - The tests should reflect what the code NOW does correctly + + - **If the logic change appears to be a bug** (regression, unintended side effect): + - Fix the source code to restore the expected behavior + - Do NOT modify the tests - they are catching a real bug + +5. **How to decide if it's a bug vs intentional change**: + - Look at the git diff and commit messages + - If the change was deliberate and the test expectations are now outdated → update tests + - If the change broke existing functionality that should still work → fix the code + - When in doubt, ask the user + +6. **After making fixes**, re-run the tests to verify everything passes. + +7. **Report summary** of what was fixed (tests updated vs code fixed). diff --git a/jules_branch/.dockerignore b/jules_branch/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..8163526befa0a6bd172e25dca2415881acc4d772 --- /dev/null +++ b/jules_branch/.dockerignore @@ -0,0 +1,19 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +**/dist/ +dist-electron/ +**/dist-electron/ +build/ +**/build/ +.next/ +**/.next/ +.nuxt/ +**/.nuxt/ +out/ +**/out/ +.cache/ +**/.cache/ \ No newline at end of file diff --git a/jules_branch/.geminiignore b/jules_branch/.geminiignore new file mode 100644 index 0000000000000000000000000000000000000000..703ef020123a81478729bb04f4c80b6a3e0e2360 --- /dev/null +++ b/jules_branch/.geminiignore @@ -0,0 +1,14 @@ +# Auto-generated by Automaker to speed up Gemini CLI startup +# Prevents Gemini CLI from scanning large directories during context discovery +.git +node_modules +dist +build +.next +.nuxt +coverage +.automaker +.worktrees +.vscode +.idea +*.lock diff --git a/jules_branch/.github/ISSUE_TEMPLATE/bug_report.yml b/jules_branch/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..af6bb48bd1137527f5fa6a876f06aaeb3afe2b97 --- /dev/null +++ b/jules_branch/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,117 @@ +name: Bug Report +description: File a bug report to help us improve Automaker +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the form below with as much detail as possible. + + - type: dropdown + id: operating-system + attributes: + label: Operating System + description: What operating system are you using? + options: + - macOS + - Windows + - Linux + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: run-mode + attributes: + label: Run Mode + description: How are you running Automaker? + options: + - Electron (Desktop App) + - Web (Browser) + - Docker + default: 0 + validations: + required: true + + - type: input + id: app-version + attributes: + label: App Version + description: What version of Automaker are you using? (e.g., 0.1.0) + placeholder: '0.1.0' + validations: + required: true + + - type: textarea + id: bug-description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: What should have happened? + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: A clear and concise description of what actually happened. + placeholder: What actually happened? + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + placeholder: Drag and drop screenshots here or paste image URLs + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: If applicable, paste relevant logs or error messages. + placeholder: Paste logs here... + render: shell + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context about the problem here. + placeholder: Any additional information that might be helpful... + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this bug hasn't been reported already + required: true + - label: I have provided all required information above + required: true diff --git a/jules_branch/.github/ISSUE_TEMPLATE/feature_request.yml b/jules_branch/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..7cddcaefec9d3f8357c234ff218ceef62b26f794 --- /dev/null +++ b/jules_branch/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,108 @@ +name: Feature Request +description: Suggest a new feature or enhancement for Automaker +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to suggest a feature! Please fill out the form below to help us understand your request. + + - type: dropdown + id: feature-area + attributes: + label: Feature Area + description: Which area of Automaker does this feature relate to? + options: + - UI/UX (User Interface) + - Agent/AI + - Kanban Board + - Git/Worktree Management + - Project Management + - Settings/Configuration + - Documentation + - Performance + - Other + default: 0 + validations: + required: true + + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to your workflow? + options: + - Nice to have + - Would improve my workflow + - Critical for my use case + default: 0 + validations: + required: true + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: Is your feature request related to a problem? Please describe the problem you're trying to solve. + placeholder: A clear and concise description of what the problem is. Ex. I'm always frustrated when... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like to see implemented. + placeholder: A clear and concise description of what you want to happen. + validations: + required: true + + - type: textarea + id: alternatives-considered + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or workarounds you've considered. + placeholder: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + + - type: textarea + id: use-cases + attributes: + label: Use Cases + description: Describe specific scenarios where this feature would be useful. + placeholder: | + 1. When working on... + 2. As a user who needs to... + 3. In situations where... + validations: + required: false + + - type: textarea + id: mockups + attributes: + label: Mockups/Screenshots + description: If applicable, add mockups, wireframes, or screenshots to help illustrate your feature request. + placeholder: Drag and drop images here or paste image URLs + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context, references, or examples about the feature request here. + placeholder: Any additional information that might be helpful... + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched existing issues to ensure this feature hasn't been requested already + required: true + - label: I have provided a clear description of the problem and proposed solution + required: true diff --git a/jules_branch/.github/actions/setup-project/action.yml b/jules_branch/.github/actions/setup-project/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..262c46dc584ddb768d066215e1310570ef123073 --- /dev/null +++ b/jules_branch/.github/actions/setup-project/action.yml @@ -0,0 +1,80 @@ +name: 'Setup Project' +description: 'Common setup steps for CI workflows - checkout, Node.js, dependencies, and native modules' + +inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '22' + check-lockfile: + description: 'Run lockfile lint check for SSH URLs' + required: false + default: 'false' + rebuild-node-pty-path: + description: 'Working directory for node-pty rebuild (empty = root)' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Configure Git for HTTPS + shell: bash + # Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp) + # This is needed because SSH authentication isn't available in CI + run: git config --global url."https://github.com/".insteadOf "git@github.com:" + + - name: Auto-fix SSH URLs in lockfile + if: inputs.check-lockfile == 'true' + shell: bash + # Auto-fix any git+ssh:// URLs in package-lock.json before linting + # This handles cases where npm reintroduces SSH URLs for git dependencies + run: node scripts/fix-lockfile-urls.mjs + + - name: Check for SSH URLs in lockfile + if: inputs.check-lockfile == 'true' + shell: bash + run: npm run lint:lockfile + + - name: Install dependencies + shell: bash + # Use npm install instead of npm ci to correctly resolve platform-specific + # optional dependencies (e.g., @tailwindcss/oxide, lightningcss binaries) + # Skip scripts to avoid electron-builder install-app-deps which uses too much memory + # Use --force to allow platform-specific dev dependencies like dmg-license on non-darwin platforms + run: npm install --ignore-scripts --force + + - name: Install Linux native bindings + if: runner.os == 'Linux' + shell: bash + # Workaround for npm optional dependencies bug (npm/cli#4828) + # Explicitly install Linux bindings needed for build tools + run: | + npm install --no-save --force --ignore-scripts \ + @rollup/rollup-linux-x64-gnu@4.53.3 \ + @tailwindcss/oxide-linux-x64-gnu@4.1.17 + + - name: Build shared packages + shell: bash + # Build shared packages (types, utils, platform, etc.) before apps can use them + run: npm run build:packages + + - name: Rebuild native modules (root) + if: inputs.rebuild-node-pty-path == '' + shell: bash + # Rebuild node-pty and other native modules for Electron + run: npm rebuild node-pty + + - name: Rebuild native modules (workspace) + if: inputs.rebuild-node-pty-path != '' + shell: bash + # Rebuild node-pty and other native modules needed for server + run: npm rebuild node-pty + working-directory: ${{ inputs.rebuild-node-pty-path }} diff --git a/jules_branch/.github/scripts/upload-to-r2.js b/jules_branch/.github/scripts/upload-to-r2.js new file mode 100644 index 0000000000000000000000000000000000000000..b54d4b1916d0b22984cf6aa2c32ab9c3322e3211 --- /dev/null +++ b/jules_branch/.github/scripts/upload-to-r2.js @@ -0,0 +1,355 @@ +const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3'); +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { pipeline } = require('stream/promises'); + +const s3Client = new S3Client({ + region: 'auto', + endpoint: process.env.R2_ENDPOINT, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, + }, +}); + +const BUCKET = process.env.R2_BUCKET_NAME; +const PUBLIC_URL = process.env.R2_PUBLIC_URL; +const VERSION = process.env.RELEASE_VERSION; +const RELEASE_TAG = process.env.RELEASE_TAG || `v${VERSION}`; +const GITHUB_REPO = process.env.GITHUB_REPOSITORY; + +async function fetchExistingReleases() { + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET, + Key: 'releases.json', + }) + ); + const body = await response.Body.transformToString(); + return JSON.parse(body); + } catch (error) { + if (error.name === 'NoSuchKey' || error.$metadata?.httpStatusCode === 404) { + console.log('No existing releases.json found, creating new one'); + return { latestVersion: null, releases: [] }; + } + throw error; + } +} + +async function uploadFile(localPath, r2Key, contentType) { + const fileBuffer = fs.readFileSync(localPath); + const stats = fs.statSync(localPath); + + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: r2Key, + Body: fileBuffer, + ContentType: contentType, + }) + ); + + console.log(`Uploaded: ${r2Key} (${stats.size} bytes)`); + return stats.size; +} + +function findArtifacts(dir, pattern) { + if (!fs.existsSync(dir)) return []; + const files = fs.readdirSync(dir); + return files.filter((f) => pattern.test(f)).map((f) => path.join(dir, f)); +} + +async function checkUrlAccessible(url, maxRetries = 10, initialDelay = 1000) { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const result = await new Promise((resolve, reject) => { + const request = https.get(url, { timeout: 10000 }, (response) => { + const statusCode = response.statusCode; + + // Follow redirects + if ( + statusCode === 302 || + statusCode === 301 || + statusCode === 307 || + statusCode === 308 + ) { + const redirectUrl = response.headers.location; + response.destroy(); + if (!redirectUrl) { + resolve({ + accessible: false, + statusCode, + error: 'Redirect without location header', + }); + return; + } + // Follow the redirect URL + return https + .get(redirectUrl, { timeout: 10000 }, (redirectResponse) => { + const redirectStatus = redirectResponse.statusCode; + const contentType = redirectResponse.headers['content-type'] || ''; + // Check if it's actually a file (zip/tar.gz) and not HTML + const isFile = + contentType.includes('application/zip') || + contentType.includes('application/gzip') || + contentType.includes('application/x-gzip') || + contentType.includes('application/x-tar') || + redirectUrl.includes('.zip') || + redirectUrl.includes('.tar.gz'); + const isGood = redirectStatus >= 200 && redirectStatus < 300 && isFile; + redirectResponse.destroy(); + resolve({ + accessible: isGood, + statusCode: redirectStatus, + finalUrl: redirectUrl, + contentType, + }); + }) + .on('error', (error) => { + resolve({ + accessible: false, + statusCode, + error: error.message, + }); + }) + .on('timeout', function () { + this.destroy(); + resolve({ + accessible: false, + statusCode, + error: 'Timeout following redirect', + }); + }); + } + + // Check if status is good (200-299 range) and it's actually a file + const contentType = response.headers['content-type'] || ''; + const isFile = + contentType.includes('application/zip') || + contentType.includes('application/gzip') || + contentType.includes('application/x-gzip') || + contentType.includes('application/x-tar') || + url.includes('.zip') || + url.includes('.tar.gz'); + const isGood = statusCode >= 200 && statusCode < 300 && isFile; + response.destroy(); + resolve({ accessible: isGood, statusCode, contentType }); + }); + + request.on('error', (error) => { + resolve({ + accessible: false, + statusCode: null, + error: error.message, + }); + }); + + request.on('timeout', () => { + request.destroy(); + resolve({ + accessible: false, + statusCode: null, + error: 'Request timeout', + }); + }); + }); + + if (result.accessible) { + if (attempt > 0) { + console.log( + `✓ URL ${url} is now accessible after ${attempt} retries (status: ${result.statusCode})` + ); + } else { + console.log(`✓ URL ${url} is accessible (status: ${result.statusCode})`); + } + return result.finalUrl || url; // Return the final URL (after redirects) if available + } else { + const errorMsg = result.error ? ` - ${result.error}` : ''; + const statusMsg = result.statusCode ? ` (status: ${result.statusCode})` : ''; + const contentTypeMsg = result.contentType ? ` [content-type: ${result.contentType}]` : ''; + console.log(`✗ URL ${url} not accessible${statusMsg}${contentTypeMsg}${errorMsg}`); + } + } catch (error) { + console.log(`✗ URL ${url} check failed: ${error.message}`); + } + + if (attempt < maxRetries - 1) { + const delay = initialDelay * Math.pow(2, attempt); + console.log(` Retrying in ${delay}ms... (attempt ${attempt + 1}/${maxRetries})`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw new Error(`URL ${url} is not accessible after ${maxRetries} attempts`); +} + +async function downloadFromGitHub(url, outputPath) { + return new Promise((resolve, reject) => { + const request = https.get(url, { timeout: 30000 }, (response) => { + const statusCode = response.statusCode; + + // Follow redirects (all redirect types) + if (statusCode === 301 || statusCode === 302 || statusCode === 307 || statusCode === 308) { + const redirectUrl = response.headers.location; + response.destroy(); + if (!redirectUrl) { + reject(new Error(`Redirect without location header for ${url}`)); + return; + } + // Resolve relative redirects + const finalRedirectUrl = redirectUrl.startsWith('http') + ? redirectUrl + : new URL(redirectUrl, url).href; + console.log(` Following redirect: ${finalRedirectUrl}`); + return downloadFromGitHub(finalRedirectUrl, outputPath).then(resolve).catch(reject); + } + + if (statusCode !== 200) { + response.destroy(); + reject(new Error(`Failed to download ${url}: ${statusCode} ${response.statusMessage}`)); + return; + } + + const fileStream = fs.createWriteStream(outputPath); + response.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(); + resolve(); + }); + fileStream.on('error', (error) => { + response.destroy(); + reject(error); + }); + }); + + request.on('error', reject); + request.on('timeout', () => { + request.destroy(); + reject(new Error(`Request timeout for ${url}`)); + }); + }); +} + +async function main() { + const artifactsDir = 'artifacts'; + const tempDir = path.join(artifactsDir, 'temp'); + + // Create temp directory for downloaded GitHub archives + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + // Download source archives from GitHub + const githubZipUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.zip`; + const githubTarGzUrl = `https://github.com/${GITHUB_REPO}/archive/refs/tags/${RELEASE_TAG}.tar.gz`; + + const sourceZipPath = path.join(tempDir, `automaker-${VERSION}.zip`); + const sourceTarGzPath = path.join(tempDir, `automaker-${VERSION}.tar.gz`); + + console.log(`Waiting for source archives to be available on GitHub...`); + console.log(` ZIP: ${githubZipUrl}`); + console.log(` TAR.GZ: ${githubTarGzUrl}`); + + // Wait for archives to be accessible with exponential backoff + // This returns the final URL after following redirects + const finalZipUrl = await checkUrlAccessible(githubZipUrl); + const finalTarGzUrl = await checkUrlAccessible(githubTarGzUrl); + + console.log(`Downloading source archives from GitHub...`); + await downloadFromGitHub(finalZipUrl, sourceZipPath); + await downloadFromGitHub(finalTarGzUrl, sourceTarGzPath); + + console.log(`Downloaded source archives successfully`); + + // Find all artifacts + const artifacts = { + windows: findArtifacts(path.join(artifactsDir, 'windows-builds'), /\.exe$/), + macos: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-x64\.dmg$/), + macosArm: findArtifacts(path.join(artifactsDir, 'macos-builds'), /-arm64\.dmg$/), + linux: findArtifacts(path.join(artifactsDir, 'linux-builds'), /\.AppImage$/), + sourceZip: [sourceZipPath], + sourceTarGz: [sourceTarGzPath], + }; + + console.log('Found artifacts:'); + for (const [platform, files] of Object.entries(artifacts)) { + console.log( + ` ${platform}: ${files.length > 0 ? files.map((f) => path.basename(f)).join(', ') : 'none'}` + ); + } + + // Upload each artifact to R2 + const assets = {}; + const contentTypes = { + windows: 'application/x-msdownload', + macos: 'application/x-apple-diskimage', + macosArm: 'application/x-apple-diskimage', + linux: 'application/x-executable', + sourceZip: 'application/zip', + sourceTarGz: 'application/gzip', + }; + + for (const [platform, files] of Object.entries(artifacts)) { + if (files.length === 0) { + console.warn(`Warning: No artifact found for ${platform}`); + continue; + } + + // Use the first matching file for each platform + const localPath = files[0]; + const filename = path.basename(localPath); + const r2Key = `releases/${VERSION}/${filename}`; + const size = await uploadFile(localPath, r2Key, contentTypes[platform]); + + assets[platform] = { + url: `${PUBLIC_URL}/releases/${VERSION}/${filename}`, + filename, + size, + arch: + platform === 'macosArm' + ? 'arm64' + : platform === 'sourceZip' || platform === 'sourceTarGz' + ? 'source' + : 'x64', + }; + } + + // Fetch and update releases.json + const releasesData = await fetchExistingReleases(); + + const newRelease = { + version: VERSION, + date: new Date().toISOString(), + assets, + githubReleaseUrl: `https://github.com/${GITHUB_REPO}/releases/tag/${RELEASE_TAG}`, + }; + + // Remove existing entry for this version if re-running + releasesData.releases = releasesData.releases.filter((r) => r.version !== VERSION); + + // Prepend new release + releasesData.releases.unshift(newRelease); + releasesData.latestVersion = VERSION; + + // Upload updated releases.json + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: 'releases.json', + Body: JSON.stringify(releasesData, null, 2), + ContentType: 'application/json', + CacheControl: 'public, max-age=60', + }) + ); + + console.log('Successfully updated releases.json'); + console.log(`Latest version: ${VERSION}`); + console.log(`Total releases: ${releasesData.releases.length}`); +} + +main().catch((err) => { + console.error('Failed to upload to R2:', err); + process.exit(1); +}); diff --git a/jules_branch/.github/workflows/claude.yml b/jules_branch/.github/workflows/claude.yml new file mode 100644 index 0000000000000000000000000000000000000000..9471a0591de36ca20ab974144e773510bffe15e5 --- /dev/null +++ b/jules_branch/.github/workflows/claude.yml @@ -0,0 +1,49 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/jules_branch/.github/workflows/e2e-tests.yml b/jules_branch/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..4f682a0bd8122fea38acc13e346d21423837c12c --- /dev/null +++ b/jules_branch/.github/workflows/e2e-tests.yml @@ -0,0 +1,202 @@ +name: E2E Tests + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # shardIndex: [1, 2, 3] + # shardTotal: [3] + shardIndex: [1] + shardTotal: [1] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + rebuild-node-pty-path: 'apps/server' + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: apps/ui + + - name: Build server + run: npm run build --workspace=apps/server + + - name: Set up Git user + run: | + git config --global user.name "GitHub CI" + git config --global user.email "ci@example.com" + + - name: Start backend server + run: | + echo "Starting backend server..." + # Start server in background and save PID + npm run start --workspace=apps/server > backend.log 2>&1 & + SERVER_PID=$! + echo "Server started with PID: $SERVER_PID" + echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV + + env: + PORT: 3108 + TEST_SERVER_PORT: 3108 + NODE_ENV: test + # Use a deterministic API key so Playwright can log in reliably + AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + # Reduce log noise in CI + AUTOMAKER_HIDE_API_KEY: 'true' + # Avoid real API calls during CI + AUTOMAKER_MOCK_AGENT: 'true' + # Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true' + + - name: Wait for backend server + run: | + echo "Waiting for backend server to be ready..." + + # Check if server process is running + if [ -z "$SERVER_PID" ]; then + echo "ERROR: Server PID not found in environment" + cat backend.log 2>/dev/null || echo "No backend log found" + exit 1 + fi + + # Check if process is actually running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process $SERVER_PID is not running!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Recent system logs ===" + dmesg 2>/dev/null | tail -20 || echo "No dmesg available" + exit 1 + fi + + # Wait for health endpoint + for i in {1..60}; do + if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then + echo "Backend server is ready!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "Health check response:" + curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')" + exit 0 + fi + + # Check if server process is still running + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "ERROR: Server process died during wait!" + echo "=== Backend logs ===" + cat backend.log + exit 1 + fi + + echo "Waiting... ($i/60)" + sleep 1 + done + + echo "ERROR: Backend server failed to start within 60 seconds!" + echo "=== Backend logs ===" + cat backend.log + echo "" + echo "=== Process status ===" + ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" + netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening" + lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use" + echo "" + echo "=== Health endpoint test ===" + curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed" + + # Kill the server process if it's still hanging + if kill -0 $SERVER_PID 2>/dev/null; then + echo "" + echo "Killing stuck server process..." + kill -9 $SERVER_PID 2>/dev/null || true + fi + + exit 1 + + - name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + # Playwright automatically starts the Vite frontend via webServer config + # (see apps/ui/playwright.config.ts) - no need to start it manually + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + working-directory: apps/ui + env: + CI: true + VITE_SKIP_SETUP: 'true' + # Keep UI-side login/defaults consistent + AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + # Backend is already started above - Playwright config sets + # AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend. + # Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes + # a cookie domain mismatch (cookies are bound to 127.0.0.1, but + # VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost). + TEST_USE_EXTERNAL_BACKEND: 'true' + TEST_SERVER_PORT: 3108 + + - name: Print backend logs on failure + if: failure() + run: | + echo "=== E2E Tests Failed - Backend Logs ===" + cat backend.log 2>/dev/null || echo "No backend log found" + echo "" + echo "=== Process status at failure ===" + ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found" + echo "" + echo "=== Port status ===" + netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening" + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} + path: apps/ui/playwright-report/ + retention-days: 7 + + - name: Upload test results (screenshots, traces, videos) + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} + path: | + apps/ui/test-results/ + retention-days: 7 + if-no-files-found: ignore + + - name: Upload blob report for merging + uses: actions/upload-artifact@v4 + if: always() + with: + name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} + path: apps/ui/blob-report/ + retention-days: 1 + if-no-files-found: ignore + + - name: Cleanup - Kill backend server + if: always() + run: | + if [ -n "$SERVER_PID" ]; then + echo "Cleaning up backend server (PID: $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null || true + kill -9 $SERVER_PID 2>/dev/null || true + echo "Backend server cleanup complete" + fi diff --git a/jules_branch/.github/workflows/format-check.yml b/jules_branch/.github/workflows/format-check.yml new file mode 100644 index 0000000000000000000000000000000000000000..d6904979b7ed3c8d1b374376c0e13910f140a75f --- /dev/null +++ b/jules_branch/.github/workflows/format-check.yml @@ -0,0 +1,31 @@ +name: Format Check + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + +jobs: + format: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm install --ignore-scripts --force + + - name: Check formatting + run: npm run format:check diff --git a/jules_branch/.github/workflows/pr-check.yml b/jules_branch/.github/workflows/pr-check.yml new file mode 100644 index 0000000000000000000000000000000000000000..4311eeb0b2addf27f5b687933a3a59e541ad97ff --- /dev/null +++ b/jules_branch/.github/workflows/pr-check.yml @@ -0,0 +1,26 @@ +name: PR Build Check + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + + - name: Run build:electron (dir only - faster CI) + run: npm run build:electron:dir diff --git a/jules_branch/.github/workflows/release.yml b/jules_branch/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..f4fe01f34d5fc05c1cc6d73aef07a91c66d72fdd --- /dev/null +++ b/jules_branch/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: Release Build + +on: + release: + types: [published] + +permissions: + contents: write + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + shell: bash + run: | + # Remove 'v' prefix if present (e.g., "v1.2.3" -> "1.2.3") + VERSION="${{ github.event.release.tag_name }}" + VERSION="${VERSION#v}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Extracted version: ${VERSION}" + + - name: Update package.json version + shell: bash + run: | + node apps/ui/scripts/update-version.mjs "${{ steps.version.outputs.version }}" + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + + - name: Install RPM build tools (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: sudo apt-get update && sudo apt-get install -y rpm + + - name: Build Electron app (macOS) + if: matrix.os == 'macos-latest' + shell: bash + run: npm run build:electron:mac --workspace=apps/ui + env: + CSC_IDENTITY_AUTO_DISCOVERY: false + + - name: Build Electron app (Windows) + if: matrix.os == 'windows-latest' + shell: bash + run: npm run build:electron:win --workspace=apps/ui + + - name: Build Electron app (Linux) + if: matrix.os == 'ubuntu-latest' + shell: bash + run: npm run build:electron:linux --workspace=apps/ui + + - name: Upload macOS artifacts + if: matrix.os == 'macos-latest' + uses: actions/upload-artifact@v4 + with: + name: macos-builds + path: | + apps/ui/release/*.dmg + apps/ui/release/*.zip + if-no-files-found: error + retention-days: 30 + + - name: Upload Windows artifacts + if: matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: windows-builds + path: apps/ui/release/*.exe + if-no-files-found: error + retention-days: 30 + + - name: Upload Linux artifacts + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + with: + name: linux-builds + path: | + apps/ui/release/*.AppImage + apps/ui/release/*.deb + apps/ui/release/*.rpm + if-no-files-found: error + retention-days: 30 + + upload: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: macos-builds + path: artifacts/macos-builds + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: windows-builds + path: artifacts/windows-builds + + - name: Download Linux artifacts + uses: actions/download-artifact@v4 + with: + name: linux-builds + path: artifacts/linux-builds + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + fail_on_unmatched_files: true + files: | + artifacts/macos-builds/*.dmg + artifacts/macos-builds/*.zip + artifacts/windows-builds/*.exe + artifacts/linux-builds/*.AppImage + artifacts/linux-builds/*.deb + artifacts/linux-builds/*.rpm + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/jules_branch/.github/workflows/security-audit.yml b/jules_branch/.github/workflows/security-audit.yml new file mode 100644 index 0000000000000000000000000000000000000000..7da30c5da9b4f2df0b83c585c01cdff8f8b983cd --- /dev/null +++ b/jules_branch/.github/workflows/security-audit.yml @@ -0,0 +1,30 @@ +name: Security Audit + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + schedule: + # Run weekly on Mondays at 9 AM UTC + - cron: '0 9 * * 1' + +jobs: + audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + + - name: Run npm audit + run: npm audit --audit-level=critical + continue-on-error: false diff --git a/jules_branch/.github/workflows/test.yml b/jules_branch/.github/workflows/test.yml new file mode 100644 index 0000000000000000000000000000000000000000..dacea6312a962be1999c8086ebd644c4c710f794 --- /dev/null +++ b/jules_branch/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Test Suite + +on: + pull_request: + branches: + - '*' + push: + branches: + - main + - master + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup project + uses: ./.github/actions/setup-project + with: + check-lockfile: 'true' + rebuild-node-pty-path: 'apps/server' + + - name: Run package tests + run: npm run test:packages + env: + NODE_ENV: test + + - name: Run server tests with coverage + run: npm run test:server:coverage + env: + NODE_ENV: test + + # - name: Upload coverage reports + # uses: codecov/codecov-action@v4 + # if: always() + # with: + # files: ./apps/server/coverage/coverage-final.json + # flags: server + # name: server-coverage + # env: + # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/jules_branch/.gitignore b/jules_branch/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1c2d1a3243cb076a2cba8d1eb37f8c0b4a04215b --- /dev/null +++ b/jules_branch/.gitignore @@ -0,0 +1,116 @@ +#added by trueheads > will remove once supercombo adds multi-os support +launch.sh + +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +out/ +.next/ +.turbo/ + +# Automaker +.automaker/images/ +.automaker/ +/.automaker/* +/.automaker/ + +.worktrees/ + +/logs +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# OS-specific files +.DS_Store +.DS_Store? +._* +Thumbs.db +ehthumbs.db +Desktop.ini + +# IDE/Editor configs +.vscode/ +.idea/ +*.sublime-workspace +*.sublime-project + +# Editor backup/temp files +*~ +*.bak +*.backup +*.orig +*.swp +*.swo +*.tmp +*.temp + +# Local settings (user-specific) +*.local.json + +# Application state/backup +backup.json + +# Test artifacts +test-results/ +coverage/ +.nyc_output/ +*.lcov +playwright-report/ +blob-report/ +test/**/test-project-[0-9]*/ +test/opus-thinking-*/ +test/agent-session-test-*/ +test/feature-backlog-test-*/ +test/running-task-display-test-*/ +test/agent-output-modal-responsive-*/ +test/fixtures/ +test/board-bg-test-*/ +test/edit-feature-test-*/ +test/open-project-test-*/ + + +# Environment files (keep .example) +.env +.env.local +.env.*.local +!.env.example +!.env.local.example + +# Codex config (contains API keys) +.codex/config.toml + +# TypeScript +*.tsbuildinfo + +# Misc +*.pem + +docker-compose.override.yml +.claude/docker-compose.override.yml +.claude/hans/ + +pnpm-lock.yaml +yarn.lock + +# Fork-specific workflow files (should never be committed) +DEVELOPMENT_WORKFLOW.md +check-sync.sh +# API key files +data/.api-key +data/credentials.json +data/ +.codex/ + +# GSD planning docs (local-only) +.planning/ +.mcp.json +.planning +.bg-shell/ \ No newline at end of file diff --git a/jules_branch/.husky/pre-commit b/jules_branch/.husky/pre-commit new file mode 100644 index 0000000000000000000000000000000000000000..4c156c16804f5d229c0bdf3bee05c12ace9c485c --- /dev/null +++ b/jules_branch/.husky/pre-commit @@ -0,0 +1,63 @@ +#!/usr/bin/env sh + +# Try to load nvm if available (optional - works without it too) +if [ -z "$NVM_DIR" ]; then + # Check for Herd's nvm first (macOS with Herd) + if [ -s "$HOME/Library/Application Support/Herd/config/nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/Library/Application Support/Herd/config/nvm" + # Then check standard nvm location + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + fi +fi + +# Source nvm if found (silently skip if not available) +[ -n "$NVM_DIR" ] && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 2>/dev/null + +# Load node version from .nvmrc if using nvm (silently skip if nvm not available or fails) +if [ -f .nvmrc ] && command -v nvm >/dev/null 2>&1; then + # Check if Unix nvm was sourced (it's a shell function with NVM_DIR set) + if [ -n "$NVM_DIR" ] && type nvm 2>/dev/null | grep -q "function"; then + # Unix nvm: reads .nvmrc automatically + nvm use >/dev/null 2>&1 || true + else + # nvm-windows: needs explicit version from .nvmrc + NODE_VERSION=$(cat .nvmrc | tr -d '[:space:]') + if [ -n "$NODE_VERSION" ]; then + nvm use "$NODE_VERSION" >/dev/null 2>&1 || true + fi + fi +fi + +# Ensure common system paths are in PATH (for systems without nvm) +# This helps find node/npm installed via Homebrew, system packages, etc. +if [ -n "$WINDIR" ]; then + export PATH="$PATH:/c/Program Files/nodejs:/c/Program Files (x86)/nodejs" + export PATH="$PATH:$APPDATA/npm:$LOCALAPPDATA/Programs/nodejs" +else + export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin" +fi + +# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed +# This prevents CI failures from SSH URLs that npm introduces for git dependencies +if git diff --cached --name-only | grep -q "^package-lock.json$"; then + if command -v node >/dev/null 2>&1; then + if grep -q "git+ssh://" package-lock.json 2>/dev/null; then + echo "Fixing git+ssh:// URLs in package-lock.json..." + node scripts/fix-lockfile-urls.mjs + git add package-lock.json + fi + fi +fi + +# Run lint-staged - works with or without nvm +# Prefer npx, fallback to npm exec, both work with system-installed Node.js +if command -v npx >/dev/null 2>&1; then + npx lint-staged +elif command -v npm >/dev/null 2>&1; then + npm exec -- lint-staged +else + echo "Error: Neither npx nor npm found in PATH." + echo "Please ensure Node.js is installed (via nvm, Homebrew, system package manager, etc.)" + exit 1 +fi diff --git a/jules_branch/.npmrc b/jules_branch/.npmrc new file mode 100644 index 0000000000000000000000000000000000000000..86aca125a81766646e00fa7bc871382166525bc5 --- /dev/null +++ b/jules_branch/.npmrc @@ -0,0 +1,16 @@ +# Cross-platform compatibility for Tailwind CSS v4 and lightningcss +# These packages use platform-specific optional dependencies that npm +# automatically resolves based on your OS (macOS, Linux, Windows, WSL) +# +# IMPORTANT: When switching platforms or getting platform mismatch errors: +# 1. Delete node_modules: rm -rf node_modules apps/*/node_modules +# 2. Run: npm install +# +# In CI/CD: Use "npm install" instead of "npm ci" to allow npm to resolve +# the correct platform-specific binaries at install time. + +# Include bindings for all platforms in package-lock.json to support CI/CD +# This ensures Linux, macOS, and Windows bindings are all present +# NOTE: Only enable when regenerating package-lock.json, then comment out to keep installs fast +# supportedArchitectures.os=linux,darwin,win32 +# supportedArchitectures.cpu=x64,arm64 diff --git a/jules_branch/.nvmrc b/jules_branch/.nvmrc new file mode 100644 index 0000000000000000000000000000000000000000..42126c0545a2d2bd95d9c05030272464fabb93a3 --- /dev/null +++ b/jules_branch/.nvmrc @@ -0,0 +1,2 @@ +22 + diff --git a/jules_branch/.prettierignore b/jules_branch/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..50ff1306c6aa777c9f4ad1cdcc6d221edaa82e5f --- /dev/null +++ b/jules_branch/.prettierignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +build/ +out/ +.next/ +.turbo/ +release/ + +# Automaker +.automaker/ + +# Logs +logs/ +*.log + +# Lock files +package-lock.json +pnpm-lock.yaml + +# Generated files +*.min.js +*.min.css +routeTree.gen.ts +apps/ui/src/routeTree.gen.ts + +# Test artifacts +test-results/ +coverage/ +playwright-report/ +blob-report/ + +# IDE/Editor +.vscode/ +.idea/ + +# Electron +dist-electron/ +server-bundle/ diff --git a/jules_branch/.prettierrc b/jules_branch/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..b6b0fde5e3f8b7b7db602b66349f51334b30ca2d --- /dev/null +++ b/jules_branch/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/jules_branch/CLAUDE.md b/jules_branch/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..84dd1fbb1e4edefd618ea6a6261f2cf04e18c9af --- /dev/null +++ b/jules_branch/CLAUDE.md @@ -0,0 +1,176 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Automaker is an autonomous AI development studio built as an npm workspace monorepo. It provides a Kanban-based workflow where AI agents (powered by Claude Agent SDK) implement features in isolated git worktrees. + +## Common Commands + +```bash +# Development +npm run dev # Interactive launcher (choose web or electron) +npm run dev:web # Web browser mode (localhost:3007) +npm run dev:electron # Desktop app mode +npm run dev:electron:debug # Desktop with DevTools open + +# Building +npm run build # Build web application +npm run build:packages # Build all shared packages (required before other builds) +npm run build:electron # Build desktop app for current platform +npm run build:server # Build server only + +# Testing +npm run test # E2E tests (Playwright, headless) +npm run test:headed # E2E tests with browser visible +npm run test:server # Server unit tests (Vitest) +npm run test:packages # All shared package tests +npm run test:all # All tests (packages + server) + +# Single test file +npm run test:server -- tests/unit/specific.test.ts + +# Linting and formatting +npm run lint # ESLint +npm run format # Prettier write +npm run format:check # Prettier check +``` + +## Architecture + +### Monorepo Structure + +``` +automaker/ +├── apps/ +│ ├── ui/ # React + Vite + Electron frontend (port 3007) +│ └── server/ # Express + WebSocket backend (port 3008) +└── libs/ # Shared packages (@automaker/*) + ├── types/ # Core TypeScript definitions (no dependencies) + ├── utils/ # Logging, errors, image processing, context loading + ├── prompts/ # AI prompt templates + ├── platform/ # Path management, security, process spawning + ├── model-resolver/ # Claude model alias resolution + ├── dependency-resolver/ # Feature dependency ordering + └── git-utils/ # Git operations & worktree management +``` + +### Package Dependency Chain + +Packages can only depend on packages above them: + +``` +@automaker/types (no dependencies) + ↓ +@automaker/utils, @automaker/prompts, @automaker/platform, @automaker/model-resolver, @automaker/dependency-resolver + ↓ +@automaker/git-utils + ↓ +@automaker/server, @automaker/ui +``` + +### Key Technologies + +- **Frontend**: React 19, Vite 7, Electron 39, TanStack Router, Zustand 5, Tailwind CSS 4 +- **Backend**: Express 5, WebSocket (ws), Claude Agent SDK, node-pty +- **Testing**: Playwright (E2E), Vitest (unit) + +### Server Architecture + +The server (`apps/server/src/`) follows a modular pattern: + +- `routes/` - Express route handlers organized by feature (agent, features, auto-mode, worktree, etc.) +- `services/` - Business logic (AgentService, AutoModeService, FeatureLoader, TerminalService) +- `providers/` - AI provider abstraction (currently Claude via Claude Agent SDK) +- `lib/` - Utilities (events, auth, worktree metadata) + +### Frontend Architecture + +The UI (`apps/ui/src/`) uses: + +- `routes/` - TanStack Router file-based routing +- `components/views/` - Main view components (board, settings, terminal, etc.) +- `store/` - Zustand stores with persistence (app-store.ts, setup-store.ts) +- `hooks/` - Custom React hooks +- `lib/` - Utilities and API client + +## Data Storage + +### Per-Project Data (`.automaker/`) + +``` +.automaker/ +├── features/ # Feature JSON files and images +│ └── {featureId}/ +│ ├── feature.json +│ ├── agent-output.md +│ └── images/ +├── context/ # Context files for AI agents (CLAUDE.md, etc.) +├── settings.json # Project-specific settings +├── spec.md # Project specification +└── analysis.json # Project structure analysis +``` + +### Global Data (`DATA_DIR`, default `./data`) + +``` +data/ +├── settings.json # Global settings, profiles, shortcuts +├── credentials.json # API keys +├── sessions-metadata.json # Chat session metadata +└── agent-sessions/ # Conversation histories +``` + +## Import Conventions + +Always import from shared packages, never from old paths: + +```typescript +// ✅ Correct +import type { Feature, ExecuteOptions } from '@automaker/types'; +import { createLogger, classifyError } from '@automaker/utils'; +import { getEnhancementPrompt } from '@automaker/prompts'; +import { getFeatureDir, ensureAutomakerDir } from '@automaker/platform'; +import { resolveModelString } from '@automaker/model-resolver'; +import { resolveDependencies } from '@automaker/dependency-resolver'; +import { getGitRepositoryDiffs } from '@automaker/git-utils'; + +// ❌ Never import from old paths +import { Feature } from '../services/feature-loader'; // Wrong +import { createLogger } from '../lib/logger'; // Wrong +``` + +## Key Patterns + +### Event-Driven Architecture + +All server operations emit events that stream to the frontend via WebSocket. Events are created using `createEventEmitter()` from `lib/events.ts`. + +### Git Worktree Isolation + +Each feature executes in an isolated git worktree, created via `@automaker/git-utils`. This protects the main branch during AI agent execution. + +### Context Files + +Project-specific rules are stored in `.automaker/context/` and automatically loaded into agent prompts via `loadContextFiles()` from `@automaker/utils`. + +### Model Resolution + +Use `resolveModelString()` from `@automaker/model-resolver` to convert model aliases: + +- `haiku` → `claude-haiku-4-5` +- `sonnet` → `claude-sonnet-4-20250514` +- `opus` → `claude-opus-4-6` + +## Environment Variables + +- `ANTHROPIC_API_KEY` - Anthropic API key (or use Claude Code CLI auth) +- `HOST` - Host to bind server to (default: 0.0.0.0) +- `HOSTNAME` - Hostname for user-facing URLs (default: localhost) +- `PORT` - Server port (default: 3008) +- `DATA_DIR` - Data storage directory (default: ./data) +- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory +- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing +- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (disabled when NODE_ENV=production) +- `VITE_HOSTNAME` - Hostname for frontend API URLs (default: localhost) diff --git a/jules_branch/CONTRIBUTING.md b/jules_branch/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..61ad83f43b10cd531a1cf25826761fa2de721a6c --- /dev/null +++ b/jules_branch/CONTRIBUTING.md @@ -0,0 +1,740 @@ +# Contributing to Automaker + +Thank you for your interest in contributing to Automaker! We're excited to have you join our community of developers building the future of autonomous AI development. + +Automaker is an autonomous AI development studio that provides a Kanban-based workflow where AI agents implement features in isolated git worktrees. Whether you're fixing bugs, adding features, improving documentation, or suggesting ideas, your contributions help make this project better for everyone. + +This guide will help you get started with contributing to Automaker. Please take a moment to read through these guidelines to ensure a smooth contribution process. + +## Contribution License Agreement + +**Important:** By submitting, pushing, or contributing any code, documentation, pull requests, issues, or other materials to the Automaker project, you agree to assign all right, title, and interest in and to your contributions, including all copyrights, patents, and other intellectual property rights, to the Core Contributors of Automaker. This assignment is irrevocable and includes the right to use, modify, distribute, and monetize your contributions in any manner. + +**You understand and agree that you will have no right to receive any royalties, compensation, or other financial benefits from any revenue, income, or commercial use generated from your contributed code or any derivative works thereof.** All contributions are made without expectation of payment or financial return. + +For complete details on contribution terms and rights assignment, please review [Section 5 (CONTRIBUTIONS AND RIGHTS ASSIGNMENT) of the LICENSE](LICENSE#5-contributions-and-rights-assignment). + +## Table of Contents + +- [Contributing to Automaker](#contributing-to-automaker) + - [Table of Contents](#table-of-contents) + - [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Fork and Clone](#fork-and-clone) + - [Development Setup](#development-setup) + - [Project Structure](#project-structure) + - [Pull Request Process](#pull-request-process) + - [Branching Strategy (RC Branches)](#branching-strategy-rc-branches) + - [Branch Naming Convention](#branch-naming-convention) + - [Commit Message Format](#commit-message-format) + - [Submitting a Pull Request](#submitting-a-pull-request) + - [1. Prepare Your Changes](#1-prepare-your-changes) + - [2. Run Pre-submission Checks](#2-run-pre-submission-checks) + - [3. Push Your Changes](#3-push-your-changes) + - [4. Open a Pull Request](#4-open-a-pull-request) + - [PR Requirements Checklist](#pr-requirements-checklist) + - [Review Process](#review-process) + - [What to Expect](#what-to-expect) + - [Review Focus Areas](#review-focus-areas) + - [Responding to Feedback](#responding-to-feedback) + - [Approval Criteria](#approval-criteria) + - [Getting Help](#getting-help) + - [Code Style Guidelines](#code-style-guidelines) + - [Testing Requirements](#testing-requirements) + - [Running Tests](#running-tests) + - [Test Frameworks](#test-frameworks) + - [End-to-End Tests (Playwright)](#end-to-end-tests-playwright) + - [Unit Tests (Vitest)](#unit-tests-vitest) + - [Writing Tests](#writing-tests) + - [When to Write Tests](#when-to-write-tests) + - [CI/CD Pipeline](#cicd-pipeline) + - [CI Checks](#ci-checks) + - [CI Testing Environment](#ci-testing-environment) + - [Viewing CI Results](#viewing-ci-results) + - [Common CI Failures](#common-ci-failures) + - [Coverage Requirements](#coverage-requirements) + - [Issue Reporting](#issue-reporting) + - [Bug Reports](#bug-reports) + - [Before Reporting](#before-reporting) + - [Bug Report Template](#bug-report-template) + - [Feature Requests](#feature-requests) + - [Before Requesting](#before-requesting) + - [Feature Request Template](#feature-request-template) + - [Security Issues](#security-issues) + +--- + +## Getting Started + +### Prerequisites + +Before contributing to Automaker, ensure you have the following installed on your system: + +- **Node.js 18+** (tested with Node.js 22) + - Download from [nodejs.org](https://nodejs.org/) + - Verify installation: `node --version` +- **npm** (comes with Node.js) + - Verify installation: `npm --version` +- **Git** for version control + - Verify installation: `git --version` +- **Claude Code CLI** or **Anthropic API Key** (for AI agent functionality) + - Required to run the AI development features + +**Optional but recommended:** + +- A code editor with TypeScript support (VS Code recommended) +- GitHub CLI (`gh`) for easier PR management + +### Fork and Clone + +1. **Fork the repository** on GitHub + - Navigate to [https://github.com/AutoMaker-Org/automaker](https://github.com/AutoMaker-Org/automaker) + - Click the "Fork" button in the top-right corner + - This creates your own copy of the repository + +2. **Clone your fork locally** + + ```bash + git clone https://github.com/YOUR_USERNAME/automaker.git + cd automaker + ``` + +3. **Add the upstream remote** to keep your fork in sync + + ```bash + git remote add upstream https://github.com/AutoMaker-Org/automaker.git + ``` + +4. **Verify remotes** + ```bash + git remote -v + # Should show: + # origin https://github.com/YOUR_USERNAME/automaker.git (fetch) + # origin https://github.com/YOUR_USERNAME/automaker.git (push) + # upstream https://github.com/AutoMaker-Org/automaker.git (fetch) + # upstream https://github.com/AutoMaker-Org/automaker.git (push) + ``` + +### Development Setup + +1. **Install dependencies** + + ```bash + npm install + ``` + +2. **Build shared packages** (required before running the app) + + ```bash + npm run build:packages + ``` + +3. **Start the development server** + ```bash + npm run dev # Interactive launcher - choose mode + npm run dev:web # Browser mode (web interface) + npm run dev:electron # Desktop app mode + ``` + +**Common development commands:** + +| Command | Description | +| ------------------------ | -------------------------------- | +| `npm run dev` | Interactive development launcher | +| `npm run dev:web` | Start in browser mode | +| `npm run dev:electron` | Start desktop app | +| `npm run build` | Build all packages and apps | +| `npm run build:packages` | Build shared packages only | +| `npm run lint` | Run ESLint checks | +| `npm run format` | Format code with Prettier | +| `npm run format:check` | Check formatting without changes | +| `npm run test` | Run E2E tests (Playwright) | +| `npm run test:server` | Run server unit tests | +| `npm run test:packages` | Run package tests | +| `npm run test:all` | Run all tests | + +### Project Structure + +Automaker is organized as an npm workspace monorepo: + +``` +automaker/ +├── apps/ +│ ├── ui/ # React + Vite + Electron frontend +│ └── server/ # Express + WebSocket backend +├── libs/ +│ ├── @automaker/types/ # Shared TypeScript types +│ ├── @automaker/utils/ # Utility functions +│ ├── @automaker/prompts/ # AI prompt templates +│ ├── @automaker/platform/ # Platform abstractions +│ ├── @automaker/model-resolver/ # AI model resolution +│ ├── @automaker/dependency-resolver/ # Dependency management +│ └── @automaker/git-utils/ # Git operations +├── docs/ # Documentation +└── package.json # Root package configuration +``` + +**Key conventions:** + +- Always import from `@automaker/*` shared packages, never use relative paths to `libs/` +- Frontend code lives in `apps/ui/` +- Backend code lives in `apps/server/` +- Shared logic should be in the appropriate `libs/` package + +--- + +## Pull Request Process + +This section covers everything you need to know about contributing changes through pull requests, from creating your branch to getting your code merged. + +### Branching Strategy (RC Branches) + +Automaker uses **Release Candidate (RC) branches** for all development work. Understanding this workflow is essential before contributing. + +**How it works:** + +1. **All development happens on RC branches** - We maintain version-specific RC branches (e.g., `v0.10.0rc`, `v0.11.0rc`) where all active development occurs +2. **RC branches are eventually merged to main** - Once an RC branch is stable and ready for release, it gets merged into `main` +3. **Main branch is for releases only** - The `main` branch contains only released, stable code + +**Before creating a PR:** + +1. **Check for the latest RC branch** - Before starting work, check the repository for the current RC branch: + + ```bash + git fetch upstream + git branch -r | grep rc + ``` + +2. **Base your work on the RC branch** - Create your feature branch from the latest RC branch, not from `main`: + + ```bash + # Find the latest RC branch (e.g., v0.11.0rc) + git checkout upstream/v0.11.0rc + git checkout -b feature/your-feature-name + ``` + +3. **Target the RC branch in your PR** - When opening your pull request, set the base branch to the current RC branch, not `main` + +**Example workflow:** + +```bash +# 1. Fetch latest changes +git fetch upstream + +# 2. Check for RC branches +git branch -r | grep rc +# Output: upstream/v0.11.0rc + +# 3. Create your branch from the RC +git checkout -b feature/add-dark-mode upstream/v0.11.0rc + +# 4. Make your changes and commit +git commit -m "feat: Add dark mode support" + +# 5. Push to your fork +git push origin feature/add-dark-mode + +# 6. Open PR targeting the RC branch (v0.11.0rc), NOT main +``` + +**Important:** PRs opened directly against `main` will be asked to retarget to the current RC branch. + +### Branch Naming Convention + +We use a consistent branch naming pattern to keep our repository organized: + +``` +/ +``` + +**Branch types:** + +| Type | Purpose | Example | +| ---------- | ------------------------ | --------------------------------- | +| `feature` | New functionality | `feature/add-user-authentication` | +| `fix` | Bug fixes | `fix/resolve-memory-leak` | +| `docs` | Documentation changes | `docs/update-contributing-guide` | +| `refactor` | Code restructuring | `refactor/simplify-api-handlers` | +| `test` | Adding or updating tests | `test/add-utils-unit-tests` | +| `chore` | Maintenance tasks | `chore/update-dependencies` | + +**Guidelines:** + +- Use lowercase letters and hyphens (no underscores or spaces) +- Keep descriptions short but descriptive +- Include issue number when applicable: `feature/123-add-login` + +```bash +# Create and checkout a new feature branch +git checkout -b feature/add-dark-mode + +# Create a fix branch with issue reference +git checkout -b fix/456-resolve-login-error +``` + +### Commit Message Format + +We follow the **Conventional Commits** style for clear, readable commit history: + +``` +: + +[optional body] +``` + +**Commit types:** + +| Type | Purpose | +| ---------- | --------------------------- | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `style` | Formatting (no code change) | +| `refactor` | Code restructuring | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks | + +**Guidelines:** + +- Use **imperative mood** ("Add feature" not "Added feature") +- Keep first line under **72 characters** +- Capitalize the first letter after the type prefix +- No period at the end of the subject line +- Add a blank line before the body for detailed explanations + +**Examples:** + +```bash +# Simple commit +git commit -m "feat: Add user authentication flow" + +# Commit with body for more context +git commit -m "fix: Resolve memory leak in WebSocket handler + +The connection cleanup was not being called when clients +disconnected unexpectedly. Added proper cleanup in the +error handler to prevent memory accumulation." + +# Documentation update +git commit -m "docs: Update API documentation" + +# Refactoring +git commit -m "refactor: Simplify state management logic" +``` + +### Submitting a Pull Request + +Follow these steps to submit your contribution: + +#### 1. Prepare Your Changes + +Ensure you've synced with the latest upstream changes from the RC branch: + +```bash +# Fetch latest changes from upstream +git fetch upstream + +# Rebase your branch on the current RC branch (if needed) +git rebase upstream/v0.11.0rc # Use the current RC branch name +``` + +#### 2. Run Pre-submission Checks + +Before opening your PR, verify everything passes locally: + +```bash +# Run all tests +npm run test:all + +# Check formatting +npm run format:check + +# Run linter +npm run lint + +# Build to verify no compile errors +npm run build +``` + +#### 3. Push Your Changes + +```bash +# Push your branch to your fork +git push origin feature/your-feature-name +``` + +#### 4. Open a Pull Request + +1. Go to your fork on GitHub +2. Click "Compare & pull request" for your branch +3. **Important:** Set the base repository to `AutoMaker-Org/automaker` and the base branch to the **current RC branch** (e.g., `v0.11.0rc`), not `main` +4. Fill out the PR template completely + +#### PR Requirements Checklist + +Your PR should include: + +- [ ] **Targets the current RC branch** (not `main`) - see [Branching Strategy](#branching-strategy-rc-branches) +- [ ] **Clear title** describing the change (use conventional commit format) +- [ ] **Description** explaining what changed and why +- [ ] **Link to related issue** (if applicable): `Closes #123` or `Fixes #456` +- [ ] **All CI checks passing** (format, lint, build, tests) +- [ ] **No merge conflicts** with the RC branch +- [ ] **Tests included** for new functionality +- [ ] **Documentation updated** if adding/changing public APIs + +**Example PR Description:** + +```markdown +## Summary + +This PR adds dark mode support to the Automaker UI. + +- Implements theme toggle in settings panel +- Adds CSS custom properties for theme colors +- Persists theme preference to localStorage + +## Related Issue + +Closes #123 + +## Testing + +- [x] Tested toggle functionality in Chrome and Firefox +- [x] Verified theme persists across page reloads +- [x] Checked accessibility contrast ratios + +## Screenshots + +[Include before/after screenshots for UI changes] +``` + +### Review Process + +All contributions go through code review to maintain quality: + +#### What to Expect + +1. **CI Checks Run First** - Automated checks (format, lint, build, tests) must pass before review +2. **Maintainer Review** - The project maintainers will review your PR and decide whether to merge it +3. **Feedback & Discussion** - The reviewer may ask questions or request changes +4. **Iteration** - Make requested changes and push updates to the same branch +5. **Approval & Merge** - Once approved and checks pass, your PR will be merged + +#### Review Focus Areas + +The reviewer checks for: + +- **Correctness** - Does the code work as intended? +- **Clean Code** - Does it follow our [code style guidelines](#code-style-guidelines)? +- **Test Coverage** - Are new features properly tested? +- **Documentation** - Are public APIs documented? +- **Breaking Changes** - Are any breaking changes discussed first? + +#### Responding to Feedback + +- Respond to **all** review comments, even if just to acknowledge +- Ask questions if feedback is unclear +- Push additional commits to address feedback (don't force-push during review) +- Mark conversations as resolved once addressed + +#### Approval Criteria + +Your PR is ready to merge when: + +- ✅ All CI checks pass +- ✅ The maintainer has approved the changes +- ✅ All review comments are addressed +- ✅ No unresolved merge conflicts + +#### Getting Help + +If your PR seems stuck: + +- Comment asking for status update (mention @webdevcody if needed) +- Reach out on [Discord](https://discord.gg/jjem7aEDKU) +- Make sure all checks are passing and you've responded to all feedback + +--- + +## Code Style Guidelines + +Automaker uses automated tooling to enforce code style. Run `npm run format` to format code and `npm run lint` to check for issues. Pre-commit hooks automatically format staged files before committing. + +--- + +## Testing Requirements + +Testing helps prevent regressions. Automaker uses **Playwright** for end-to-end testing and **Vitest** for unit tests. + +### Running Tests + +Use these commands to run tests locally: + +| Command | Description | +| ------------------------------ | ------------------------------------- | +| `npm run test` | Run E2E tests (Playwright) | +| `npm run test:server` | Run server unit tests (Vitest) | +| `npm run test:packages` | Run shared package tests | +| `npm run test:all` | Run all tests | +| `npm run test:server:coverage` | Run server tests with coverage report | + +**Before submitting a PR**, always run the full test suite: + +```bash +npm run test:all +``` + +### Test Frameworks + +#### End-to-End Tests (Playwright) + +E2E tests verify the entire application works correctly from a user's perspective. + +- **Framework:** [Playwright](https://playwright.dev/) +- **Location:** `e2e/` directory +- **Test ports:** UI on port 3007, Server on port 3008 + +**Running E2E tests:** + +```bash +# Run all E2E tests +npm run test + +# Run with headed browser (useful for debugging) +npx playwright test --headed + +# Run a specific test file +npm test --workspace=@automaker/ui -- tests/example.spec.ts +``` + +**E2E Test Guidelines:** + +- Write tests from a user's perspective +- Use descriptive test names that explain the scenario +- Clean up test data after each test +- Use appropriate timeouts for async operations +- Prefer `locator` over direct selectors for resilience + +#### Unit Tests (Vitest) + +Unit tests verify individual functions and modules work correctly in isolation. + +- **Framework:** [Vitest](https://vitest.dev/) +- **Location:** In the `tests/` directory within each package (e.g., `apps/server/tests/`) + +**Running unit tests:** + +```bash +# Run all server unit tests +npm run test:server + +# Run with coverage report +npm run test:server:coverage + +# Run package tests +npm run test:packages + +# Run in watch mode during development +npx vitest --watch +``` + +**Unit Test Guidelines:** + +- Keep tests small and focused on one behavior +- Use descriptive test names: `it('should return null when user is not found')` +- Follow the AAA pattern: Arrange, Act, Assert +- Mock external dependencies to isolate the unit under test +- Aim for meaningful coverage, not just line coverage + +### Writing Tests + +#### When to Write Tests + +- **New features:** All new features should include tests +- **Bug fixes:** Add a test that reproduces the bug before fixing +- **Refactoring:** Ensure existing tests pass after refactoring +- **Public APIs:** All public APIs must have test coverage + +### CI/CD Pipeline + +Automaker uses **GitHub Actions** for continuous integration. Every pull request triggers automated checks. + +#### CI Checks + +The following checks must pass before your PR can be merged: + +| Check | Description | +| ----------------- | --------------------------------------------- | +| **Format** | Verifies code is formatted with Prettier | +| **Build** | Ensures the project compiles without errors | +| **Package Tests** | Runs tests for shared `@automaker/*` packages | +| **Server Tests** | Runs server unit tests with coverage | + +#### CI Testing Environment + +For CI environments, Automaker supports a mock agent mode: + +```bash +# Enable mock agent mode for CI testing +AUTOMAKER_MOCK_AGENT=true npm run test +``` + +This allows tests to run without requiring a real Claude API connection. + +#### Viewing CI Results + +1. Go to your PR on GitHub +2. Scroll to the "Checks" section at the bottom +3. Click on any failed check to see detailed logs +4. Fix issues locally and push updates + +#### Common CI Failures + +| Issue | Solution | +| ------------------- | --------------------------------------------- | +| Format check failed | Run `npm run format` locally | +| Build failed | Run `npm run build` and fix TypeScript errors | +| Tests failed | Run `npm run test:all` locally to reproduce | +| Coverage decreased | Add tests for new code paths | + +### Coverage Requirements + +While we don't enforce strict coverage percentages, we expect: + +- **New features:** Should include comprehensive tests +- **Bug fixes:** Should include a regression test +- **Critical paths:** Must have test coverage (authentication, data persistence, etc.) + +To view coverage reports locally: + +```bash +npm run test:server:coverage +``` + +This generates an HTML report you can open in your browser to see which lines are covered. + +--- + +## Issue Reporting + +Found a bug or have an idea for a new feature? We'd love to hear from you! This section explains how to report issues effectively. + +### Bug Reports + +When reporting a bug, please provide as much information as possible to help us understand and reproduce the issue. + +#### Before Reporting + +1. **Search existing issues** - Check if the bug has already been reported +2. **Try the latest version** - Make sure you're running the latest version of Automaker +3. **Reproduce the issue** - Verify you can consistently reproduce the bug + +#### Bug Report Template + +When creating a bug report, include: + +- **Title:** A clear, descriptive title summarizing the issue +- **Environment:** + - Operating System and version + - Node.js version (`node --version`) + - Automaker version or commit hash +- **Steps to Reproduce:** Numbered list of steps to reproduce the bug +- **Expected Behavior:** What you expected to happen +- **Actual Behavior:** What actually happened +- **Logs/Screenshots:** Any relevant error messages, console output, or screenshots + +**Example Bug Report:** + +```markdown +## Bug: WebSocket connection drops after 5 minutes of inactivity + +### Environment + +- OS: Windows 11 +- Node.js: 22.11.0 +- Automaker: commit abc1234 + +### Steps to Reproduce + +1. Start the application with `npm run dev:web` +2. Open the Kanban board +3. Leave the browser tab open for 5+ minutes without interaction +4. Try to move a card + +### Expected Behavior + +The card should move to the new column. + +### Actual Behavior + +The UI shows "Connection lost" and the card doesn't move. + +### Logs + +[WebSocket] Connection closed: 1006 +``` + +### Feature Requests + +We welcome ideas for improving Automaker! Here's how to submit a feature request: + +#### Before Requesting + +1. **Check existing issues** - Your idea may already be proposed or in development +2. **Consider scope** - Think about whether the feature fits Automaker's mission as an autonomous AI development studio + +#### Feature Request Template + +A good feature request includes: + +- **Title:** A brief, descriptive title +- **Problem Statement:** What problem does this feature solve? +- **Proposed Solution:** How do you envision this working? +- **Alternatives Considered:** What other approaches did you consider? +- **Additional Context:** Mockups, examples, or references that help explain your idea + +**Example Feature Request:** + +```markdown +## Feature: Dark Mode Support + +### Problem Statement + +Working late at night, the bright UI causes eye strain and doesn't match +my system's dark theme preference. + +### Proposed Solution + +Add a theme toggle in the settings panel that allows switching between +light and dark modes. Ideally, it should also detect system preference. + +### Alternatives Considered + +- Browser extension to force dark mode (doesn't work well with custom styling) +- Custom CSS override (breaks with updates) + +### Additional Context + +Similar to how VS Code handles themes - a dropdown in settings with +immediate preview. +``` + +### Security Issues + +**Important:** If you discover a security vulnerability, please do NOT open a public issue. Instead: + +1. Join our [Discord server](https://discord.gg/jjem7aEDKU) and send a direct message to the user `@webdevcody` +2. Include detailed steps to reproduce +3. Allow time for us to address the issue before public disclosure + +We take security seriously and appreciate responsible disclosure. + +--- + +For license and contribution terms, see the [LICENSE](LICENSE) file in the repository root and the [README.md](README.md#license) for more details. + +--- + +Thank you for contributing to Automaker! diff --git a/jules_branch/DISCLAIMER.md b/jules_branch/DISCLAIMER.md new file mode 100644 index 0000000000000000000000000000000000000000..95ef7d16820e020c7c3ac550a1d28ea51acc4f88 --- /dev/null +++ b/jules_branch/DISCLAIMER.md @@ -0,0 +1,85 @@ +# Security Disclaimer + +## Important Warning + +**Automaker uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** + +## Risk Assessment + +This software utilizes AI agents (such as Claude) that can: + +- **Read files** from your file system +- **Write and modify files** in your projects +- **Delete files** when instructed +- **Execute commands** on your operating system +- **Access environment variables** and configuration files + +While we have made efforts to review this codebase for security vulnerabilities and implement safeguards, **you assume all risk** when running this software. + +## Recommendations + +### 1. Review the Code First + +Before running Automaker, we strongly recommend reviewing the source code yourself to understand what operations it performs and ensure you are comfortable with its behavior. + +### 2. Use Sandboxing (Highly Recommended) + +**We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Instead, consider: + +- **Docker**: Run Automaker in a Docker container to isolate it from your host system +- **Virtual Machine**: Use a VM (such as VirtualBox, VMware, or Parallels) to create an isolated environment +- **Cloud Development Environment**: Use a cloud-based development environment that provides isolation + +#### Running in Isolated Docker Container + +For maximum security, run Automaker in an isolated Docker container that **cannot access your laptop's files**: + +```bash +# 1. Set your API key (bash/Linux/Mac - creates UTF-8 file) +echo "ANTHROPIC_API_KEY=your-api-key-here" > .env + +# On Windows PowerShell, use instead: +Set-Content -Path .env -Value "ANTHROPIC_API_KEY=your-api-key-here" -Encoding UTF8 + +# 2. Build and run isolated container +docker-compose up -d + +# 3. Access the UI at http://localhost:3007 +# API at http://localhost:3008/api/health +``` + +The container uses only Docker-managed volumes and has no access to your host filesystem. See [docker-isolation.md](docs/docker-isolation.md) for full documentation. + +### 3. Limit Access + +If you must run locally: + +- Create a dedicated user account with limited permissions +- Only grant access to specific project directories +- Avoid running with administrator/root privileges +- Keep sensitive files and credentials outside of project directories + +### 4. Monitor Activity + +- Review the agent's actions in the output logs +- Pay attention to file modifications and command executions +- Stop the agent immediately if you notice unexpected behavior + +## No Warranty & Limitation of Liability + +THE SOFTWARE UTILIZES ARTIFICIAL INTELLIGENCE TO GENERATE CODE, EXECUTE COMMANDS, AND INTERACT WITH YOUR FILE SYSTEM. YOU ACKNOWLEDGE THAT AI SYSTEMS CAN BE UNPREDICTABLE, MAY GENERATE INCORRECT, INSECURE, OR DESTRUCTIVE CODE, AND MAY TAKE ACTIONS THAT COULD DAMAGE YOUR SYSTEM, FILES, OR HARDWARE. + +This software is provided "as is", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, including but not limited to hardware damage, data loss, financial loss, or business interruption, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the software or the use or other dealings in the software. + +## Acknowledgment + +By using Automaker, you acknowledge that: + +1. You have read and understood this disclaimer +2. You accept full responsibility for any consequences of using this software +3. You understand the risks of AI agents having access to your operating system +4. You agree to take appropriate precautions as outlined above + +--- + +**If you are not comfortable with these risks, do not use this software.** diff --git a/jules_branch/Dockerfile b/jules_branch/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7d48e15fe16aa6737cde3fb4437a0e576b7dc119 --- /dev/null +++ b/jules_branch/Dockerfile @@ -0,0 +1,237 @@ +# Automaker Multi-Stage Dockerfile +# Single Dockerfile for both server and UI builds +# Usage: +# docker build --target server -t automaker-server . +# docker build --target ui -t automaker-ui . +# Or use docker-compose which selects targets automatically + +# ============================================================================= +# BASE STAGE - Common setup for all builds (DRY: defined once, used by all) +# ============================================================================= +FROM node:22-slim AS base + +# Install build dependencies for native modules (node-pty) +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy root package files +COPY package*.json ./ + +# Copy all libs package.json files (centralized - add new libs here) +COPY libs/types/package*.json ./libs/types/ +COPY libs/utils/package*.json ./libs/utils/ +COPY libs/prompts/package*.json ./libs/prompts/ +COPY libs/platform/package*.json ./libs/platform/ +COPY libs/spec-parser/package*.json ./libs/spec-parser/ +COPY libs/model-resolver/package*.json ./libs/model-resolver/ +COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/ +COPY libs/git-utils/package*.json ./libs/git-utils/ +COPY libs/spec-parser/package*.json ./libs/spec-parser/ + +# Copy scripts (needed by npm workspace) +COPY scripts ./scripts + +# ============================================================================= +# SERVER BUILD STAGE +# ============================================================================= +FROM base AS server-builder + +# Copy server-specific package.json +COPY apps/server/package*.json ./apps/server/ + +# Install dependencies (--ignore-scripts to skip husky/prepare, then rebuild native modules) +RUN npm ci --ignore-scripts && npm rebuild node-pty + +# Copy all source files +COPY libs ./libs +COPY apps/server ./apps/server + +# Build packages in dependency order, then build server +RUN npm run build:packages && npm run build --workspace=apps/server + +# ============================================================================= +# SERVER PRODUCTION STAGE +# ============================================================================= +FROM node:22-slim AS server + +# Build argument for tracking which commit this image was built from +ARG GIT_COMMIT_SHA=unknown +LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" + +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker build --build-arg UID=$(id -u) --build-arg GID=$(id -g) ... +ARG UID=1001 +ARG GID=1001 + +# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch) +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ + && GH_VERSION="2.63.2" \ + && ARCH=$(uname -m) \ + && case "$ARCH" in \ + x86_64) GH_ARCH="amd64" ;; \ + aarch64|arm64) GH_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac \ + && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \ + && tar -xzf gh.tar.gz \ + && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \ + && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude CLI globally (available to all users via npm global bin) +RUN npm install -g @anthropic-ai/claude-code + +# Create non-root user with home directory BEFORE installing Cursor CLI +# Uses UID/GID build args to match host user for mounted volume permissions +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \ + mkdir -p /home/automaker/.local/bin && \ + mkdir -p /home/automaker/.cursor && \ + chown -R automaker:automaker /home/automaker && \ + chmod 700 /home/automaker/.cursor + +# Install Cursor CLI as the automaker user +# Set HOME explicitly and install to /home/automaker/.local/bin/ +USER automaker +ENV HOME=/home/automaker +RUN curl https://cursor.com/install -fsS | bash && \ + echo "=== Checking Cursor CLI installation ===" && \ + ls -la /home/automaker/.local/bin/ && \ + echo "=== PATH is: $PATH ===" && \ + (which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)" + +# Install OpenCode CLI (for multi-provider AI model access) +RUN curl -fsSL https://opencode.ai/install | bash && \ + echo "=== Checking OpenCode CLI installation ===" && \ + ls -la /home/automaker/.local/bin/ && \ + (which opencode && opencode --version) || echo "opencode installed (may need auth setup)" + +USER root + +# Add PATH to profile so it's available in all interactive shells (for login shells) +RUN mkdir -p /etc/profile.d && \ + echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \ + chmod +x /etc/profile.d/cursor-cli.sh + +# Add to automaker's .bashrc for bash interactive shells +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \ + chown automaker:automaker /home/automaker/.bashrc + +# Also add to root's .bashrc since docker exec defaults to root +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc + +WORKDIR /app + +# Copy root package.json (needed for workspace resolution) +COPY --from=server-builder /app/package*.json ./ + +# Copy built libs (workspace packages are symlinked in node_modules) +COPY --from=server-builder /app/libs ./libs + +# Copy built server +COPY --from=server-builder /app/apps/server/dist ./apps/server/dist +COPY --from=server-builder /app/apps/server/package*.json ./apps/server/ + +# Copy node_modules (includes symlinks to libs) +COPY --from=server-builder /app/node_modules ./node_modules + +# Install Playwright Chromium browser for AI agent verification tests +# This adds ~300MB to the image but enables automated testing mode out of the box +# Using the locally installed playwright ensures we use the pinned version from package-lock.json +USER automaker +RUN ./node_modules/.bin/playwright install chromium && \ + echo "=== Playwright Chromium installed ===" && \ + ls -la /home/automaker/.cache/ms-playwright/ +USER root + +# Create data and projects directories +RUN mkdir -p /data /projects && chown automaker:automaker /data /projects + +# Configure git for mounted volumes and authentication +# Use --system so it's not overwritten by mounted user .gitconfig +RUN git config --system --add safe.directory '*' && \ + # Use gh as credential helper (works with GH_TOKEN env var) + git config --system credential.helper '!gh auth git-credential' + +# Copy entrypoint script for fixing permissions on mounted volumes +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Note: We stay as root here so entrypoint can fix permissions +# The entrypoint script will switch to automaker user before running the command + +# Environment variables +ENV PORT=3008 +ENV DATA_DIR=/data +ENV HOME=/home/automaker +# Add user's local bin to PATH for cursor-agent +ENV PATH="/home/automaker/.local/bin:${PATH}" + +# Expose port +EXPOSE 3008 + +# Health check (using curl since it's already installed, more reliable than busybox wget) +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3008/api/health || exit 1 + +# Use entrypoint to fix permissions before starting +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + +# Start server +CMD ["node", "apps/server/dist/index.js"] + +# ============================================================================= +# UI BUILD STAGE +# ============================================================================= +FROM base AS ui-builder + +# Copy UI-specific package.json +COPY apps/ui/package*.json ./apps/ui/ + +# Install dependencies (--ignore-scripts to skip husky and build:packages in prepare script) +RUN npm ci --ignore-scripts + +# Copy all source files +COPY libs ./libs +COPY apps/ui ./apps/ui + +# Build packages in dependency order, then build UI +# When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies +# to the server container. This avoids CORS issues entirely in Docker Compose setups. +# Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com +ARG VITE_SERVER_URL= +ENV VITE_SKIP_ELECTRON=true +ENV VITE_SERVER_URL=${VITE_SERVER_URL} +RUN npm run build:packages && npm run build --workspace=apps/ui + +# ============================================================================= +# UI PRODUCTION STAGE +# ============================================================================= +FROM nginx:alpine AS ui + +# Build argument for tracking which commit this image was built from +ARG GIT_COMMIT_SHA=unknown +LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}" + +# Copy built files +COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html + +# Copy nginx config for SPA routing +COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/jules_branch/Dockerfile.dev b/jules_branch/Dockerfile.dev new file mode 100644 index 0000000000000000000000000000000000000000..60e445f2e15f5206a5aade94ea76a54aadf9a789 --- /dev/null +++ b/jules_branch/Dockerfile.dev @@ -0,0 +1,94 @@ +# Automaker Development Dockerfile +# For development with live reload via volume mounting +# Source code is NOT copied - it's mounted as a volume +# +# Usage: +# docker compose -f docker-compose.dev.yml up + +FROM node:22-slim + +# Install build dependencies for native modules (node-pty) and runtime tools +# Also install Playwright/Chromium system dependencies (aligns with playwright install-deps on Debian/Ubuntu) +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 make g++ \ + git curl bash gosu ca-certificates openssh-client \ + # Playwright/Chromium dependencies + libglib2.0-0 libnss3 libnspr4 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 \ + libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \ + libx11-6 libx11-xcb1 libxcb1 libxext6 libxrender1 libxss1 libxtst6 \ + libxshmfence1 libgtk-3-0 libexpat1 libfontconfig1 fonts-liberation \ + xdg-utils libpangocairo-1.0-0 libpangoft2-1.0-0 libu2f-udev libvulkan1 \ + && GH_VERSION="2.63.2" \ + && ARCH=$(uname -m) \ + && case "$ARCH" in \ + x86_64) GH_ARCH="amd64" ;; \ + aarch64|arm64) GH_ARCH="arm64" ;; \ + *) echo "Unsupported architecture: $ARCH" && exit 1 ;; \ + esac \ + && curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \ + && tar -xzf gh.tar.gz \ + && mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \ + && rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \ + && rm -rf /var/lib/apt/lists/* + +# Install Claude CLI globally +RUN npm install -g @anthropic-ai/claude-code + +# Build arguments for user ID matching (allows matching host user for mounted volumes) +# Override at build time: docker-compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) +ARG UID=1001 +ARG GID=1001 + +# Create non-root user with configurable UID/GID +# Use -o flag to allow non-unique IDs (GID 1000 may already exist as 'node' group) +RUN groupadd -o -g ${GID} automaker && \ + useradd -o -u ${UID} -g automaker -m -d /home/automaker -s /bin/bash automaker && \ + mkdir -p /home/automaker/.local/bin && \ + mkdir -p /home/automaker/.cursor && \ + chown -R automaker:automaker /home/automaker && \ + chmod 700 /home/automaker/.cursor + +# Install Cursor CLI as automaker user +USER automaker +ENV HOME=/home/automaker +RUN curl https://cursor.com/install -fsS | bash || true +USER root + +# Add PATH to profile for Cursor CLI +RUN mkdir -p /etc/profile.d && \ + echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \ + chmod +x /etc/profile.d/cursor-cli.sh + +# Add to user bashrc files +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \ + chown automaker:automaker /home/automaker/.bashrc +RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc + +WORKDIR /app + +# Create directories with proper permissions +RUN mkdir -p /data /projects && chown automaker:automaker /data /projects + +# Configure git for mounted volumes +RUN git config --system --add safe.directory '*' && \ + git config --system credential.helper '!gh auth git-credential' + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Environment variables +ENV PORT=3008 +ENV DATA_DIR=/data +ENV HOME=/home/automaker +ENV PATH="/home/automaker/.local/bin:${PATH}" + +# Expose both dev ports +EXPOSE 3007 3008 + +# Use entrypoint for permission handling +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + +# Default command - will be overridden by docker-compose +CMD ["npm", "run", "dev:web"] diff --git a/jules_branch/LICENSE b/jules_branch/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..e388c0a7c472272fd7d4aca4e29b931ee8bad6b9 --- /dev/null +++ b/jules_branch/LICENSE @@ -0,0 +1,27 @@ +## Project Status + +**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge. + +--- + +MIT License + +Copyright (c) 2025 Automaker Core Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/jules_branch/OPENCODE_CONFIG_CONTENT b/jules_branch/OPENCODE_CONFIG_CONTENT new file mode 100644 index 0000000000000000000000000000000000000000..9dabfe4921112c6cf3a3d451b3b258191376b862 --- /dev/null +++ b/jules_branch/OPENCODE_CONFIG_CONTENT @@ -0,0 +1,2 @@ +{ + "$schema": "https://opencode.ai/config.json",} \ No newline at end of file diff --git a/jules_branch/README.md b/jules_branch/README.md new file mode 100644 index 0000000000000000000000000000000000000000..e13ad62e5378fce4551174bf5e1a693c80836265 --- /dev/null +++ b/jules_branch/README.md @@ -0,0 +1,714 @@ +

+ Automaker Logo +

+ +> **[!TIP]** +> +> **Learn more about Agentic Coding!** +> +> Automaker itself was built by a group of engineers using AI and agentic coding techniques to build features faster than ever. By leveraging tools like Cursor IDE and Claude Code CLI, the team orchestrated AI agents to implement complex functionality in days instead of weeks. +> +> **Learn how:** Master these same techniques and workflows in the [Agentic Jumpstart course](https://agenticjumpstart.com/?utm=automaker-gh). + +# Automaker + +**Stop typing code. Start directing AI agents.** + +
+

Table of Contents

+ +- [What Makes Automaker Different?](#what-makes-automaker-different) + - [The Workflow](#the-workflow) + - [Powered by Claude Agent SDK](#powered-by-claude-agent-sdk) + - [Why This Matters](#why-this-matters) +- [Security Disclaimer](#security-disclaimer) +- [Community & Support](#community--support) +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Quick Start](#quick-start) +- [How to Run](#how-to-run) + - [Development Mode](#development-mode) + - [Interactive TUI Launcher](#interactive-tui-launcher-recommended-for-new-users) + - [Building for Production](#building-for-production) + - [Testing](#testing) + - [Linting](#linting) + - [Environment Configuration](#environment-configuration) + - [Authentication Setup](#authentication-setup) +- [Features](#features) + - [Core Workflow](#core-workflow) + - [AI & Planning](#ai--planning) + - [Project Management](#project-management) + - [Collaboration & Review](#collaboration--review) + - [Developer Tools](#developer-tools) + - [Advanced Features](#advanced-features) +- [Tech Stack](#tech-stack) + - [Frontend](#frontend) + - [Backend](#backend) + - [Testing & Quality](#testing--quality) + - [Shared Libraries](#shared-libraries) +- [Available Views](#available-views) +- [Architecture](#architecture) + - [Monorepo Structure](#monorepo-structure) + - [How It Works](#how-it-works) + - [Key Architectural Patterns](#key-architectural-patterns) + - [Security & Isolation](#security--isolation) + - [Data Storage](#data-storage) +- [Learn More](#learn-more) +- [License](#license) + +
+ +Automaker is an autonomous AI development studio that transforms how you build software. Instead of manually writing every line of code, you describe features on a Kanban board and watch as AI agents powered by Claude Agent SDK automatically implement them. Built with React, Vite, Electron, and Express, Automaker provides a complete workflow for managing AI agents through a desktop application (or web browser), with features like real-time streaming, git worktree isolation, plan approval, and multi-agent task execution. + +![Automaker UI](https://i.imgur.com/jdwKydM.png) + +## What Makes Automaker Different? + +Traditional development tools help you write code. Automaker helps you **orchestrate AI agents** to build entire features autonomously. Think of it as having a team of AI developers working for you—you define what needs to be built, and Automaker handles the implementation. + +### The Workflow + +1. **Add Features** - Describe features you want built (with text, images, or screenshots) +2. **Move to "In Progress"** - Automaker automatically assigns an AI agent to implement the feature +3. **Watch It Build** - See real-time progress as the agent writes code, runs tests, and makes changes +4. **Review & Verify** - Review the changes, run tests, and approve when ready +5. **Ship Faster** - Build entire applications in days, not weeks + +### Powered by Claude Agent SDK + +Automaker leverages the [Claude Agent SDK](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) to give AI agents full access to your codebase. Agents can read files, write code, execute commands, run tests, and make git commits—all while working in isolated git worktrees to keep your main branch safe. The SDK provides autonomous AI agents that can use tools, make decisions, and complete complex multi-step tasks without constant human intervention. + +### Why This Matters + +The future of software development is **agentic coding**—where developers become architects directing AI agents rather than manual coders. Automaker puts this future in your hands today, letting you experience what it's like to build software 10x faster with AI agents handling the implementation while you focus on architecture and business logic. + +## Community & Support + +Join the **Agentic Jumpstart** to connect with other builders exploring **agentic coding** and autonomous development workflows. + +In the Discord, you can: + +- 💬 Discuss agentic coding patterns and best practices +- 🧠 Share ideas for AI-driven development workflows +- 🛠️ Get help setting up or extending Automaker +- 🚀 Show off projects built with AI agents +- 🤝 Collaborate with other developers and contributors + +👉 **Join the Discord:** [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU) + +--- + +## Getting Started + +### Prerequisites + +- **Node.js 22+** (required: >=22.0.0 <23.0.0) +- **npm** (comes with Node.js) +- **[Claude Code CLI](https://code.claude.com/docs/en/overview)** - Install and authenticate with your Anthropic subscription. Automaker integrates with your authenticated Claude Code CLI to access Claude models. + +### Quick Start + +```bash +# 1. Clone the repository +git clone https://github.com/AutoMaker-Org/automaker.git +cd automaker + +# 2. Install dependencies +npm install + +# 3. Start Automaker +npm run dev +# Choose between: +# 1. Web Application (browser at localhost:3007) +# 2. Desktop Application (Electron - recommended) +``` + +**Authentication:** Automaker integrates with your authenticated Claude Code CLI. Make sure you have [installed and authenticated](https://code.claude.com/docs/en/quickstart) the Claude Code CLI before running Automaker. Your CLI credentials will be detected automatically. + +**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes. + +## How to Run + +### Development Mode + +Start Automaker in development mode: + +```bash +npm run dev +``` + +This will prompt you to choose your run mode, or you can specify a mode directly: + +#### Electron Desktop App (Recommended) + +```bash +# Standard development mode +npm run dev:electron + +# With DevTools open automatically +npm run dev:electron:debug + +# For WSL (Windows Subsystem for Linux) +npm run dev:electron:wsl + +# For WSL with GPU acceleration +npm run dev:electron:wsl:gpu +``` + +#### Web Browser Mode + +```bash +# Run in web browser (http://localhost:3007) +npm run dev:web +``` + +### Interactive TUI Launcher (Recommended for New Users) + +For a user-friendly interactive menu, use the built-in TUI launcher script: + +```bash +# Show interactive menu with all launch options +./start-automaker.sh + +# Or launch directly without menu +./start-automaker.sh web # Web browser +./start-automaker.sh electron # Desktop app +./start-automaker.sh electron-debug # Desktop + DevTools + +# Additional options +./start-automaker.sh --help # Show all available options +./start-automaker.sh --version # Show version information +./start-automaker.sh --check-deps # Verify project dependencies +./start-automaker.sh --no-colors # Disable colored output +./start-automaker.sh --no-history # Don't remember last choice +``` + +**Features:** + +- 🎨 Beautiful terminal UI with gradient colors and ASCII art +- ⌨️ Interactive menu (press 1-3 to select, Q to exit) +- 💾 Remembers your last choice +- ✅ Pre-flight checks (validates Node.js, npm, dependencies) +- 📏 Responsive layout (adapts to terminal size) +- ⏱️ 30-second timeout for hands-free selection +- 🌐 Cross-shell compatible (bash/zsh) + +**History File:** +Your last selected mode is saved in `~/.automaker_launcher_history` for quick re-runs. + +### Building for Production + +#### Web Application + +```bash +# Build for web deployment (uses Vite) +npm run build +``` + +#### Desktop Application + +```bash +# Build for current platform (macOS/Windows/Linux) +npm run build:electron + +# Platform-specific builds +npm run build:electron:mac # macOS (DMG + ZIP, x64 + arm64) +npm run build:electron:win # Windows (NSIS installer, x64) +npm run build:electron:linux # Linux (AppImage + DEB + RPM, x64) + +# Output directory: apps/ui/release/ +``` + +**Linux Distribution Packages:** + +- **AppImage**: Universal format, works on any Linux distribution +- **DEB**: Ubuntu, Debian, Linux Mint, Pop!\_OS +- **RPM**: Fedora, RHEL, Rocky Linux, AlmaLinux, openSUSE + +**Installing on Fedora/RHEL:** + +```bash +# Download the RPM package +wget https://github.com/AutoMaker-Org/automaker/releases/latest/download/Automaker--x86_64.rpm + +# Install with dnf (Fedora) +sudo dnf install ./Automaker--x86_64.rpm + +# Or with yum (RHEL/CentOS) +sudo yum localinstall ./Automaker--x86_64.rpm +``` + +#### Docker Deployment + +Docker provides the most secure way to run Automaker by isolating it from your host filesystem. + +```bash +# Build and run with Docker Compose +docker-compose up -d + +# Access UI at http://localhost:3007 +# API at http://localhost:3008 + +# View logs +docker-compose logs -f + +# Stop containers +docker-compose down +``` + +##### Authentication + +Automaker integrates with your authenticated Claude Code CLI. To use CLI authentication in Docker, mount your Claude CLI config directory (see [Claude CLI Authentication](#claude-cli-authentication) below). + +##### Working with Projects (Host Directory Access) + +By default, the container is isolated from your host filesystem. To work on projects from your host machine, create a `docker-compose.override.yml` file (gitignored): + +```yaml +services: + server: + volumes: + # Mount your project directories + - /path/to/your/project:/projects/your-project +``` + +##### Claude CLI Authentication + +Mount your Claude CLI config directory to use your authenticated CLI credentials: + +```yaml +services: + server: + volumes: + # Linux/macOS + - ~/.claude:/home/automaker/.claude + # Windows + - C:/Users/YourName/.claude:/home/automaker/.claude +``` + +**Note:** The Claude CLI config must be writable (do not use `:ro` flag) as the CLI writes debug files. + +> **⚠️ Important: Linux/WSL Users** +> +> The container runs as UID 1001 by default. If your host user has a different UID (common on Linux/WSL where the first user is UID 1000), you must create a `.env` file to match your host user: +> +> ```bash +> # Check your UID/GID +> id -u # outputs your UID (e.g., 1000) +> id -g # outputs your GID (e.g., 1000) +> ``` +> +> Create a `.env` file in the automaker directory: +> +> ``` +> UID=1000 +> GID=1000 +> ``` +> +> Then rebuild the images: +> +> ```bash +> docker compose build +> ``` +> +> Without this, files written by the container will be inaccessible to your host user. + +##### GitHub CLI Authentication (For Git Push/PR Operations) + +To enable git push and GitHub CLI operations inside the container: + +```yaml +services: + server: + volumes: + # Mount GitHub CLI config + # Linux/macOS + - ~/.config/gh:/home/automaker/.config/gh + # Windows + - 'C:/Users/YourName/AppData/Roaming/GitHub CLI:/home/automaker/.config/gh' + + # Mount git config for user identity (name, email) + - ~/.gitconfig:/home/automaker/.gitconfig:ro + environment: + # GitHub token (required on Windows where tokens are in Credential Manager) + # Get your token with: gh auth token + - GH_TOKEN=${GH_TOKEN} +``` + +Then add `GH_TOKEN` to your `.env` file: + +```bash +GH_TOKEN=gho_your_github_token_here +``` + +##### Complete docker-compose.override.yml Example + +```yaml +services: + server: + volumes: + # Your projects + - /path/to/project1:/projects/project1 + - /path/to/project2:/projects/project2 + + # Authentication configs + - ~/.claude:/home/automaker/.claude + - ~/.config/gh:/home/automaker/.config/gh + - ~/.gitconfig:/home/automaker/.gitconfig:ro + environment: + - GH_TOKEN=${GH_TOKEN} +``` + +##### Architecture Support + +The Docker image supports both AMD64 and ARM64 architectures. The GitHub CLI and Claude CLI are automatically downloaded for the correct architecture during build. + +##### Playwright for Automated Testing + +The Docker image includes **Playwright Chromium pre-installed** for AI agent verification tests. When agents implement features in automated testing mode, they use Playwright to verify the implementation works correctly. + +**No additional setup required** - Playwright verification works out of the box. + +#### Optional: Persist browsers for manual updates + +By default, Playwright Chromium is pre-installed in the Docker image. If you need to manually update browsers or want to persist browser installations across container restarts (not image rebuilds), you can mount a volume. + +**Important:** When you first add this volume mount to an existing setup, the empty volume will override the pre-installed browsers. You must re-install them: + +```bash +# After adding the volume mount for the first time +docker exec --user automaker -w /app automaker-server npx playwright install chromium +``` + +Add this to your `docker-compose.override.yml`: + +```yaml +services: + server: + volumes: + - playwright-cache:/home/automaker/.cache/ms-playwright + +volumes: + playwright-cache: + name: automaker-playwright-cache +``` + +**Updating browsers manually:** + +```bash +docker exec --user automaker -w /app automaker-server npx playwright install chromium +``` + +### Testing + +#### End-to-End Tests (Playwright) + +```bash +npm run test # Headless E2E tests +npm run test:headed # Browser visible E2E tests +``` + +#### Unit Tests (Vitest) + +```bash +npm run test:server # Server unit tests +npm run test:server:coverage # Server tests with coverage +npm run test:packages # All shared package tests +npm run test:all # Packages + server tests +``` + +#### Test Configuration + +- E2E tests run on ports 3007 (UI) and 3008 (server) +- Automatically starts test servers before running +- Uses Chromium browser via Playwright +- Mock agent mode available in CI with `AUTOMAKER_MOCK_AGENT=true` + +### Linting + +```bash +# Run ESLint +npm run lint +``` + +### Environment Configuration + +#### Optional - Server + +- `PORT` - Server port (default: 3008) +- `DATA_DIR` - Data storage directory (default: ./data) +- `ENABLE_REQUEST_LOGGING` - HTTP request logging (default: true) + +#### Optional - Security + +- `AUTOMAKER_API_KEY` - Optional API authentication for the server +- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory +- `CORS_ORIGIN` - CORS allowed origins (comma-separated list; defaults to localhost only) + +#### Optional - Development + +- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode +- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron +- `AUTOMAKER_SKIP_SANDBOX_WARNING` - Skip sandbox warning dialog (useful for dev/CI) +- `AUTOMAKER_AUTO_LOGIN=true` - Skip login prompt in development (ignored when NODE_ENV=production) + +### Authentication Setup + +Automaker integrates with your authenticated Claude Code CLI and uses your Anthropic subscription. + +Install and authenticate the Claude Code CLI following the [official quickstart guide](https://code.claude.com/docs/en/quickstart). + +Once authenticated, Automaker will automatically detect and use your CLI credentials. No additional configuration needed! + +## Features + +### Core Workflow + +- 📋 **Kanban Board** - Visual drag-and-drop board to manage features through backlog, in progress, waiting approval, and verified stages +- 🤖 **AI Agent Integration** - Automatic AI agent assignment to implement features when moved to "In Progress" +- 🔀 **Git Worktree Isolation** - Each feature executes in isolated git worktrees to protect your main branch +- 📡 **Real-time Streaming** - Watch AI agents work in real-time with live tool usage, progress updates, and task completion +- 🔄 **Follow-up Instructions** - Send additional instructions to running agents without stopping them + +### AI & Planning + +- 🧠 **Multi-Model Support** - Choose from Claude Opus, Sonnet, and Haiku per feature +- 💭 **Extended Thinking** - Enable thinking modes (none, medium, deep, ultra) for complex problem-solving +- 📝 **Planning Modes** - Four planning levels: skip (direct implementation), lite (quick plan), spec (task breakdown), full (phased execution) +- ✅ **Plan Approval** - Review and approve AI-generated plans before implementation begins +- 📊 **Multi-Agent Task Execution** - Spec mode spawns dedicated agents per task for focused implementation + +### Project Management + +- 🔍 **Project Analysis** - AI-powered codebase analysis to understand your project structure +- 💡 **Feature Suggestions** - AI-generated feature suggestions based on project analysis +- 📁 **Context Management** - Add markdown, images, and documentation files that agents automatically reference +- 🔗 **Dependency Blocking** - Features can depend on other features, enforcing execution order +- 🌳 **Graph View** - Visualize feature dependencies with interactive graph visualization +- 📋 **GitHub Integration** - Import issues, validate feasibility, and convert to tasks automatically + +### Collaboration & Review + +- 🧪 **Verification Workflow** - Features move to "Waiting Approval" for review and testing +- 💬 **Agent Chat** - Interactive chat sessions with AI agents for exploratory work +- 👤 **AI Profiles** - Create custom agent configurations with different prompts, models, and settings +- 📜 **Session History** - Persistent chat sessions across restarts with full conversation history +- 🔍 **Git Diff Viewer** - Review changes made by agents before approving + +### Developer Tools + +- 🖥️ **Integrated Terminal** - Full terminal access with tabs, splits, and persistent sessions +- 🖼️ **Image Support** - Attach screenshots and diagrams to feature descriptions for visual context +- ⚡ **Concurrent Execution** - Configure how many features can run simultaneously (default: 3) +- ⌨️ **Keyboard Shortcuts** - Fully customizable shortcuts for navigation and actions +- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more +- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64) +- 🌐 **Web Mode** - Run in browser or as Electron desktop app + +### Advanced Features + +- 🔐 **Docker Isolation** - Security-focused Docker deployment with no host filesystem access +- 🎯 **Worktree Management** - Create, switch, commit, and create PRs from worktrees +- 📊 **Usage Tracking** - Monitor Claude API usage with detailed metrics +- 🔊 **Audio Notifications** - Optional completion sounds (mutable in settings) +- 💾 **Auto-save** - All work automatically persisted to `.automaker/` directory + +## Tech Stack + +### Frontend + +- **React 19** - UI framework +- **Vite 7** - Build tool and development server +- **Electron 39** - Desktop application framework +- **TypeScript 5.9** - Type safety +- **TanStack Router** - File-based routing +- **Zustand 5** - State management with persistence +- **Tailwind CSS 4** - Utility-first styling with 25+ themes +- **Radix UI** - Accessible component primitives +- **dnd-kit** - Drag and drop for Kanban board +- **@xyflow/react** - Graph visualization for dependencies +- **xterm.js** - Integrated terminal emulator +- **CodeMirror 6** - Code editor for XML/syntax highlighting +- **Lucide Icons** - Icon library + +### Backend + +- **Node.js** - JavaScript runtime with ES modules +- **Express 5** - HTTP server framework +- **TypeScript 5.9** - Type safety +- **Claude Agent SDK** - AI agent integration (@anthropic-ai/claude-agent-sdk) +- **WebSocket (ws)** - Real-time event streaming +- **node-pty** - PTY terminal sessions + +### Testing & Quality + +- **Playwright** - End-to-end testing +- **Vitest** - Unit testing framework +- **ESLint 9** - Code linting +- **Prettier 3** - Code formatting +- **Husky** - Git hooks for pre-commit formatting + +### Shared Libraries + +- **@automaker/types** - Shared TypeScript definitions +- **@automaker/utils** - Logging, error handling, image processing +- **@automaker/prompts** - AI prompt templates +- **@automaker/platform** - Path management and security +- **@automaker/model-resolver** - Claude model alias resolution +- **@automaker/dependency-resolver** - Feature dependency ordering +- **@automaker/git-utils** - Git operations and worktree management + +## Available Views + +Automaker provides several specialized views accessible via the sidebar or keyboard shortcuts: + +| View | Shortcut | Description | +| ------------------ | -------- | ------------------------------------------------------------------------------------------------ | +| **Board** | `K` | Kanban board for managing feature workflow (Backlog → In Progress → Waiting Approval → Verified) | +| **Agent** | `A` | Interactive chat sessions with AI agents for exploratory work and questions | +| **Spec** | `D` | Project specification editor with AI-powered generation and feature suggestions | +| **Context** | `C` | Manage context files (markdown, images) that AI agents automatically reference | +| **Settings** | `S` | Configure themes, shortcuts, defaults, authentication, and more | +| **Terminal** | `T` | Integrated terminal with tabs, splits, and persistent sessions | +| **Graph** | `H` | Visualize feature dependencies with interactive graph visualization | +| **Ideation** | `I` | Brainstorm and generate ideas with AI assistance | +| **Memory** | `Y` | View and manage agent memory and conversation history | +| **GitHub Issues** | `G` | Import and validate GitHub issues, convert to tasks | +| **GitHub PRs** | `R` | View and manage GitHub pull requests | +| **Running Agents** | - | View all active agents across projects with status and progress | + +### Keyboard Navigation + +All shortcuts are customizable in Settings. Default shortcuts: + +- **Navigation:** `K` (Board), `A` (Agent), `D` (Spec), `C` (Context), `S` (Settings), `T` (Terminal), `H` (Graph), `I` (Ideation), `Y` (Memory), `G` (GitHub Issues), `R` (GitHub PRs) +- **UI:** `` ` `` (Toggle sidebar) +- **Actions:** `N` (New item in current view), `O` (Open project), `P` (Project picker) +- **Projects:** `Q`/`E` (Cycle previous/next project) +- **Terminal:** `Alt+D` (Split right), `Alt+S` (Split down), `Alt+W` (Close), `Alt+T` (New tab) + +## Architecture + +### Monorepo Structure + +Automaker is built as an npm workspace monorepo with two main applications and seven shared packages: + +```text +automaker/ +├── apps/ +│ ├── ui/ # React + Vite + Electron frontend +│ └── server/ # Express + WebSocket backend +└── libs/ # Shared packages + ├── types/ # Core TypeScript definitions + ├── utils/ # Logging, errors, utilities + ├── prompts/ # AI prompt templates + ├── platform/ # Path management, security + ├── model-resolver/ # Claude model aliasing + ├── dependency-resolver/ # Feature dependency ordering + └── git-utils/ # Git operations & worktree management +``` + +### How It Works + +1. **Feature Definition** - Users create feature cards on the Kanban board with descriptions, images, and configuration +2. **Git Worktree Creation** - When a feature starts, a git worktree is created for isolated development +3. **Agent Execution** - Claude Agent SDK executes in the worktree with full file system and command access +4. **Real-time Streaming** - Agent output streams via WebSocket to the frontend for live monitoring +5. **Plan Approval** (optional) - For spec/full planning modes, agents generate plans that require user approval +6. **Multi-Agent Tasks** (spec mode) - Each task in the spec gets a dedicated agent for focused implementation +7. **Verification** - Features move to "Waiting Approval" where changes can be reviewed via git diff +8. **Integration** - After approval, changes can be committed and PRs created from the worktree + +### Key Architectural Patterns + +- **Event-Driven Architecture** - All server operations emit events that stream to the frontend +- **Provider Pattern** - Extensible AI provider system (currently Claude, designed for future providers) +- **Service-Oriented Backend** - Modular services for agent management, features, terminals, settings +- **State Management** - Zustand with persistence for frontend state across restarts +- **File-Based Storage** - No database; features stored as JSON files in `.automaker/` directory + +### Security & Isolation + +- **Git Worktrees** - Each feature executes in an isolated git worktree, protecting your main branch +- **Path Sandboxing** - Optional `ALLOWED_ROOT_DIRECTORY` restricts file access +- **Docker Isolation** - Recommended deployment uses Docker with no host filesystem access +- **Plan Approval** - Optional plan review before implementation prevents unwanted changes + +### Data Storage + +Automaker uses a file-based storage system (no database required): + +#### Per-Project Data + +Stored in `{projectPath}/.automaker/`: + +```text +.automaker/ +├── features/ # Feature JSON files and images +│ └── {featureId}/ +│ ├── feature.json # Feature metadata +│ ├── agent-output.md # AI agent output log +│ └── images/ # Attached images +├── context/ # Context files for AI agents +├── worktrees/ # Git worktree metadata +├── validations/ # GitHub issue validation results +├── ideation/ # Brainstorming and analysis data +│ └── analysis.json # Project structure analysis +├── board/ # Board-related data +├── images/ # Project-level images +├── settings.json # Project-specific settings +├── app_spec.txt # Project specification (XML format) +├── active-branches.json # Active git branches tracking +└── execution-state.json # Auto-mode execution state +``` + +#### Global Data + +Stored in `DATA_DIR` (default `./data`): + +```text +data/ +├── settings.json # Global settings, profiles, shortcuts +├── credentials.json # API keys (encrypted) +├── sessions-metadata.json # Chat session metadata +└── agent-sessions/ # Conversation histories + └── {sessionId}.json +``` + +--- + +> **[!CAUTION]** +> +> ## Security Disclaimer +> +> **This software uses AI-powered tooling that has access to your operating system and can read, modify, and delete files. Use at your own risk.** +> +> We have reviewed this codebase for security vulnerabilities, but you assume all risk when running this software. You should review the code yourself before running it. +> +> **We do not recommend running Automaker directly on your local computer** due to the risk of AI agents having access to your entire file system. Please sandbox this application using Docker or a virtual machine. +> +> **[Read the full disclaimer](./DISCLAIMER.md)** + +--- + +## Learn More + +### Documentation + +- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker +- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs +- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages + +### Community + +Join the **Agentic Jumpstart** Discord to connect with other builders exploring **agentic coding**: + +👉 [Agentic Jumpstart Discord](https://discord.gg/jjem7aEDKU) + +## Project Status + +**This project is no longer actively maintained.** The codebase is provided as-is for those who wish to use, study, or fork it. No bug fixes, security updates, or new features are being developed. Community contributions may still be accepted, but there is no guarantee of review or merge. + +## License + +This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for the full text. diff --git a/jules_branch/apps/server/.env.example b/jules_branch/apps/server/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..bad631237876bc5614819aea3910fd359f2fed47 --- /dev/null +++ b/jules_branch/apps/server/.env.example @@ -0,0 +1,95 @@ +# Automaker Server Configuration +# Copy this file to .env and configure your settings + +# ============================================ +# REQUIRED +# ============================================ + +# Your Anthropic API key for Claude models +ANTHROPIC_API_KEY=sk-ant-... + +# ============================================ +# OPTIONAL - Additional API Keys +# ============================================ + +# OpenAI API key for Codex/GPT models +OPENAI_API_KEY=sk-... + +# Cursor API key for Cursor models +CURSOR_API_KEY=... + +# OAuth credentials for CLI authentication (extracted automatically) +CLAUDE_OAUTH_CREDENTIALS= +CURSOR_AUTH_TOKEN= + +# ============================================ +# OPTIONAL - Security +# ============================================ + +# API key for authenticating requests (leave empty to disable auth) +# If set, all API requests must include X-API-Key header +AUTOMAKER_API_KEY= + +# Root directory for projects and file operations +# If set, users can only create/open projects and files within this directory +# Recommended for sandboxed deployments (Docker, restricted environments) +# Example: ALLOWED_ROOT_DIRECTORY=/projects +ALLOWED_ROOT_DIRECTORY= + +# CORS origin - which domains can access the API +# Use "*" for development, set specific origin for production +CORS_ORIGIN=http://localhost:3007 + +# ============================================ +# OPTIONAL - Server +# ============================================ + +# Host to bind the server to (default: 0.0.0.0) +# Use 0.0.0.0 to listen on all interfaces (recommended for Docker/remote access) +# Use 127.0.0.1 or localhost to restrict to local connections only +HOST=0.0.0.0 + +# Port to run the server on +PORT=3008 + +# Port to run the server on for testing +TEST_SERVER_PORT=3108 + +# Port to run the UI on for testing +TEST_PORT=3107 + +# Data directory for sessions and metadata +DATA_DIR=./data + +# ============================================ +# OPTIONAL - Terminal Access +# ============================================ + +# Enable/disable terminal access (default: true) +TERMINAL_ENABLED=true + +# Password to protect terminal access (leave empty for no password) +# If set, users must enter this password before accessing terminal +TERMINAL_PASSWORD= + +ENABLE_REQUEST_LOGGING=false + +# ============================================ +# OPTIONAL - UI Behavior +# ============================================ + +# Skip the sandbox warning dialog on startup (default: false) +# Set to "true" to disable the warning entirely (useful for dev/CI environments) +AUTOMAKER_SKIP_SANDBOX_WARNING=false + +# ============================================ +# OPTIONAL - Debugging +# ============================================ + +# Enable raw output logging for agent streams (default: false) +# When enabled, saves unprocessed stream events to raw-output.jsonl +# in each feature's directory (.automaker/features/{id}/raw-output.jsonl) +# Useful for debugging provider streaming issues, improving log parsing, +# or analyzing how different providers (Claude, Cursor) stream responses +# Note: This adds disk I/O overhead, only enable when debugging +AUTOMAKER_DEBUG_RAW_OUTPUT=false diff --git a/jules_branch/apps/server/.gitignore b/jules_branch/apps/server/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..6e37bb00be5be9d7d9b661f530cdd58d6f8d6073 --- /dev/null +++ b/jules_branch/apps/server/.gitignore @@ -0,0 +1,4 @@ +.env +data +node_modules +coverage \ No newline at end of file diff --git a/jules_branch/apps/server/eslint.config.mjs b/jules_branch/apps/server/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..008c1f68ea7c67fe8d8b0961db177cc192731cee --- /dev/null +++ b/jules_branch/apps/server/eslint.config.mjs @@ -0,0 +1,74 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; + +const eslintConfig = defineConfig([ + js.configs.recommended, + { + files: ['**/*.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + // Node.js globals + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + fetch: 'readonly', + Response: 'readonly', + Request: 'readonly', + Headers: 'readonly', + FormData: 'readonly', + RequestInit: 'readonly', + // Timers + setTimeout: 'readonly', + setInterval: 'readonly', + clearTimeout: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + clearImmediate: 'readonly', + queueMicrotask: 'readonly', + // Node.js types + NodeJS: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': ts, + }, + rules: { + ...ts.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + // Server code frequently works with terminal output containing ANSI escape codes + 'no-control-regex': 'off', + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-nocheck': 'allow-with-description', + minimumDescriptionLength: 10, + }, + ], + }, + }, + globalIgnores(['dist/**', 'node_modules/**']), +]); + +export default eslintConfig; diff --git a/jules_branch/apps/server/package.json b/jules_branch/apps/server/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a552ff11b75f8c6e200ccc499f2e97131b10005b --- /dev/null +++ b/jules_branch/apps/server/package.json @@ -0,0 +1,62 @@ +{ + "name": "@automaker/server", + "version": "1.0.0", + "description": "Backend server for Automaker - provides API for both web and Electron modes", + "author": "AutoMaker Team", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "dev:test": "tsx src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src/", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run", + "test:cov": "vitest run --coverage", + "test:watch": "vitest watch", + "test:unit": "vitest run tests/unit" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "0.2.32", + "@automaker/dependency-resolver": "1.0.0", + "@automaker/git-utils": "1.0.0", + "@automaker/model-resolver": "1.0.0", + "@automaker/platform": "1.0.0", + "@automaker/prompts": "1.0.0", + "@automaker/types": "1.0.0", + "@automaker/utils": "1.0.0", + "@github/copilot-sdk": "0.1.16", + "@modelcontextprotocol/sdk": "1.25.2", + "@openai/codex-sdk": "^0.98.0", + "cookie-parser": "1.4.7", + "cors": "2.8.5", + "dotenv": "17.2.3", + "express": "5.2.1", + "morgan": "1.10.1", + "node-pty": "1.1.0-beta41", + "ws": "8.18.3", + "yaml": "2.7.0" + }, + "devDependencies": { + "@playwright/test": "1.57.0", + "@types/cookie": "0.6.0", + "@types/cookie-parser": "1.4.10", + "@types/cors": "2.8.19", + "@types/express": "5.0.6", + "@types/morgan": "1.9.10", + "@types/node": "22.19.3", + "@types/ws": "8.18.1", + "@vitest/coverage-v8": "4.0.16", + "@vitest/ui": "4.0.16", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "4.0.16" + } +} diff --git a/jules_branch/apps/server/src/index.ts b/jules_branch/apps/server/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc0c3fbc16f634dd1796ea2d6f2bf0b7f519fddc --- /dev/null +++ b/jules_branch/apps/server/src/index.ts @@ -0,0 +1,979 @@ +/** + * Automaker Backend Server + * + * Provides HTTP/WebSocket API for both web and Electron modes. + * In Electron mode, this server runs locally. + * In web mode, this server runs on a remote host. + */ + +import express from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; +import cookie from 'cookie'; +import { WebSocketServer, WebSocket } from 'ws'; +import { createServer } from 'http'; +import dotenv from 'dotenv'; + +import { createEventEmitter, type EventEmitter } from './lib/events.js'; +import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform'; +import { createLogger, setLogLevel, LogLevel } from '@automaker/utils'; + +const logger = createLogger('Server'); + +/** + * Map server log level string to LogLevel enum + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; +import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js'; +import { requireJsonContentType } from './middleware/require-json-content-type.js'; +import { createAuthRoutes } from './routes/auth/index.js'; +import { createFsRoutes } from './routes/fs/index.js'; +import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js'; +import { createAgentRoutes } from './routes/agent/index.js'; +import { createSessionsRoutes } from './routes/sessions/index.js'; +import { createFeaturesRoutes } from './routes/features/index.js'; +import { createAutoModeRoutes } from './routes/auto-mode/index.js'; +import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js'; +import { createWorktreeRoutes } from './routes/worktree/index.js'; +import { createGitRoutes } from './routes/git/index.js'; +import { createSetupRoutes } from './routes/setup/index.js'; +import { createModelsRoutes } from './routes/models/index.js'; +import { createRunningAgentsRoutes } from './routes/running-agents/index.js'; +import { createWorkspaceRoutes } from './routes/workspace/index.js'; +import { createTemplatesRoutes } from './routes/templates/index.js'; +import { + createTerminalRoutes, + validateTerminalToken, + isTerminalEnabled, + isTerminalPasswordRequired, +} from './routes/terminal/index.js'; +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 { getTerminalService } from './services/terminal-service.js'; +import { SettingsService } from './services/settings-service.js'; +import { createSpecRegenerationRoutes } from './routes/app-spec/index.js'; +import { createClaudeRoutes } from './routes/claude/index.js'; +import { ClaudeUsageService } from './services/claude-usage-service.js'; +import { createCodexRoutes } from './routes/codex/index.js'; +import { CodexUsageService } from './services/codex-usage-service.js'; +import { CodexAppServerService } from './services/codex-app-server-service.js'; +import { CodexModelCacheService } from './services/codex-model-cache-service.js'; +import { createZaiRoutes } from './routes/zai/index.js'; +import { ZaiUsageService } from './services/zai-usage-service.js'; +import { createGeminiRoutes } from './routes/gemini/index.js'; +import { GeminiUsageService } from './services/gemini-usage-service.js'; +import { createGitHubRoutes } from './routes/github/index.js'; +import { createContextRoutes } from './routes/context/index.js'; +import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; +import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; +import { createMCPRoutes } from './routes/mcp/index.js'; +import { MCPTestService } from './services/mcp-test-service.js'; +import { createPipelineRoutes } from './routes/pipeline/index.js'; +import { pipelineService } from './services/pipeline-service.js'; +import { createIdeationRoutes } from './routes/ideation/index.js'; +import { IdeationService } from './services/ideation-service.js'; +import { getDevServerService } from './services/dev-server-service.js'; +import { eventHookService } from './services/event-hook-service.js'; +import { createNotificationsRoutes } from './routes/notifications/index.js'; +import { getNotificationService } from './services/notification-service.js'; +import { createEventHistoryRoutes } from './routes/event-history/index.js'; +import { getEventHistoryService } from './services/event-history-service.js'; +import { getTestRunnerService } from './services/test-runner-service.js'; +import { createProjectsRoutes } from './routes/projects/index.js'; + +// Load environment variables +dotenv.config(); + +const PORT = parseInt(process.env.PORT || '3008', 10); +const HOST = process.env.HOST || '0.0.0.0'; +const HOSTNAME = process.env.HOSTNAME || 'localhost'; +const DATA_DIR = process.env.DATA_DIR || './data'; +logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR); +logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR); +logger.info('[SERVER_STARTUP] process.cwd():', process.cwd()); +const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true + +// Runtime-configurable request logging flag (can be changed via settings) +let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT; + +/** + * Enable or disable HTTP request logging at runtime + */ +export function setRequestLoggingEnabled(enabled: boolean): void { + requestLoggingEnabled = enabled; +} + +/** + * Get current request logging state + */ +export function isRequestLoggingEnabled(): boolean { + return requestLoggingEnabled; +} + +// Width for log box content (excluding borders) +const BOX_CONTENT_WIDTH = 67; + +// Check for Claude authentication (async - runs in background) +// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication +(async () => { + const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); + logger.debug('[CREDENTIAL_CHECK] Environment variables:', { + hasAnthropicKey, + hasEnvOAuthToken, + }); + + if (hasAnthropicKey) { + logger.info('✓ ANTHROPIC_API_KEY detected'); + return; + } + + if (hasEnvOAuthToken) { + logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected'); + return; + } + + // Check for Claude Code CLI authentication + // Store indicators outside the try block so we can use them in the warning message + let cliAuthIndicators: Awaited> | null = null; + + try { + cliAuthIndicators = await getClaudeAuthIndicators(); + const indicators = cliAuthIndicators; + + // Log detailed credential detection results + const { checks, ...indicatorSummary } = indicators; + logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', indicatorSummary); + + logger.debug('[CREDENTIAL_CHECK] File check details:', checks); + + const hasCliAuth = + indicators.hasStatsCacheWithActivity || + (indicators.hasSettingsFile && indicators.hasProjectsSessions) || + (indicators.hasCredentialsFile && + (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); + + logger.debug('[CREDENTIAL_CHECK] Auth determination:', { + hasCliAuth, + reason: hasCliAuth + ? indicators.hasStatsCacheWithActivity + ? 'stats cache with activity' + : indicators.hasSettingsFile && indicators.hasProjectsSessions + ? 'settings file + project sessions' + : indicators.credentials?.hasOAuthToken + ? 'credentials file with OAuth token' + : 'credentials file with API key' + : 'no valid credentials found', + }); + + if (hasCliAuth) { + logger.info('✓ Claude Code CLI authentication detected'); + return; + } + } catch (error) { + // Ignore errors checking CLI auth - will fall through to warning + logger.warn('Error checking for Claude Code CLI authentication:', error); + } + + // No authentication found - show warning with paths that were checked + const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); + const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); + const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); + const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd( + BOX_CONTENT_WIDTH + ); + const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH); + const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH); + const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd( + BOX_CONTENT_WIDTH + ); + + // Build paths checked summary from the indicators (if available) + let pathsCheckedInfo = ''; + if (cliAuthIndicators) { + const pathsChecked: string[] = []; + + // Collect paths that were checked (paths are always populated strings) + pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); + pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); + pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); + for (const credFile of cliAuthIndicators.checks.credentialFiles) { + pathsChecked.push(`Credentials: ${credFile.path}`); + } + + if (pathsChecked.length > 0) { + pathsCheckedInfo = ` +║ ║ +║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║ +${pathsChecked + .map((p) => { + const maxLen = BOX_CONTENT_WIDTH - 4; + const display = p.length > maxLen ? '...' + p.slice(-(maxLen - 3)) : p; + return `║ ${display.padEnd(maxLen)} ║`; + }) + .join('\n')}`; + } + } + + logger.warn(` +╔═════════════════════════════════════════════════════════════════════╗ +║ ${wHeader}║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ║ +║ ${w1}║ +║ ║ +║ ${w2}║ +║ ${w3}║ +║ ${w4}║ +║ ${w5}║ +║ ${w6}║${pathsCheckedInfo} +║ ║ +╚═════════════════════════════════════════════════════════════════════╝ +`); +})(); + +// Initialize security +initAllowedPaths(); + +// Create Express app +const app = express(); + +// Middleware +// Custom colored logger showing only endpoint and status code (dynamically configurable) +morgan.token('status-colored', (_req, res) => { + const status = res.statusCode; + if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors + if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors + if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects + return `\x1b[32m${status}\x1b[0m`; // Green for success +}); + +app.use( + morgan(':method :url :status-colored', { + // Skip when request logging is disabled or for health check endpoints + skip: (req) => + !requestLoggingEnabled || + req.url === '/api/health' || + req.url === '/api/auto-mode/context-exists', + }) +); +// CORS configuration +// When using credentials (cookies), origin cannot be '*' +// We dynamically allow the requesting origin for local development + +// Check if origin is a local/private network address +function isLocalOrigin(origin: string): boolean { + try { + const url = new URL(origin); + const hostname = url.hostname; + return ( + hostname === 'localhost' || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '0.0.0.0' || + hostname.startsWith('192.168.') || + hostname.startsWith('10.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) + ); + } catch { + return false; + } +} + +app.use( + cors({ + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps, curl, Electron) + if (!origin) { + callback(null, true); + return; + } + + // If CORS_ORIGIN is set, use it (can be comma-separated list) + const allowedOrigins = process.env.CORS_ORIGIN?.split(',') + .map((o) => o.trim()) + .filter(Boolean); + if (allowedOrigins && allowedOrigins.length > 0) { + if (allowedOrigins.includes('*')) { + callback(null, true); + return; + } + if (allowedOrigins.includes(origin)) { + callback(null, origin); + return; + } + // Fall through to local network check below + } + + // Allow all localhost/loopback/private network origins (any port) + if (isLocalOrigin(origin)) { + callback(null, origin); + return; + } + + // Reject other origins by default for security + callback(new Error('Not allowed by CORS')); + }, + credentials: true, + }) +); +app.use(express.json({ limit: '50mb' })); +app.use(cookieParser()); + +// Create shared event emitter for streaming +const events: EventEmitter = createEventEmitter(); + +// Create services +// Note: settingsService is created first so it can be injected into other services +const settingsService = new SettingsService(DATA_DIR); +const agentService = new AgentService(DATA_DIR, events, settingsService); +const featureLoader = new FeatureLoader(); + +// Auto-mode services: compatibility layer provides old interface while using new architecture +const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader); +const claudeUsageService = new ClaudeUsageService(); +const codexAppServerService = new CodexAppServerService(); +const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); +const codexUsageService = new CodexUsageService(codexAppServerService); +const zaiUsageService = new ZaiUsageService(); +const geminiUsageService = new GeminiUsageService(); +const mcpTestService = new MCPTestService(settingsService); +const ideationService = new IdeationService(events, settingsService, featureLoader); + +// Initialize DevServerService with event emitter for real-time log streaming +const devServerService = getDevServerService(); +devServerService.initialize(DATA_DIR, events).catch((err) => { + logger.error('Failed to initialize DevServerService:', err); +}); + +// Initialize Notification Service with event emitter for real-time updates +const notificationService = getNotificationService(); +notificationService.setEventEmitter(events); + +// Initialize Event History Service +const eventHistoryService = getEventHistoryService(); + +// Initialize Test Runner Service with event emitter for real-time test output streaming +const testRunnerService = getTestRunnerService(); +testRunnerService.setEventEmitter(events); + +// Initialize Event Hook Service for custom event triggers (with history storage) +eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader); + +// Initialize services +(async () => { + // Migrate settings from legacy Electron userData location if needed + // This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux), + // ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows) + // to the new shared ./data directory + try { + const migrationResult = await settingsService.migrateFromLegacyElectronPath(); + if (migrationResult.migrated) { + logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`); + logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`); + } + if (migrationResult.errors.length > 0) { + logger.warn('Migration errors:', migrationResult.errors); + } + } catch (err) { + logger.warn('Failed to check for legacy settings migration:', err); + } + + // Fetch global settings once and reuse for logging config and feature reconciliation + let globalSettings: Awaited> | null = null; + try { + globalSettings = await settingsService.getGlobalSettings(); + } catch { + logger.warn('Failed to load global settings, using defaults'); + } + + // Apply logging settings from saved settings + if (globalSettings) { + try { + if ( + globalSettings.serverLogLevel && + LOG_LEVEL_MAP[globalSettings.serverLogLevel] !== undefined + ) { + setLogLevel(LOG_LEVEL_MAP[globalSettings.serverLogLevel]); + logger.info(`Server log level set to: ${globalSettings.serverLogLevel}`); + } + // Apply request logging setting (default true if not set) + const enableRequestLog = globalSettings.enableRequestLogging ?? true; + setRequestLoggingEnabled(enableRequestLog); + logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`); + } catch { + logger.warn('Failed to apply logging settings, using defaults'); + } + } + + await agentService.initialize(); + logger.info('Agent service initialized'); + + // Reconcile feature states on startup + // After any type of restart (clean, forced, crash), features may be stuck in + // transient states (in_progress, interrupted, pipeline_*) that don't match reality. + // Reconcile them back to resting states before the UI is served. + if (globalSettings) { + try { + if (globalSettings.projects && globalSettings.projects.length > 0) { + let totalReconciled = 0; + for (const project of globalSettings.projects) { + const count = await autoModeService.reconcileFeatureStates(project.path); + totalReconciled += count; + } + if (totalReconciled > 0) { + logger.info( + `[STARTUP] Reconciled ${totalReconciled} feature(s) across ${globalSettings.projects.length} project(s)` + ); + } else { + logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); + } + + // Resume interrupted features in the background for all projects. + // This handles features stuck in transient states (in_progress, pipeline_*) + // or explicitly marked as interrupted. Running in background so it doesn't block startup. + for (const project of globalSettings.projects) { + autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { + logger.warn( + `[STARTUP] Failed to resume interrupted features for ${project.path}:`, + err + ); + }); + } + logger.info('[STARTUP] Initiated background resume of interrupted features'); + } + } catch (err) { + logger.warn('[STARTUP] Failed to reconcile feature states:', err); + } + } + + // Bootstrap Codex model cache in background (don't block server startup) + void codexModelCacheService.getModels().catch((err) => { + logger.error('Failed to bootstrap Codex model cache:', err); + }); +})(); + +// Run stale validation cleanup every hour to prevent memory leaks from crashed validations +const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour +setInterval(() => { + const cleaned = cleanupStaleValidations(); + if (cleaned > 0) { + logger.info(`Cleaned up ${cleaned} stale validation entries`); + } +}, VALIDATION_CLEANUP_INTERVAL_MS); + +// Require Content-Type: application/json for all API POST/PUT/PATCH requests +// This helps prevent CSRF and content-type confusion attacks +app.use('/api', requireJsonContentType); + +// Mount API routes - health, auth, and setup are unauthenticated +app.use('/api/health', createHealthRoutes()); +app.use('/api/auth', createAuthRoutes()); +app.use('/api/setup', createSetupRoutes()); + +// Apply authentication to all other routes +app.use('/api', authMiddleware); + +// Protected health endpoint with detailed info +app.get('/api/health/detailed', createDetailedHandler()); + +app.use('/api/fs', createFsRoutes(events)); +app.use('/api/agent', createAgentRoutes(agentService, events)); +app.use('/api/sessions', createSessionsRoutes(agentService)); +app.use( + '/api/features', + createFeaturesRoutes(featureLoader, settingsService, events, autoModeService) +); +app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); +app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader)); +app.use('/api/git', createGitRoutes()); +app.use('/api/models', createModelsRoutes()); +app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); +app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService)); +app.use('/api/workspace', createWorkspaceRoutes()); +app.use('/api/templates', createTemplatesRoutes()); +app.use('/api/terminal', createTerminalRoutes()); +app.use('/api/settings', createSettingsRoutes(settingsService)); +app.use('/api/claude', createClaudeRoutes(claudeUsageService)); +app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); +app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); +app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events)); +app.use('/api/github', createGitHubRoutes(events, settingsService)); +app.use('/api/context', createContextRoutes(settingsService)); +app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); +app.use('/api/mcp', createMCPRoutes(mcpTestService)); +app.use('/api/pipeline', createPipelineRoutes(pipelineService)); +app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); +app.use('/api/notifications', createNotificationsRoutes(notificationService)); +app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); +app.use( + '/api/projects', + createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService) +); + +// Create HTTP server +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(settingsService); + +/** + * Authenticate WebSocket upgrade requests + * Checks for API key in header/query, session token in header/query, OR valid session cookie + */ +function authenticateWebSocket(request: import('http').IncomingMessage): boolean { + const url = new URL(request.url || '', `http://${request.headers.host}`); + + // Convert URL search params to query object + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + // Parse cookies from header + const cookieHeader = request.headers.cookie; + const cookies = cookieHeader ? cookie.parse(cookieHeader) : {}; + + // Use shared authentication logic for standard auth methods + if ( + checkRawAuthentication( + request.headers as Record, + query, + cookies + ) + ) { + return true; + } + + // Additionally check for short-lived WebSocket connection token (WebSocket-specific) + const wsToken = url.searchParams.get('wsToken'); + if (wsToken && validateWsConnectionToken(wsToken)) { + return true; + } + + return false; +} + +// Handle HTTP upgrade requests manually to route to correct WebSocket server +server.on('upgrade', (request, socket, head) => { + const { pathname } = new URL(request.url || '', `http://${request.headers.host}`); + + // Authenticate all WebSocket connections + if (!authenticateWebSocket(request)) { + logger.info('Authentication failed, rejecting connection'); + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + if (pathname === '/api/events') { + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } else if (pathname === '/api/terminal/ws') { + terminalWss.handleUpgrade(request, socket, head, (ws) => { + terminalWss.emit('connection', ws, request); + }); + } else { + socket.destroy(); + } +}); + +// Events WebSocket connection handler +wss.on('connection', (ws: WebSocket) => { + logger.info('Client connected, ready state:', ws.readyState); + + // Subscribe to all events and forward to this client + const unsubscribe = events.subscribe((type, payload) => { + // Use debug level for high-frequency events to avoid log spam + // that causes progressive memory growth and server slowdown + const isHighFrequency = + type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress'; + const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger); + + log('Event received:', { + type, + hasPayload: !!payload, + wsReadyState: ws.readyState, + }); + + if (ws.readyState === WebSocket.OPEN) { + const message = JSON.stringify({ type, payload }); + ws.send(message); + } else { + logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState); + } + }); + + ws.on('close', () => { + logger.info('Client disconnected'); + unsubscribe(); + }); + + ws.on('error', (error) => { + logger.error('ERROR:', error); + unsubscribe(); + }); +}); + +// Track WebSocket connections per session +const terminalConnections: Map> = new Map(); +// Track last resize dimensions per session to deduplicate resize messages +const lastResizeDimensions: Map = new Map(); +// Track last resize timestamp to rate-limit resize operations (prevents resize storm) +const lastResizeTime: Map = new Map(); +const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations + +// Clean up resize tracking when sessions actually exit (not just when connections close) +terminalService.onExit((sessionId) => { + lastResizeDimensions.delete(sessionId); + lastResizeTime.delete(sessionId); + terminalConnections.delete(sessionId); +}); + +// Terminal WebSocket connection handler +terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => { + // Parse URL to get session ID and token + const url = new URL(req.url || '', `http://${req.headers.host}`); + const sessionId = url.searchParams.get('sessionId'); + const token = url.searchParams.get('token'); + + logger.info(`Connection attempt for session: ${sessionId}`); + + // Check if terminal is enabled + if (!isTerminalEnabled()) { + logger.info('Terminal is disabled'); + ws.close(4003, 'Terminal access is disabled'); + return; + } + + // Validate token if password is required + if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) { + logger.info('Invalid or missing token'); + ws.close(4001, 'Authentication required'); + return; + } + + if (!sessionId) { + logger.info('No session ID provided'); + ws.close(4002, 'Session ID required'); + return; + } + + // Check if session exists + const session = terminalService.getSession(sessionId); + if (!session) { + logger.warn( + `Terminal session ${sessionId} not found. ` + + `The session may have exited, been deleted, or was never created. ` + + `Active terminal sessions: ${terminalService.getSessionCount()}` + ); + ws.close( + 4004, + 'Session not found. The terminal session may have expired or been closed. Please create a new terminal.' + ); + return; + } + + logger.info(`Client connected to session ${sessionId}`); + + // Track this connection + if (!terminalConnections.has(sessionId)) { + terminalConnections.set(sessionId, new Set()); + } + terminalConnections.get(sessionId)!.add(ws); + + // Send initial connection success FIRST + ws.send( + JSON.stringify({ + type: 'connected', + sessionId, + shell: session.shell, + cwd: session.cwd, + }) + ); + + // Send scrollback buffer BEFORE subscribing to prevent race condition + // Also clear pending output buffer to prevent duplicates from throttled flush + const scrollback = terminalService.getScrollbackAndClearPending(sessionId); + if (scrollback && scrollback.length > 0) { + ws.send( + JSON.stringify({ + type: 'scrollback', + data: scrollback, + }) + ); + } + + // NOW subscribe to terminal data (after scrollback is sent) + const unsubscribeData = terminalService.onData((sid, data) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'data', data })); + } + }); + + // Subscribe to terminal exit + const unsubscribeExit = terminalService.onExit((sid, exitCode) => { + if (sid === sessionId && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'exit', exitCode })); + ws.close(1000, 'Session ended'); + } + }); + + // Handle incoming messages + ws.on('message', (message) => { + try { + const msg = JSON.parse(message.toString()); + + switch (msg.type) { + case 'input': + // Validate input data type and length + if (typeof msg.data !== 'string') { + ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' })); + break; + } + // Limit input size to 1MB to prevent memory issues + if (msg.data.length > 1024 * 1024) { + ws.send(JSON.stringify({ type: 'error', message: 'Input too large' })); + break; + } + // Write user input to terminal + terminalService.write(sessionId, msg.data); + break; + + case 'resize': + // Validate resize dimensions are positive integers within reasonable bounds + if ( + typeof msg.cols !== 'number' || + typeof msg.rows !== 'number' || + !Number.isInteger(msg.cols) || + !Number.isInteger(msg.rows) || + msg.cols < 1 || + msg.cols > 1000 || + msg.rows < 1 || + msg.rows > 500 + ) { + break; // Silently ignore invalid resize requests + } + // Resize terminal with deduplication and rate limiting + if (msg.cols && msg.rows) { + const now = Date.now(); + const lastTime = lastResizeTime.get(sessionId) || 0; + const lastDimensions = lastResizeDimensions.get(sessionId); + + // Skip if resized too recently (prevents resize storm during splits) + if (now - lastTime < RESIZE_MIN_INTERVAL_MS) { + break; + } + + // Check if dimensions are different from last resize + if ( + !lastDimensions || + lastDimensions.cols !== msg.cols || + lastDimensions.rows !== msg.rows + ) { + // Only suppress output on subsequent resizes, not the first one + // The first resize happens on terminal open and we don't want to drop the initial prompt + const isFirstResize = !lastDimensions; + terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize); + lastResizeDimensions.set(sessionId, { + cols: msg.cols, + rows: msg.rows, + }); + lastResizeTime.set(sessionId, now); + } + } + break; + + case 'ping': + // Respond to ping + ws.send(JSON.stringify({ type: 'pong' })); + break; + + default: + logger.warn(`Unknown message type: ${msg.type}`); + } + } catch (error) { + logger.error('Error processing message:', error); + } + }); + + ws.on('close', () => { + logger.info(`Client disconnected from session ${sessionId}`); + unsubscribeData(); + unsubscribeExit(); + + // Remove from connections tracking + const connections = terminalConnections.get(sessionId); + if (connections) { + connections.delete(ws); + if (connections.size === 0) { + terminalConnections.delete(sessionId); + // DON'T delete lastResizeDimensions/lastResizeTime here! + // The session still exists, and reconnecting clients need to know + // this isn't the "first resize" to prevent duplicate prompts. + // These get cleaned up when the session actually exits. + } + } + }); + + ws.on('error', (error) => { + logger.error(`Error on session ${sessionId}:`, error); + unsubscribeData(); + unsubscribeExit(); + }); +}); + +// Start server with error handling for port conflicts +const startServer = (port: number, host: string) => { + server.listen(port, host, () => { + const terminalStatus = isTerminalEnabled() + ? isTerminalPasswordRequired() + ? 'enabled (password protected)' + : 'enabled' + : 'disabled'; + + // Build URLs for display + const listenAddr = `${host}:${port}`; + const httpUrl = `http://${HOSTNAME}:${port}`; + const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`; + const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`; + const healthUrl = `http://${HOSTNAME}:${port}/api/health`; + + const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH); + const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH); + const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH); + const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH); + const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH); + const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH); + const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH); + + logger.info(` +╔═════════════════════════════════════════════════════════════════════╗ +║ ${sHeader}║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ║ +║ ${s1}║ +║ ${s2}║ +║ ${s3}║ +║ ${s4}║ +║ ${s5}║ +║ ${s6}║ +║ ║ +╚═════════════════════════════════════════════════════════════════════╝ +`); + }); + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + const portStr = port.toString(); + const nextPortStr = (port + 1).toString(); + const killCmd = `lsof -ti:${portStr} | xargs kill -9`; + const altCmd = `PORT=${nextPortStr} npm run dev:server`; + + const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH); + const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH); + const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH); + const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH); + const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH); + const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH); + const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH); + const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH); + const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH); + + logger.error(` +╔═════════════════════════════════════════════════════════════════════╗ +║ ${eHeader}║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ║ +║ ${e1}║ +║ ║ +║ ${e2}║ +║ ║ +║ ${e3}║ +║ ${e4}║ +║ ║ +║ ${e5}║ +║ ${e6}║ +║ ║ +║ ${e7}║ +║ ${e8}║ +║ ║ +╚═════════════════════════════════════════════════════════════════════╝ +`); + process.exit(1); + } else { + logger.error('Error starting server:', error); + process.exit(1); + } + }); +}; + +startServer(PORT, HOST); + +// Global error handlers to prevent crashes from uncaught errors +process.on('unhandledRejection', (reason: unknown, _promise: Promise) => { + logger.error('Unhandled Promise Rejection:', { + reason: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + // Don't exit - log the error and continue running + // This prevents the server from crashing due to unhandled rejections +}); + +process.on('uncaughtException', (error: Error) => { + logger.error('Uncaught Exception:', { + message: error.message, + stack: error.stack, + }); + // Exit on uncaught exceptions to prevent undefined behavior + // The process is in an unknown state after an uncaught exception + process.exit(1); +}); + +// Graceful shutdown timeout (30 seconds) +const SHUTDOWN_TIMEOUT_MS = 30000; + +// Graceful shutdown helper +const gracefulShutdown = async (signal: string) => { + logger.info(`${signal} received, shutting down...`); + + // Set up a force-exit timeout to prevent hanging + const forceExitTimeout = setTimeout(() => { + logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + + // Mark all running features as interrupted before shutdown + // This ensures they can be resumed when the server restarts + // Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects + await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`); + + terminalService.cleanup(); + server.close(() => { + clearTimeout(forceExitTimeout); + logger.info('Server closed'); + process.exit(0); + }); +}; + +process.on('SIGTERM', () => { + gracefulShutdown('SIGTERM'); +}); + +process.on('SIGINT', () => { + gracefulShutdown('SIGINT'); +}); diff --git a/jules_branch/apps/server/src/lib/agent-discovery.ts b/jules_branch/apps/server/src/lib/agent-discovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..b831bdecf65418627a67ffbfb2579cb97ad61180 --- /dev/null +++ b/jules_branch/apps/server/src/lib/agent-discovery.ts @@ -0,0 +1,257 @@ +/** + * Agent Discovery - Scans filesystem for AGENT.md files + * + * Discovers agents from: + * - ~/.claude/agents/ (user-level, global) + * - .claude/agents/ (project-level) + * + * Similar to Skills, but for custom subagents defined in AGENT.md files. + */ + +import path from 'path'; +import os from 'os'; +import { createLogger } from '@automaker/utils'; +import { secureFs, systemPaths } from '@automaker/platform'; +import type { AgentDefinition } from '@automaker/types'; + +const logger = createLogger('AgentDiscovery'); + +export interface FilesystemAgent { + name: string; // Directory name (e.g., 'code-reviewer') + definition: AgentDefinition; + source: 'user' | 'project'; + filePath: string; // Full path to AGENT.md +} + +/** + * Parse agent content string into AgentDefinition + * Format: + * --- + * name: agent-name # Optional + * description: When to use this agent + * tools: tool1, tool2, tool3 # Optional (comma or space separated list) + * model: sonnet # Optional: sonnet, opus, haiku + * --- + * System prompt content here... + */ +function parseAgentContent(content: string, filePath: string): AgentDefinition | null { + // Extract frontmatter + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!frontmatterMatch) { + logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`); + return null; + } + + const [, frontmatter, prompt] = frontmatterMatch; + + // Parse description (required) + const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim(); + if (!description) { + logger.warn(`Missing description in agent file: ${filePath}`); + return null; + } + + // Parse tools (optional) - supports both comma-separated and space-separated + const toolsMatch = frontmatter.match(/tools:\s*(.+)/); + const tools = toolsMatch + ? toolsMatch[1] + .split(/[,\s]+/) // Split by comma or whitespace + .map((t) => t.trim()) + .filter((t) => t && t !== '') + : undefined; + + // Parse model (optional) - validate against allowed values + const modelMatch = frontmatter.match(/model:\s*(\w+)/); + const modelValue = modelMatch?.[1]?.trim(); + const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const; + const model = + modelValue && validModels.includes(modelValue as (typeof validModels)[number]) + ? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit') + : undefined; + + if (modelValue && !model) { + logger.warn( + `Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}` + ); + } + + return { + description, + prompt: prompt.trim(), + tools, + model, + }; +} + +/** + * Directory entry with type information + */ +interface DirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; +} + +/** + * Filesystem adapter interface for abstracting systemPaths vs secureFs + */ +interface FsAdapter { + exists: (filePath: string) => Promise; + readdir: (dirPath: string) => Promise; + readFile: (filePath: string) => Promise; +} + +/** + * Create a filesystem adapter for system paths (user directory) + */ +function createSystemPathAdapter(): FsAdapter { + return { + exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)), + readdir: async (dirPath) => { + const entryNames = await systemPaths.systemPathReaddir(dirPath); + const entries: DirEntry[] = []; + for (const name of entryNames) { + const stat = await systemPaths.systemPathStat(path.join(dirPath, name)); + entries.push({ + name, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + }); + } + return entries; + }, + readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Create a filesystem adapter for project paths (secureFs) + */ +function createSecureFsAdapter(): FsAdapter { + return { + exists: (filePath) => + secureFs + .access(filePath) + .then(() => true) + .catch(() => false), + readdir: async (dirPath) => { + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + })); + }, + readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise, + }; +} + +/** + * Parse agent file using the provided filesystem adapter + */ +async function parseAgentFileWithAdapter( + filePath: string, + fsAdapter: FsAdapter +): Promise { + try { + const content = await fsAdapter.readFile(filePath); + return parseAgentContent(content, filePath); + } catch (error) { + logger.error(`Failed to parse agent file: ${filePath}`, error); + return null; + } +} + +/** + * Scan a directory for agent .md files + * Agents can be in two formats: + * 1. Flat: agent-name.md (file directly in agents/) + * 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills) + */ +async function scanAgentsDirectory( + baseDir: string, + source: 'user' | 'project' +): Promise { + const agents: FilesystemAgent[] = []; + const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter(); + + try { + // Check if directory exists + const exists = await fsAdapter.exists(baseDir); + if (!exists) { + logger.debug(`Directory does not exist: ${baseDir}`); + return agents; + } + + // Read all entries in the directory + const entries = await fsAdapter.readdir(baseDir); + + for (const entry of entries) { + // Check for flat .md file format (agent-name.md) + if (entry.isFile && entry.name.endsWith('.md')) { + const agentName = entry.name.slice(0, -3); // Remove .md extension + const agentFilePath = path.join(baseDir, entry.name); + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); + if (definition) { + agents.push({ + name: agentName, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (flat): ${agentName}`); + } + } + // Check for subdirectory format (agent-name/AGENT.md) + else if (entry.isDirectory) { + const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md'); + const agentFileExists = await fsAdapter.exists(agentFilePath); + + if (agentFileExists) { + const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter); + if (definition) { + agents.push({ + name: entry.name, + definition, + source, + filePath: agentFilePath, + }); + logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`); + } + } + } + } + } catch (error) { + logger.error(`Failed to scan agents directory: ${baseDir}`, error); + } + + return agents; +} + +/** + * Discover all filesystem-based agents from user and project sources + */ +export async function discoverFilesystemAgents( + projectPath?: string, + sources: Array<'user' | 'project'> = ['user', 'project'] +): Promise { + const agents: FilesystemAgent[] = []; + + // Discover user-level agents from ~/.claude/agents/ + if (sources.includes('user')) { + const userAgentsDir = path.join(os.homedir(), '.claude', 'agents'); + const userAgents = await scanAgentsDirectory(userAgentsDir, 'user'); + agents.push(...userAgents); + logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`); + } + + // Discover project-level agents from .claude/agents/ + if (sources.includes('project') && projectPath) { + const projectAgentsDir = path.join(projectPath, '.claude', 'agents'); + const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project'); + agents.push(...projectAgents); + logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`); + } + + return agents; +} diff --git a/jules_branch/apps/server/src/lib/app-spec-format.ts b/jules_branch/apps/server/src/lib/app-spec-format.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8393cf1e04bd1c02a95863a46a9228c9a54db06 --- /dev/null +++ b/jules_branch/apps/server/src/lib/app-spec-format.ts @@ -0,0 +1,210 @@ +/** + * XML Template Format Specification for app_spec.txt + * + * This format must be included in all prompts that generate, modify, or regenerate + * app specifications to ensure consistency across the application. + */ + +// Import and re-export spec types from shared package +export type { SpecOutput } from '@automaker/types'; +export { specOutputSchema } from '@automaker/types'; + +/** + * Escape special XML characters + * Handles undefined/null values by converting them to empty strings + */ +export function escapeXml(str: string | undefined | null): string { + if (str == null) { + return ''; + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Convert structured spec output to XML format + */ +export function specToXml(spec: import('@automaker/types').SpecOutput): string { + const indent = ' '; + + let xml = ` + +${indent}${escapeXml(spec.project_name)} + +${indent} +${indent}${indent}${escapeXml(spec.overview)} +${indent} + +${indent} +${spec.technology_stack.map((t) => `${indent}${indent}${escapeXml(t)}`).join('\n')} +${indent} + +${indent} +${spec.core_capabilities.map((c) => `${indent}${indent}${escapeXml(c)}`).join('\n')} +${indent} + +${indent} +${spec.implemented_features + .map( + (f) => `${indent}${indent} +${indent}${indent}${indent}${escapeXml(f.name)} +${indent}${indent}${indent}${escapeXml(f.description)}${ + f.file_locations && f.file_locations.length > 0 + ? `\n${indent}${indent}${indent} +${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}${escapeXml(loc)}`).join('\n')} +${indent}${indent}${indent}` + : '' + } +${indent}${indent}` + ) + .join('\n')} +${indent}`; + + // Optional sections + if (spec.additional_requirements && spec.additional_requirements.length > 0) { + xml += ` + +${indent} +${spec.additional_requirements.map((r) => `${indent}${indent}${escapeXml(r)}`).join('\n')} +${indent}`; + } + + if (spec.development_guidelines && spec.development_guidelines.length > 0) { + xml += ` + +${indent} +${spec.development_guidelines.map((g) => `${indent}${indent}${escapeXml(g)}`).join('\n')} +${indent}`; + } + + if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) { + xml += ` + +${indent} +${spec.implementation_roadmap + .map( + (r) => `${indent}${indent} +${indent}${indent}${indent}${escapeXml(r.phase)} +${indent}${indent}${indent}${escapeXml(r.status)} +${indent}${indent}${indent}${escapeXml(r.description)} +${indent}${indent}` + ) + .join('\n')} +${indent}`; + } + + xml += ` +`; + + return xml; +} + +/** + * Get prompt instruction for structured output (simpler than XML instructions) + */ +export function getStructuredSpecPromptInstruction(): string { + return ` +Analyze the project and provide a comprehensive specification with: + +1. **project_name**: The name of the project +2. **overview**: A comprehensive description of what the project does, its purpose, and key goals +3. **technology_stack**: List all technologies, frameworks, libraries, and tools used +4. **core_capabilities**: List the main features and capabilities the project provides +5. **implemented_features**: For each implemented feature, provide: + - name: Feature name + - description: What it does + - file_locations: Key files where it's implemented (optional) +6. **additional_requirements**: Any system requirements, dependencies, or constraints (optional) +7. **development_guidelines**: Development standards and best practices (optional) +8. **implementation_roadmap**: Project phases with status (completed/in_progress/pending) (optional) + +Be thorough in your analysis. The output will be automatically formatted as structured JSON. +`; +} +export const APP_SPEC_XML_FORMAT = ` +The app_spec.txt file MUST follow this exact XML format: + + + Project Name + + + A comprehensive description of what the project does, its purpose, and key goals. + + + + Technology 1 + Technology 2 + + + + + Core capability 1 + Core capability 2 + + + + + + + + + + + + + + Guideline 1 + Guideline 2 + + + + + + + + +IMPORTANT: +- All content must be wrapped in valid XML tags +- Use proper XML escaping for special characters (<, >, &) +- Maintain proper indentation (2 spaces) +- All sections should be populated based on project analysis +- The format must be strictly followed - do not use markdown, JSON, or any other format +`; + +/** + * Returns a prompt suffix that instructs the AI to format the response as XML + * following the app_spec.txt template format. + */ +export function getAppSpecFormatInstruction(): string { + return ` +${APP_SPEC_XML_FORMAT} + +CRITICAL FORMATTING REQUIREMENTS: +- Do NOT use the Write, Edit, or Bash tools to create files - just OUTPUT the XML in your response +- Your ENTIRE response MUST be valid XML following the exact template structure above +- Do NOT use markdown formatting (no # headers, no **bold**, no - lists, etc.) +- Do NOT include any explanatory text, prefix, or suffix outside the XML tags +- Do NOT include phrases like "Based on my analysis...", "I'll create...", "Let me analyze..." before the XML +- Do NOT include any text before or after +- Your response must start IMMEDIATELY with with no preceding text +- Your response must end IMMEDIATELY with with no following text +- Use ONLY XML tags as shown in the template +- Properly escape XML special characters (< for <, > for >, & for &) +- Maintain 2-space indentation for readability +- The output will be saved directly to app_spec.txt and must be parseable as valid XML +- The response must contain exactly ONE root XML element: +- Do not include code blocks, markdown fences, or any other formatting + +VERIFICATION: Before responding, verify that: +1. Your response starts with (no spaces, no text before it) +2. Your response ends with (no spaces, no text after it) +3. There is exactly one root XML element +4. There is no explanatory text, analysis, or commentary outside the XML tags + +Your response should be ONLY the XML content, nothing else. +`; +} diff --git a/jules_branch/apps/server/src/lib/auth-utils.ts b/jules_branch/apps/server/src/lib/auth-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..936d22779def91b1a1529c44b76efb15186696bc --- /dev/null +++ b/jules_branch/apps/server/src/lib/auth-utils.ts @@ -0,0 +1,263 @@ +/** + * Secure authentication utilities that avoid environment variable race conditions + */ + +import { spawn } from 'child_process'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('AuthUtils'); + +export interface SecureAuthEnv { + [key: string]: string | undefined; +} + +export interface AuthValidationResult { + isValid: boolean; + error?: string; + normalizedKey?: string; +} + +/** + * Validates API key format without modifying process.env + */ +export function validateApiKey( + key: string, + provider: 'anthropic' | 'openai' | 'cursor' +): AuthValidationResult { + if (!key || typeof key !== 'string' || key.trim().length === 0) { + return { isValid: false, error: 'API key is required' }; + } + + const trimmedKey = key.trim(); + + switch (provider) { + case 'anthropic': + if (!trimmedKey.startsWith('sk-ant-')) { + return { + isValid: false, + error: 'Invalid Anthropic API key format. Should start with "sk-ant-"', + }; + } + if (trimmedKey.length < 20) { + return { isValid: false, error: 'Anthropic API key too short' }; + } + break; + + case 'openai': + if (!trimmedKey.startsWith('sk-')) { + return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' }; + } + if (trimmedKey.length < 20) { + return { isValid: false, error: 'OpenAI API key too short' }; + } + break; + + case 'cursor': + // Cursor API keys might have different format + if (trimmedKey.length < 10) { + return { isValid: false, error: 'Cursor API key too short' }; + } + break; + } + + return { isValid: true, normalizedKey: trimmedKey }; +} + +/** + * Creates a secure environment object for authentication testing + * without modifying the global process.env + */ +export function createSecureAuthEnv( + authMethod: 'cli' | 'api_key', + apiKey?: string, + provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic' +): SecureAuthEnv { + const env: SecureAuthEnv = { ...process.env }; + + if (authMethod === 'cli') { + // For CLI auth, remove the API key to force CLI authentication + const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'; + delete env[envKey]; + } else if (authMethod === 'api_key' && apiKey) { + // For API key auth, validate and set the provided key + const validation = validateApiKey(apiKey, provider); + if (!validation.isValid) { + throw new Error(validation.error); + } + const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'; + env[envKey] = validation.normalizedKey; + } + + return env; +} + +/** + * Creates a temporary environment override for the current process + * WARNING: This should only be used in isolated contexts and immediately cleaned up + */ +export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void { + const originalEnv = { ...process.env }; + + // Apply the auth environment + Object.assign(process.env, authEnv); + + // Return cleanup function + return () => { + // Restore original environment + Object.keys(process.env).forEach((key) => { + if (!(key in originalEnv)) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }; +} + +/** + * Spawns a process with secure environment isolation + */ +export function spawnSecureAuth( + command: string, + args: string[], + authEnv: SecureAuthEnv, + options: { + cwd?: string; + timeout?: number; + } = {} +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + return new Promise((resolve, reject) => { + const { cwd = process.cwd(), timeout = 30000 } = options; + + logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`); + + const child = spawn(command, args, { + cwd, + env: authEnv, + stdio: 'pipe', + shell: false, + }); + + let stdout = ''; + let stderr = ''; + let isResolved = false; + + const timeoutId = setTimeout(() => { + if (!isResolved) { + child.kill('SIGTERM'); + isResolved = true; + reject(new Error('Authentication process timed out')); + } + }, timeout); + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + clearTimeout(timeoutId); + if (!isResolved) { + isResolved = true; + resolve({ stdout, stderr, exitCode: code }); + } + }); + + child.on('error', (error) => { + clearTimeout(timeoutId); + if (!isResolved) { + isResolved = true; + reject(error); + } + }); + }); +} + +/** + * Safely extracts environment variable without race conditions + */ +export function safeGetEnv(key: string): string | undefined { + return process.env[key]; +} + +/** + * Checks if an environment variable would be modified without actually modifying it + */ +export function wouldModifyEnv(key: string, newValue: string): boolean { + const currentValue = safeGetEnv(key); + return currentValue !== newValue; +} + +/** + * Secure auth session management + */ +export class AuthSessionManager { + private static activeSessions = new Map(); + + static createSession( + sessionId: string, + authMethod: 'cli' | 'api_key', + apiKey?: string, + provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic' + ): SecureAuthEnv { + const env = createSecureAuthEnv(authMethod, apiKey, provider); + this.activeSessions.set(sessionId, env); + return env; + } + + static getSession(sessionId: string): SecureAuthEnv | undefined { + return this.activeSessions.get(sessionId); + } + + static destroySession(sessionId: string): void { + this.activeSessions.delete(sessionId); + } + + static cleanup(): void { + this.activeSessions.clear(); + } +} + +/** + * Rate limiting for auth attempts to prevent abuse + */ +export class AuthRateLimiter { + private attempts = new Map(); + + constructor( + private maxAttempts = 5, + private windowMs = 60000 + ) {} + + canAttempt(identifier: string): boolean { + const now = Date.now(); + const record = this.attempts.get(identifier); + + if (!record || now - record.lastAttempt > this.windowMs) { + this.attempts.set(identifier, { count: 1, lastAttempt: now }); + return true; + } + + if (record.count >= this.maxAttempts) { + return false; + } + + record.count++; + record.lastAttempt = now; + return true; + } + + getRemainingAttempts(identifier: string): number { + const record = this.attempts.get(identifier); + if (!record) return this.maxAttempts; + return Math.max(0, this.maxAttempts - record.count); + } + + getResetTime(identifier: string): Date | null { + const record = this.attempts.get(identifier); + if (!record) return null; + return new Date(record.lastAttempt + this.windowMs); + } +} diff --git a/jules_branch/apps/server/src/lib/auth.ts b/jules_branch/apps/server/src/lib/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..60cb2d58cae036e33a0432be4d215e7fa9b34146 --- /dev/null +++ b/jules_branch/apps/server/src/lib/auth.ts @@ -0,0 +1,467 @@ +/** + * Authentication middleware for API security + * + * Supports two authentication methods: + * 1. Header-based (X-API-Key) - Used by Electron mode + * 2. Cookie-based (HTTP-only session cookie) - Used by web mode + * + * Auto-generates an API key on first run if none is configured. + */ + +import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import path from 'path'; +import * as secureFs from './secure-fs.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Auth'); + +const DATA_DIR = process.env.DATA_DIR || './data'; +const API_KEY_FILE = path.join(DATA_DIR, '.api-key'); +const SESSIONS_FILE = path.join(DATA_DIR, '.sessions'); +const SESSION_COOKIE_NAME = 'automaker_session'; +const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens + +/** + * Check if an environment variable is set to 'true' + */ +function isEnvTrue(envVar: string | undefined): boolean { + return envVar === 'true'; +} + +// Session store - persisted to file for survival across server restarts +const validSessions = new Map(); + +// Short-lived WebSocket connection tokens (in-memory only, not persisted) +const wsConnectionTokens = new Map(); + +// Clean up expired WebSocket tokens periodically +setInterval(() => { + const now = Date.now(); + wsConnectionTokens.forEach((data, token) => { + if (data.expiresAt <= now) { + wsConnectionTokens.delete(token); + } + }); +}, 60 * 1000); // Clean up every minute + +/** + * Load sessions from file on startup + */ +function loadSessions(): void { + try { + if (secureFs.existsSync(SESSIONS_FILE)) { + const data = secureFs.readFileSync(SESSIONS_FILE, 'utf-8') as string; + const sessions = JSON.parse(data) as Array< + [string, { createdAt: number; expiresAt: number }] + >; + const now = Date.now(); + let loadedCount = 0; + let expiredCount = 0; + + for (const [token, session] of sessions) { + // Only load non-expired sessions + if (session.expiresAt > now) { + validSessions.set(token, session); + loadedCount++; + } else { + expiredCount++; + } + } + + if (loadedCount > 0 || expiredCount > 0) { + logger.info(`Loaded ${loadedCount} sessions (${expiredCount} expired)`); + } + } + } catch (error) { + logger.warn('Error loading sessions:', error); + } +} + +/** + * Save sessions to file (async) + */ +async function saveSessions(): Promise { + try { + await secureFs.mkdir(path.dirname(SESSIONS_FILE), { recursive: true }); + const sessions = Array.from(validSessions.entries()); + await secureFs.writeFile(SESSIONS_FILE, JSON.stringify(sessions), { + encoding: 'utf-8', + mode: 0o600, + }); + } catch (error) { + logger.error('Failed to save sessions:', error); + } +} + +// Load existing sessions on startup +loadSessions(); + +/** + * Ensure an API key exists - either from env var, file, or generate new one. + * This provides CSRF protection by requiring a secret key for all API requests. + */ +function ensureApiKey(): string { + // First check environment variable (Electron passes it this way) + if (process.env.AUTOMAKER_API_KEY) { + logger.info('Using API key from environment variable'); + return process.env.AUTOMAKER_API_KEY; + } + + // Try to read from file + try { + if (secureFs.existsSync(API_KEY_FILE)) { + const key = (secureFs.readFileSync(API_KEY_FILE, 'utf-8') as string).trim(); + if (key) { + logger.info('Loaded API key from file'); + return key; + } + } + } catch (error) { + logger.warn('Error reading API key file:', error); + } + + // Generate new key + const newKey = crypto.randomUUID(); + try { + secureFs.mkdirSync(path.dirname(API_KEY_FILE), { recursive: true }); + secureFs.writeFileSync(API_KEY_FILE, newKey, { encoding: 'utf-8', mode: 0o600 }); + logger.info('Generated new API key'); + } catch (error) { + logger.error('Failed to save API key:', error); + } + return newKey; +} + +// API key - always generated/loaded on startup for CSRF protection +const API_KEY = ensureApiKey(); + +// Width for log box content (excluding borders) +const BOX_CONTENT_WIDTH = 67; + +// Print API key to console for web mode users (unless suppressed for production logging) +if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) { + const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN); + const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled'; + + // Build box lines with exact padding + const header = '🔐 API Key for Web Mode Authentication'.padEnd(BOX_CONTENT_WIDTH); + const line1 = "When accessing via browser, you'll be prompted to enter this key:".padEnd( + BOX_CONTENT_WIDTH + ); + const line2 = API_KEY.padEnd(BOX_CONTENT_WIDTH); + const line3 = 'In Electron mode, authentication is handled automatically.'.padEnd( + BOX_CONTENT_WIDTH + ); + const line4 = `Auto-login (AUTOMAKER_AUTO_LOGIN): ${autoLoginStatus}`.padEnd(BOX_CONTENT_WIDTH); + const tipHeader = '💡 Tips'.padEnd(BOX_CONTENT_WIDTH); + const line5 = 'Set AUTOMAKER_API_KEY env var to use a fixed key'.padEnd(BOX_CONTENT_WIDTH); + const line6 = 'Set AUTOMAKER_AUTO_LOGIN=true to skip the login prompt'.padEnd(BOX_CONTENT_WIDTH); + + logger.info(` +╔═════════════════════════════════════════════════════════════════════╗ +║ ${header}║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ║ +║ ${line1}║ +║ ║ +║ ${line2}║ +║ ║ +║ ${line3}║ +║ ║ +║ ${line4}║ +║ ║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ${tipHeader}║ +╠═════════════════════════════════════════════════════════════════════╣ +║ ${line5}║ +║ ${line6}║ +╚═════════════════════════════════════════════════════════════════════╝ +`); +} else { + logger.info('API key banner hidden (AUTOMAKER_HIDE_API_KEY=true)'); +} + +/** + * Generate a cryptographically secure session token + */ +function generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * Create a new session and return the token + */ +export async function createSession(): Promise { + const token = generateSessionToken(); + const now = Date.now(); + validSessions.set(token, { + createdAt: now, + expiresAt: now + SESSION_MAX_AGE_MS, + }); + await saveSessions(); // Persist to file + return token; +} + +/** + * Validate a session token + * Note: This returns synchronously but triggers async persistence if session expired + */ +export function validateSession(token: string): boolean { + const session = validSessions.get(token); + if (!session) return false; + + if (Date.now() > session.expiresAt) { + validSessions.delete(token); + // Fire-and-forget: persist removal asynchronously + saveSessions().catch((err) => logger.error('Error saving sessions:', err)); + return false; + } + + return true; +} + +/** + * Invalidate a session token + */ +export async function invalidateSession(token: string): Promise { + validSessions.delete(token); + await saveSessions(); // Persist removal +} + +/** + * Create a short-lived WebSocket connection token + * Used for initial WebSocket handshake authentication + */ +export function createWsConnectionToken(): string { + const token = generateSessionToken(); + const now = Date.now(); + wsConnectionTokens.set(token, { + createdAt: now, + expiresAt: now + WS_TOKEN_MAX_AGE_MS, + }); + return token; +} + +/** + * Validate a WebSocket connection token + * These tokens are single-use and short-lived (5 minutes) + * Token is invalidated immediately after first successful use + */ +export function validateWsConnectionToken(token: string): boolean { + const tokenData = wsConnectionTokens.get(token); + if (!tokenData) return false; + + // Always delete the token (single-use) + wsConnectionTokens.delete(token); + + // Check if expired + if (Date.now() > tokenData.expiresAt) { + return false; + } + + return true; +} + +/** + * Validate the API key using timing-safe comparison + * Prevents timing attacks that could leak information about the key + */ +export function validateApiKey(key: string): boolean { + if (!key || typeof key !== 'string') return false; + + // Both buffers must be the same length for timingSafeEqual + const keyBuffer = Buffer.from(key); + const apiKeyBuffer = Buffer.from(API_KEY); + + // If lengths differ, compare against a dummy to maintain constant time + if (keyBuffer.length !== apiKeyBuffer.length) { + crypto.timingSafeEqual(apiKeyBuffer, apiKeyBuffer); + return false; + } + + return crypto.timingSafeEqual(keyBuffer, apiKeyBuffer); +} + +/** + * Get session cookie options + */ +export function getSessionCookieOptions(): { + httpOnly: boolean; + secure: boolean; + sameSite: 'strict' | 'lax' | 'none'; + maxAge: number; + path: string; +} { + return { + httpOnly: true, // JavaScript cannot access this cookie + secure: process.env.NODE_ENV === 'production', // HTTPS only in production + sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR + maxAge: SESSION_MAX_AGE_MS, + path: '/', + }; +} + +/** + * Get the session cookie name + */ +export function getSessionCookieName(): string { + return SESSION_COOKIE_NAME; +} + +/** + * Authentication result type + */ +type AuthResult = + | { authenticated: true } + | { authenticated: false; errorType: 'invalid_api_key' | 'invalid_session' | 'no_auth' }; + +/** + * Core authentication check - shared between middleware and status check + * Extracts auth credentials from various sources and validates them + */ +function checkAuthentication( + headers: Record, + query: Record, + cookies: Record +): AuthResult { + // Check for API key in header (Electron mode) + const headerKey = headers['x-api-key'] as string | undefined; + if (headerKey) { + if (validateApiKey(headerKey)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_api_key' }; + } + + // Check for session token in header (web mode with explicit token) + const sessionTokenHeader = headers['x-session-token'] as string | undefined; + if (sessionTokenHeader) { + if (validateSession(sessionTokenHeader)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_session' }; + } + + // Check for API key in query parameter (fallback) + const queryKey = query.apiKey; + if (queryKey) { + if (validateApiKey(queryKey)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_api_key' }; + } + + // Check for session token in query parameter (web mode - needed for image loads) + const queryToken = query.token; + if (queryToken) { + if (validateSession(queryToken)) { + return { authenticated: true }; + } + return { authenticated: false, errorType: 'invalid_session' }; + } + + // Check for session cookie (web mode) + const sessionToken = cookies[SESSION_COOKIE_NAME]; + if (sessionToken && validateSession(sessionToken)) { + return { authenticated: true }; + } + + return { authenticated: false, errorType: 'no_auth' }; +} + +/** + * Authentication middleware + * + * Accepts either: + * 1. X-API-Key header (for Electron mode) + * 2. X-Session-Token header (for web mode with explicit token) + * 3. apiKey query parameter (fallback for Electron, cases where headers can't be set) + * 4. token query parameter (fallback for web mode, needed for image loads via CSS/img tags) + * 5. Session cookie (for web mode) + */ +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + // Allow disabling auth for local/trusted networks + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) { + next(); + return; + } + + const result = checkAuthentication( + req.headers as Record, + req.query as Record, + (req.cookies || {}) as Record + ); + + if (result.authenticated) { + next(); + return; + } + + // Return appropriate error based on what failed + switch (result.errorType) { + case 'invalid_api_key': + res.status(403).json({ + success: false, + error: 'Invalid API key.', + }); + break; + case 'invalid_session': + res.status(403).json({ + success: false, + error: 'Invalid or expired session token.', + }); + break; + case 'no_auth': + default: + res.status(401).json({ + success: false, + error: 'Authentication required.', + }); + } +} + +/** + * Check if authentication is enabled (always true now) + */ +export function isAuthEnabled(): boolean { + return true; +} + +/** + * Get authentication status for health endpoint + */ +export function getAuthStatus(): { enabled: boolean; method: string } { + const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH); + return { + enabled: !disabled, + method: disabled ? 'disabled' : 'api_key_or_session', + }; +} + +/** + * Check if a request is authenticated (for status endpoint) + */ +export function isRequestAuthenticated(req: Request): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; + const result = checkAuthentication( + req.headers as Record, + req.query as Record, + (req.cookies || {}) as Record + ); + return result.authenticated; +} + +/** + * Check if raw credentials are authenticated + * Used for WebSocket authentication where we don't have Express request objects + */ +export function checkRawAuthentication( + headers: Record, + query: Record, + cookies: Record +): boolean { + if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true; + return checkAuthentication(headers, query, cookies).authenticated; +} diff --git a/jules_branch/apps/server/src/lib/cli-detection.ts b/jules_branch/apps/server/src/lib/cli-detection.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7b5b14db36f57965dc6f77fcb0fed3b5c2a0246 --- /dev/null +++ b/jules_branch/apps/server/src/lib/cli-detection.ts @@ -0,0 +1,444 @@ +/** + * Unified CLI Detection Framework + * + * Provides consistent CLI detection and management across all providers + */ + +import { spawn, execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CliInfo { + name: string; + command: string; + version?: string; + path?: string; + installed: boolean; + authenticated: boolean; + authMethod: 'cli' | 'api_key' | 'none'; + platform?: string; + architectures?: string[]; +} + +export interface CliDetectionOptions { + timeout?: number; + includeWsl?: boolean; + wslDistribution?: string; +} + +export interface CliDetectionResult { + cli: CliInfo; + detected: boolean; + issues: string[]; +} + +export interface UnifiedCliDetection { + claude?: CliDetectionResult; + codex?: CliDetectionResult; + cursor?: CliDetectionResult; +} + +/** + * CLI Configuration for different providers + */ +const CLI_CONFIGS = { + claude: { + name: 'Claude CLI', + commands: ['claude'], + versionArgs: ['--version'], + installCommands: { + darwin: 'brew install anthropics/claude/claude', + linux: 'curl -fsSL https://claude.ai/install.sh | sh', + win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex', + }, + }, + codex: { + name: 'Codex CLI', + commands: ['codex', 'openai'], + versionArgs: ['--version'], + installCommands: { + darwin: 'npm install -g @openai/codex-cli', + linux: 'npm install -g @openai/codex-cli', + win32: 'npm install -g @openai/codex-cli', + }, + }, + cursor: { + name: 'Cursor CLI', + commands: ['cursor-agent', 'cursor'], + versionArgs: ['--version'], + installCommands: { + darwin: 'brew install cursor/cursor/cursor-agent', + linux: 'curl -fsSL https://cursor.sh/install.sh | sh', + win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex', + }, + }, +} as const; + +/** + * Detect if a CLI is installed and available + */ +export async function detectCli( + provider: keyof typeof CLI_CONFIGS, + options: CliDetectionOptions = {} +): Promise { + const config = CLI_CONFIGS[provider]; + const { timeout = 5000 } = options; + const issues: string[] = []; + + const cliInfo: CliInfo = { + name: config.name, + command: '', + installed: false, + authenticated: false, + authMethod: 'none', + }; + + try { + // Find the command in PATH + const command = await findCommand([...config.commands]); + if (command) { + cliInfo.command = command; + } + + if (!cliInfo.command) { + issues.push(`${config.name} not found in PATH`); + return { cli: cliInfo, detected: false, issues }; + } + + cliInfo.path = cliInfo.command; + cliInfo.installed = true; + + // Get version + try { + cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout); + } catch (error) { + issues.push(`Failed to get ${config.name} version: ${error}`); + } + + // Check authentication + cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command); + cliInfo.authenticated = cliInfo.authMethod !== 'none'; + + return { cli: cliInfo, detected: true, issues }; + } catch (error) { + issues.push(`Error detecting ${config.name}: ${error}`); + return { cli: cliInfo, detected: false, issues }; + } +} + +/** + * Detect all CLIs in the system + */ +export async function detectAllCLis( + options: CliDetectionOptions = {} +): Promise { + const results: UnifiedCliDetection = {}; + + // Detect all providers in parallel + const providers = Object.keys(CLI_CONFIGS) as Array; + const detectionPromises = providers.map(async (provider) => { + const result = await detectCli(provider, options); + return { provider, result }; + }); + + const detections = await Promise.all(detectionPromises); + + for (const { provider, result } of detections) { + results[provider] = result; + } + + return results; +} + +/** + * Find the first available command from a list of alternatives + */ +export async function findCommand(commands: string[]): Promise { + for (const command of commands) { + try { + const whichCommand = process.platform === 'win32' ? 'where' : 'which'; + const result = execSync(`${whichCommand} ${command}`, { + encoding: 'utf8', + timeout: 2000, + }).trim(); + + if (result) { + return result.split('\n')[0]; // Take first result on Windows + } + } catch { + // Command not found, try next + } + } + return null; +} + +/** + * Get CLI version + */ +export async function getCliVersion( + command: string, + args: string[], + timeout: number = 5000 +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'pipe', + timeout, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0 && stdout) { + resolve(stdout.trim()); + } else if (stderr) { + reject(stderr.trim()); + } else { + reject(`Command exited with code ${code}`); + } + }); + + child.on('error', reject); + }); +} + +/** + * Check authentication status for a CLI + */ +export async function checkCliAuth( + provider: keyof typeof CLI_CONFIGS, + command: string +): Promise<'cli' | 'api_key' | 'none'> { + try { + switch (provider) { + case 'claude': + return await checkClaudeAuth(command); + case 'codex': + return await checkCodexAuth(command); + case 'cursor': + return await checkCursorAuth(command); + default: + return 'none'; + } + } catch { + return 'none'; + } +} + +/** + * Check Claude CLI authentication + */ +async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { + try { + // Check for environment variable + if (process.env.ANTHROPIC_API_KEY) { + return 'api_key'; + } + + // Try running a simple command to check CLI auth + const result = await getCliVersion(command, ['--version'], 3000); + if (result) { + return 'cli'; // If version works, assume CLI is authenticated + } + } catch { + // Version command might work even without auth, so we need a better check + } + + // Try a more specific auth check + return new Promise((resolve) => { + const child = spawn(command, ['whoami'], { + stdio: 'pipe', + timeout: 3000, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0 && stdout && !stderr.includes('not authenticated')) { + resolve('cli'); + } else { + resolve('none'); + } + }); + + child.on('error', () => { + resolve('none'); + }); + }); +} + +/** + * Check Codex CLI authentication + */ +async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { + // Check for environment variable + if (process.env.OPENAI_API_KEY) { + return 'api_key'; + } + + try { + // Try a simple auth check + const result = await getCliVersion(command, ['--version'], 3000); + if (result) { + return 'cli'; + } + } catch { + // Version check failed + } + + return 'none'; +} + +/** + * Check Cursor CLI authentication + */ +async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { + // Check for environment variable + if (process.env.CURSOR_API_KEY) { + return 'api_key'; + } + + // Check for credentials files + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + path.join(os.homedir(), '.cursor', 'auth.json'), + path.join(os.homedir(), '.config', 'cursor', 'auth.json'), + ]; + + for (const credPath of credentialPaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken || creds.token || creds.apiKey) { + return 'cli'; + } + } + } catch { + // Invalid credentials file + } + } + + // Try a simple command + try { + const result = await getCliVersion(command, ['--version'], 3000); + if (result) { + return 'cli'; + } + } catch { + // Version check failed + } + + return 'none'; +} + +/** + * Get installation instructions for a provider + */ +export function getInstallInstructions( + provider: keyof typeof CLI_CONFIGS, + platform: NodeJS.Platform = process.platform +): string { + const config = CLI_CONFIGS[provider]; + const command = config.installCommands[platform as keyof typeof config.installCommands]; + + if (!command) { + return `No installation instructions available for ${provider} on ${platform}`; + } + + return command; +} + +/** + * Get platform-specific CLI paths and versions + */ +export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] { + const config = CLI_CONFIGS[provider]; + const platform = process.platform; + + switch (platform) { + case 'darwin': + return [ + `/usr/local/bin/${config.commands[0]}`, + `/opt/homebrew/bin/${config.commands[0]}`, + path.join(os.homedir(), '.local', 'bin', config.commands[0]), + ]; + + case 'linux': + return [ + `/usr/bin/${config.commands[0]}`, + `/usr/local/bin/${config.commands[0]}`, + path.join(os.homedir(), '.local', 'bin', config.commands[0]), + path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]), + ]; + + case 'win32': + return [ + path.join( + os.homedir(), + 'AppData', + 'Local', + 'Programs', + config.commands[0], + `${config.commands[0]}.exe` + ), + path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`), + path.join( + process.env.ProgramFiles || '', + config.commands[0], + 'bin', + `${config.commands[0]}.exe` + ), + ]; + + default: + return []; + } +} + +/** + * Validate CLI installation + */ +export function validateCliInstallation(cliInfo: CliInfo): { + valid: boolean; + issues: string[]; +} { + const issues: string[] = []; + + if (!cliInfo.installed) { + issues.push('CLI is not installed'); + } + + if (cliInfo.installed && !cliInfo.version) { + issues.push('Could not determine CLI version'); + } + + if (cliInfo.installed && cliInfo.authMethod === 'none') { + issues.push('CLI is not authenticated'); + } + + return { + valid: issues.length === 0, + issues, + }; +} diff --git a/jules_branch/apps/server/src/lib/codex-auth.ts b/jules_branch/apps/server/src/lib/codex-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..94fadc8c7c6cf7289b7f122cd0f5066b655b2090 --- /dev/null +++ b/jules_branch/apps/server/src/lib/codex-auth.ts @@ -0,0 +1,68 @@ +/** + * Shared utility for checking Codex CLI authentication status + * + * Uses 'codex login status' command to verify authentication. + * Never assumes authenticated - only returns true if CLI confirms. + */ + +import { spawnProcess } from '@automaker/platform'; +import { findCodexCliPath } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('CodexAuth'); + +const CODEX_COMMAND = 'codex'; +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; + +export interface CodexAuthCheckResult { + authenticated: boolean; + method: 'api_key_env' | 'cli_authenticated' | 'none'; +} + +/** + * Check Codex authentication status using 'codex login status' command + * + * @param cliPath Optional CLI path. If not provided, will attempt to find it. + * @returns Authentication status and method + */ +export async function checkCodexAuthentication( + cliPath?: string | null +): Promise { + const resolvedCliPath = cliPath || (await findCodexCliPath()); + const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + + // If CLI is not installed, cannot be authenticated + if (!resolvedCliPath) { + logger.info('CLI not found'); + return { authenticated: false, method: 'none' }; + } + + try { + const result = await spawnProcess({ + command: resolvedCliPath || CODEX_COMMAND, + args: ['login', 'status'], + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'dumb', // Avoid interactive output + }, + }); + + // Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr + const combinedOutput = (result.stdout + result.stderr).toLowerCase(); + const isLoggedIn = combinedOutput.includes('logged in'); + + if (result.exitCode === 0 && isLoggedIn) { + // Determine auth method based on what we know + const method = hasApiKey ? 'api_key_env' : 'cli_authenticated'; + logger.info(`✓ Authenticated (${method})`); + return { authenticated: true, method }; + } + + logger.info('Not authenticated'); + return { authenticated: false, method: 'none' }; + } catch (error) { + logger.error('Failed to check authentication:', error); + return { authenticated: false, method: 'none' }; + } +} diff --git a/jules_branch/apps/server/src/lib/enhancement-prompts.ts b/jules_branch/apps/server/src/lib/enhancement-prompts.ts new file mode 100644 index 0000000000000000000000000000000000000000..03f85f6ee7fa7515955fe8984a19206d00e61969 --- /dev/null +++ b/jules_branch/apps/server/src/lib/enhancement-prompts.ts @@ -0,0 +1,25 @@ +/** + * Enhancement Prompts - Re-exported from @automaker/prompts + * + * This file now re-exports enhancement prompts from the shared @automaker/prompts package + * to maintain backward compatibility with existing imports in the server codebase. + */ + +export { + IMPROVE_SYSTEM_PROMPT, + TECHNICAL_SYSTEM_PROMPT, + SIMPLIFY_SYSTEM_PROMPT, + ACCEPTANCE_SYSTEM_PROMPT, + IMPROVE_EXAMPLES, + TECHNICAL_EXAMPLES, + SIMPLIFY_EXAMPLES, + ACCEPTANCE_EXAMPLES, + getEnhancementPrompt, + getSystemPrompt, + getExamples, + buildUserPrompt, + isValidEnhancementMode, + getAvailableEnhancementModes, +} from '@automaker/prompts'; + +export type { EnhancementMode, EnhancementExample } from '@automaker/prompts'; diff --git a/jules_branch/apps/server/src/lib/error-handler.ts b/jules_branch/apps/server/src/lib/error-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..d672009848abe5efa51c1c1c657fd5907fa6cdb9 --- /dev/null +++ b/jules_branch/apps/server/src/lib/error-handler.ts @@ -0,0 +1,415 @@ +/** + * Unified Error Handling System for CLI Providers + * + * Provides consistent error classification, user-friendly messages, and debugging support + * across all AI providers (Claude, Codex, Cursor) + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ErrorHandler'); + +export enum ErrorType { + AUTHENTICATION = 'authentication', + BILLING = 'billing', + RATE_LIMIT = 'rate_limit', + NETWORK = 'network', + TIMEOUT = 'timeout', + VALIDATION = 'validation', + PERMISSION = 'permission', + CLI_NOT_FOUND = 'cli_not_found', + CLI_NOT_INSTALLED = 'cli_not_installed', + MODEL_NOT_SUPPORTED = 'model_not_supported', + INVALID_REQUEST = 'invalid_request', + SERVER_ERROR = 'server_error', + UNKNOWN = 'unknown', +} + +export enum ErrorSeverity { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +export interface ErrorClassification { + type: ErrorType; + severity: ErrorSeverity; + userMessage: string; + technicalMessage: string; + suggestedAction?: string; + retryable: boolean; + provider?: string; + context?: Record; +} + +export interface ErrorPattern { + type: ErrorType; + severity: ErrorSeverity; + patterns: RegExp[]; + userMessage: string; + suggestedAction?: string; + retryable: boolean; +} + +/** + * Error patterns for different types of errors + */ +const ERROR_PATTERNS: ErrorPattern[] = [ + // Authentication errors + { + type: ErrorType.AUTHENTICATION, + severity: ErrorSeverity.HIGH, + patterns: [ + /unauthorized/i, + /authentication.*fail/i, + /invalid_api_key/i, + /invalid api key/i, + /not authenticated/i, + /please.*log/i, + /token.*revoked/i, + /oauth.*error/i, + /credentials.*invalid/i, + ], + userMessage: 'Authentication failed. Please check your API key or login credentials.', + suggestedAction: + "Verify your API key is correct and hasn't expired, or run the CLI login command.", + retryable: false, + }, + + // Billing errors + { + type: ErrorType.BILLING, + severity: ErrorSeverity.HIGH, + patterns: [ + /credit.*balance.*low/i, + /insufficient.*credit/i, + /billing.*issue/i, + /payment.*required/i, + /usage.*exceeded/i, + /quota.*exceeded/i, + /add.*credit/i, + ], + userMessage: 'Account has insufficient credits or billing issues.', + suggestedAction: 'Please add credits to your account or check your billing settings.', + retryable: false, + }, + + // Rate limit errors + { + type: ErrorType.RATE_LIMIT, + severity: ErrorSeverity.MEDIUM, + patterns: [ + /rate.*limit/i, + /too.*many.*request/i, + /limit.*reached/i, + /try.*later/i, + /429/i, + /reset.*time/i, + /upgrade.*plan/i, + ], + userMessage: 'Rate limit reached. Please wait before trying again.', + suggestedAction: 'Wait a few minutes before retrying, or consider upgrading your plan.', + retryable: true, + }, + + // Network errors + { + type: ErrorType.NETWORK, + severity: ErrorSeverity.MEDIUM, + patterns: [/network/i, /connection/i, /dns/i, /timeout/i, /econnrefused/i, /enotfound/i], + userMessage: 'Network connection issue.', + suggestedAction: 'Check your internet connection and try again.', + retryable: true, + }, + + // Timeout errors + { + type: ErrorType.TIMEOUT, + severity: ErrorSeverity.MEDIUM, + patterns: [/timeout/i, /aborted/i, /time.*out/i], + userMessage: 'Operation timed out.', + suggestedAction: 'Try again with a simpler request or check your connection.', + retryable: true, + }, + + // Permission errors + { + type: ErrorType.PERMISSION, + severity: ErrorSeverity.HIGH, + patterns: [/permission.*denied/i, /access.*denied/i, /forbidden/i, /403/i, /not.*authorized/i], + userMessage: 'Permission denied.', + suggestedAction: 'Check if you have the required permissions for this operation.', + retryable: false, + }, + + // CLI not found + { + type: ErrorType.CLI_NOT_FOUND, + severity: ErrorSeverity.HIGH, + patterns: [/command not found/i, /not recognized/i, /not.*installed/i, /ENOENT/i], + userMessage: 'CLI tool not found.', + suggestedAction: "Please install the required CLI tool and ensure it's in your PATH.", + retryable: false, + }, + + // Model not supported + { + type: ErrorType.MODEL_NOT_SUPPORTED, + severity: ErrorSeverity.HIGH, + patterns: [/model.*not.*support/i, /unknown.*model/i, /invalid.*model/i], + userMessage: 'Model not supported.', + suggestedAction: 'Check available models and use a supported one.', + retryable: false, + }, + + // Server errors + { + type: ErrorType.SERVER_ERROR, + severity: ErrorSeverity.HIGH, + patterns: [/internal.*server/i, /server.*error/i, /500/i, /502/i, /503/i, /504/i], + userMessage: 'Server error occurred.', + suggestedAction: 'Try again in a few minutes or contact support if the issue persists.', + retryable: true, + }, +]; + +/** + * Classify an error into a specific type with user-friendly message + */ +export function classifyError( + error: unknown, + provider?: string, + context?: Record +): ErrorClassification { + const errorText = getErrorText(error); + + // Try to match against known patterns + for (const pattern of ERROR_PATTERNS) { + for (const regex of pattern.patterns) { + if (regex.test(errorText)) { + return { + type: pattern.type, + severity: pattern.severity, + userMessage: pattern.userMessage, + technicalMessage: errorText, + suggestedAction: pattern.suggestedAction, + retryable: pattern.retryable, + provider, + context, + }; + } + } + } + + // Unknown error + return { + type: ErrorType.UNKNOWN, + severity: ErrorSeverity.MEDIUM, + userMessage: 'An unexpected error occurred.', + technicalMessage: errorText, + suggestedAction: 'Please try again or contact support if the issue persists.', + retryable: true, + provider, + context, + }; +} + +/** + * Get a user-friendly error message + */ +export function getUserFriendlyErrorMessage(error: unknown, provider?: string): string { + const classification = classifyError(error, provider); + + let message = classification.userMessage; + + if (classification.suggestedAction) { + message += ` ${classification.suggestedAction}`; + } + + // Add provider-specific context if available + if (provider) { + message = `[${provider.toUpperCase()}] ${message}`; + } + + return message; +} + +/** + * Check if an error is retryable + */ +export function isRetryableError(error: unknown): boolean { + const classification = classifyError(error); + return classification.retryable; +} + +/** + * Check if an error is authentication-related + */ +export function isAuthenticationError(error: unknown): boolean { + const classification = classifyError(error); + return classification.type === ErrorType.AUTHENTICATION; +} + +/** + * Check if an error is billing-related + */ +export function isBillingError(error: unknown): boolean { + const classification = classifyError(error); + return classification.type === ErrorType.BILLING; +} + +/** + * Check if an error is rate limit related + */ +export function isRateLimitError(error: unknown): boolean { + const classification = classifyError(error); + return classification.type === ErrorType.RATE_LIMIT; +} + +/** + * Get error text from various error types + */ +function getErrorText(error: unknown): string { + if (typeof error === 'string') { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'object' && error !== null) { + // Handle structured error objects + const errorObj = error as Record; + + if (typeof errorObj.message === 'string') { + return errorObj.message; + } + + const nestedError = errorObj.error; + if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) { + return String((nestedError as Record).message); + } + + if (nestedError) { + return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError); + } + + return JSON.stringify(error); + } + + return String(error); +} + +/** + * Create a standardized error response + */ +export function createErrorResponse( + error: unknown, + provider?: string, + context?: Record +): { + success: false; + error: string; + errorType: ErrorType; + severity: ErrorSeverity; + retryable: boolean; + suggestedAction?: string; +} { + const classification = classifyError(error, provider, context); + + return { + success: false, + error: classification.userMessage, + errorType: classification.type, + severity: classification.severity, + retryable: classification.retryable, + suggestedAction: classification.suggestedAction, + }; +} + +/** + * Log error with full context + */ +export function logError( + error: unknown, + provider?: string, + operation?: string, + additionalContext?: Record +): void { + const classification = classifyError(error, provider, { + operation, + ...additionalContext, + }); + + logger.error(`Error in ${provider || 'unknown'}${operation ? ` during ${operation}` : ''}`, { + type: classification.type, + severity: classification.severity, + message: classification.userMessage, + technicalMessage: classification.technicalMessage, + retryable: classification.retryable, + suggestedAction: classification.suggestedAction, + context: classification.context, + }); +} + +/** + * Provider-specific error handlers + */ +export const ProviderErrorHandler = { + claude: { + classify: (error: unknown) => classifyError(error, 'claude'), + getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'claude'), + isAuth: (error: unknown) => isAuthenticationError(error), + isBilling: (error: unknown) => isBillingError(error), + isRateLimit: (error: unknown) => isRateLimitError(error), + }, + + codex: { + classify: (error: unknown) => classifyError(error, 'codex'), + getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'codex'), + isAuth: (error: unknown) => isAuthenticationError(error), + isBilling: (error: unknown) => isBillingError(error), + isRateLimit: (error: unknown) => isRateLimitError(error), + }, + + cursor: { + classify: (error: unknown) => classifyError(error, 'cursor'), + getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'cursor'), + isAuth: (error: unknown) => isAuthenticationError(error), + isBilling: (error: unknown) => isBillingError(error), + isRateLimit: (error: unknown) => isRateLimitError(error), + }, +}; + +/** + * Create a retry handler for retryable errors + */ +export function createRetryHandler(maxRetries: number = 3, baseDelay: number = 1000) { + return async function ( + operation: () => Promise, + shouldRetry: (error: unknown) => boolean = isRetryableError + ): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error; + + if (attempt === maxRetries || !shouldRetry(error)) { + throw error; + } + + // Exponential backoff with jitter + const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000; + logger.debug(`Retrying operation in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + throw lastError; + }; +} diff --git a/jules_branch/apps/server/src/lib/events.ts b/jules_branch/apps/server/src/lib/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f224c4e74094cf4c6d31eb84a7558e168d48132 --- /dev/null +++ b/jules_branch/apps/server/src/lib/events.ts @@ -0,0 +1,39 @@ +/** + * Event emitter for streaming events to WebSocket clients + */ + +import type { EventType, EventCallback } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Events'); + +// Re-export event types from shared package +export type { EventType, EventCallback }; + +export interface EventEmitter { + emit: (type: EventType, payload: unknown) => void; + subscribe: (callback: EventCallback) => () => void; +} + +export function createEventEmitter(): EventEmitter { + const subscribers = new Set(); + + return { + emit(type: EventType, payload: unknown) { + for (const callback of subscribers) { + try { + callback(type, payload); + } catch (error) { + logger.error('Error in event subscriber:', error); + } + } + }, + + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; +} diff --git a/jules_branch/apps/server/src/lib/exec-utils.ts b/jules_branch/apps/server/src/lib/exec-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..0073f6956046811ddd6d2d8d470b667b1e30b24f --- /dev/null +++ b/jules_branch/apps/server/src/lib/exec-utils.ts @@ -0,0 +1,37 @@ +/** + * Shared execution utilities + * + * Common helpers for spawning child processes with the correct environment. + * Used by both route handlers and service layers. + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ExecUtils'); + +// Extended PATH to include common tool installation locations +export const extendedPath = [ + process.env.PATH, + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin`, +] + .filter(Boolean) + .join(':'); + +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export function logError(error: unknown, context: string): void { + logger.error(`${context}:`, error); +} diff --git a/jules_branch/apps/server/src/lib/git-log-parser.ts b/jules_branch/apps/server/src/lib/git-log-parser.ts new file mode 100644 index 0000000000000000000000000000000000000000..85b0cb58c14efcd0c0ac088eca42097705d6ba9f --- /dev/null +++ b/jules_branch/apps/server/src/lib/git-log-parser.ts @@ -0,0 +1,62 @@ +export interface CommitFields { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + subject: string; + body: string; +} + +export function parseGitLogOutput(output: string): CommitFields[] { + const commits: CommitFields[] = []; + + // Split by NUL character to separate commits + const commitBlocks = output.split('\0').filter((block) => block.trim()); + + for (const block of commitBlocks) { + const allLines = block.split('\n'); + + // Skip leading empty lines that may appear at block boundaries + let startIndex = 0; + while (startIndex < allLines.length && allLines[startIndex].trim() === '') { + startIndex++; + } + const fields = allLines.slice(startIndex); + + // Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject) + if (fields.length < 6) { + continue; // Skip malformed blocks + } + + const commit: CommitFields = { + hash: fields[0].trim(), + shortHash: fields[1].trim(), + author: fields[2].trim(), + authorEmail: fields[3].trim(), + date: fields[4].trim(), + subject: fields[5].trim(), + body: fields.slice(6).join('\n').trim(), + }; + + commits.push(commit); + } + + return commits; +} + +/** + * Creates a commit object from parsed fields, matching the expected API response format + */ +export function createCommitFromFields(fields: CommitFields, files?: string[]) { + return { + hash: fields.hash, + shortHash: fields.shortHash, + author: fields.author, + authorEmail: fields.authorEmail, + date: fields.date, + subject: fields.subject, + body: fields.body, + files: files || [], + }; +} diff --git a/jules_branch/apps/server/src/lib/git.ts b/jules_branch/apps/server/src/lib/git.ts new file mode 100644 index 0000000000000000000000000000000000000000..d60ccadead57b8dd7b054320573c6a3868b51562 --- /dev/null +++ b/jules_branch/apps/server/src/lib/git.ts @@ -0,0 +1,236 @@ +/** + * Shared git command execution utilities. + * + * This module provides the canonical `execGitCommand` helper and common + * git utilities used across services and routes. All consumers should + * import from here rather than defining their own copy. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { spawnProcess } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('GitLib'); + +// Extended PATH so git is found when the process does not inherit a full shell PATH +// (e.g. Electron, some CI, or IDE-launched processes). +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const extraPaths: string[] = + process.platform === 'win32' + ? ([ + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`, + process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`, + ].filter(Boolean) as string[]) + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/home/linuxbrew/.linuxbrew/bin', + process.env.HOME ? `${process.env.HOME}/.local/bin` : '', + ].filter(Boolean); + +const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator); +const gitEnv = { ...process.env, PATH: extendedPath }; + +// ============================================================================ +// Secure Command Execution +// ============================================================================ + +/** + * Execute git command with array arguments to prevent command injection. + * Uses spawnProcess from @automaker/platform for secure, cross-platform execution. + * + * @param args - Array of git command arguments (e.g., ['worktree', 'add', path]) + * @param cwd - Working directory to execute the command in + * @param env - Optional additional environment variables to pass to the git process. + * These are merged on top of the current process environment. Pass + * `{ LC_ALL: 'C' }` to force git to emit English output regardless of the + * system locale so that text-based output parsing remains reliable. + * @param abortController - Optional AbortController to cancel the git process. + * When the controller is aborted the underlying process is sent SIGTERM and + * the returned promise rejects with an Error whose message is 'Process aborted'. + * @returns Promise resolving to stdout output + * @throws Error with stderr/stdout message if command fails. The thrown error + * also has `stdout` and `stderr` string properties for structured access. + * + * @example + * ```typescript + * // Safe: no injection possible + * await execGitCommand(['branch', '-D', branchName], projectPath); + * + * // Force English output for reliable text parsing: + * await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' }); + * + * // With a process-level timeout: + * const controller = new AbortController(); + * const timerId = setTimeout(() => controller.abort(), 30_000); + * try { + * await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + * } finally { + * clearTimeout(timerId); + * } + * + * // Instead of unsafe: + * // await execAsync(`git branch -D ${branchName}`, { cwd }); + * ``` + */ +export async function execGitCommand( + args: string[], + cwd: string, + env?: Record, + abortController?: AbortController +): Promise { + const result = await spawnProcess({ + command: 'git', + args, + cwd, + env: + env !== undefined + ? { + ...gitEnv, + ...env, + PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator), + } + : gitEnv, + ...(abortController !== undefined ? { abortController } : {}), + }); + + // spawnProcess returns { stdout, stderr, exitCode } + if (result.exitCode === 0) { + return result.stdout; + } else { + const errorMessage = + result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`; + throw Object.assign(new Error(errorMessage), { + stdout: result.stdout, + stderr: result.stderr, + }); + } +} + +// ============================================================================ +// Common Git Utilities +// ============================================================================ + +/** + * Get the current branch name for the given worktree. + * + * This is the canonical implementation shared across services. Services + * should import this rather than duplicating the logic locally. + * + * @param worktreePath - Path to the git worktree + * @returns The current branch name (trimmed) + */ +export async function getCurrentBranch(worktreePath: string): Promise { + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + return branchOutput.trim(); +} + +// ============================================================================ +// Index Lock Recovery +// ============================================================================ + +/** + * Check whether an error message indicates a stale git index lock file. + * + * Git operations that write to the index (e.g. `git stash push`) will fail + * with "could not write index" or "Unable to create ... .lock" when a + * `.git/index.lock` file exists from a previously interrupted operation. + * + * @param errorMessage - The error string from a failed git command + * @returns true if the error looks like a stale index lock issue + */ +export function isIndexLockError(errorMessage: string): boolean { + const lower = errorMessage.toLowerCase(); + return ( + lower.includes('could not write index') || + (lower.includes('unable to create') && lower.includes('index.lock')) || + lower.includes('index.lock') + ); +} + +/** + * Attempt to remove a stale `.git/index.lock` file for the given worktree. + * + * Uses `git rev-parse --git-dir` to locate the correct `.git` directory, + * which works for both regular repositories and linked worktrees. + * + * @param worktreePath - Path to the git worktree (or main repo) + * @returns true if a lock file was found and removed, false otherwise + */ +export async function removeStaleIndexLock(worktreePath: string): Promise { + try { + // Resolve the .git directory (handles worktrees correctly) + const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + const lockFilePath = path.join(gitDir, 'index.lock'); + + // Check if the lock file exists + try { + await fs.access(lockFilePath); + } catch { + // Lock file does not exist — nothing to remove + return false; + } + + // Remove the stale lock file + await fs.unlink(lockFilePath); + logger.info('Removed stale index.lock file', { worktreePath, lockFilePath }); + return true; + } catch (err) { + logger.warn('Failed to remove stale index.lock file', { + worktreePath, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + +/** + * Execute a git command with automatic retry when a stale index.lock is detected. + * + * If the command fails with an error indicating a locked index file, this + * helper will attempt to remove the stale `.git/index.lock` and retry the + * command exactly once. + * + * This is particularly useful for `git stash push` which writes to the + * index and commonly fails when a previous git operation was interrupted. + * + * @param args - Array of git command arguments + * @param cwd - Working directory to execute the command in + * @param env - Optional additional environment variables + * @returns Promise resolving to stdout output + * @throws The original error if retry also fails, or a non-lock error + */ +export async function execGitCommandWithLockRetry( + args: string[], + cwd: string, + env?: Record +): Promise { + try { + return await execGitCommand(args, cwd, env); + } catch (error: unknown) { + const err = error as { message?: string; stderr?: string }; + const errorMessage = err.stderr || err.message || ''; + + if (!isIndexLockError(errorMessage)) { + throw error; + } + + logger.info('Git command failed due to index lock, attempting cleanup and retry', { + cwd, + args: args.join(' '), + }); + + const removed = await removeStaleIndexLock(cwd); + if (!removed) { + // Could not remove the lock file — re-throw the original error + throw error; + } + + // Retry the command once after removing the lock file + return await execGitCommand(args, cwd, env); + } +} diff --git a/jules_branch/apps/server/src/lib/json-extractor.ts b/jules_branch/apps/server/src/lib/json-extractor.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1a97dd89c28e110f09a056bba801e5064479e06 --- /dev/null +++ b/jules_branch/apps/server/src/lib/json-extractor.ts @@ -0,0 +1,211 @@ +/** + * JSON Extraction Utilities + * + * Robust JSON extraction from AI responses that may contain markdown, + * code blocks, or other text mixed with JSON content. + * + * Used by various routes that parse structured output from Cursor or + * Claude responses when structured output is not available. + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('JsonExtractor'); + +/** + * Logger interface for optional custom logging + */ +export interface JsonExtractorLogger { + debug: (message: string, ...args: unknown[]) => void; + warn?: (message: string, ...args: unknown[]) => void; +} + +/** + * Options for JSON extraction + */ +export interface ExtractJsonOptions { + /** Custom logger (defaults to internal logger) */ + logger?: JsonExtractorLogger; + /** Required key that must be present in the extracted JSON */ + requiredKey?: string; + /** Whether the required key's value must be an array */ + requireArray?: boolean; +} + +/** + * Extract JSON from response text using multiple strategies. + * + * Strategies tried in order: + * 1. JSON in ```json code block + * 2. JSON in ``` code block (no language) + * 3. Find JSON object by matching braces (starting with requiredKey if specified) + * 4. Find any JSON object by matching braces + * 5. Parse entire response as JSON + * + * @param responseText - The raw response text that may contain JSON + * @param options - Optional extraction options + * @returns Parsed JSON object or null if extraction fails + */ +export function extractJson>( + responseText: string, + options: ExtractJsonOptions = {} +): T | null { + const log = options.logger || logger; + const requiredKey = options.requiredKey; + const requireArray = options.requireArray ?? false; + + /** + * Validate that the result has the required key/structure + */ + const validateResult = (result: unknown): result is T => { + if (!result || typeof result !== 'object') return false; + if (requiredKey) { + const obj = result as Record; + if (!(requiredKey in obj)) return false; + if (requireArray && !Array.isArray(obj[requiredKey])) return false; + } + return true; + }; + + /** + * Find matching closing brace by counting brackets + */ + const findMatchingBrace = (text: string, startIdx: number): number => { + let depth = 0; + for (let i = startIdx; i < text.length; i++) { + if (text[i] === '{') depth++; + if (text[i] === '}') { + depth--; + if (depth === 0) { + return i + 1; + } + } + } + return -1; + }; + + const strategies = [ + // Strategy 1: JSON in ```json code block + () => { + const match = responseText.match(/```json\s*([\s\S]*?)```/); + if (match) { + log.debug('Extracting JSON from ```json code block'); + return JSON.parse(match[1].trim()); + } + return null; + }, + + // Strategy 2: JSON in ``` code block (no language specified) + () => { + const match = responseText.match(/```\s*([\s\S]*?)```/); + if (match) { + const content = match[1].trim(); + // Only try if it looks like JSON (starts with { or [) + if (content.startsWith('{') || content.startsWith('[')) { + log.debug('Extracting JSON from ``` code block'); + return JSON.parse(content); + } + } + return null; + }, + + // Strategy 3: Find JSON object containing the required key (if specified) + () => { + if (!requiredKey) return null; + + const searchPattern = `{"${requiredKey}"`; + const startIdx = responseText.indexOf(searchPattern); + if (startIdx === -1) return null; + + const endIdx = findMatchingBrace(responseText, startIdx); + if (endIdx > startIdx) { + log.debug(`Extracting JSON with required key "${requiredKey}"`); + return JSON.parse(responseText.slice(startIdx, endIdx)); + } + return null; + }, + + // Strategy 4: Find any JSON object by matching braces + () => { + const startIdx = responseText.indexOf('{'); + if (startIdx === -1) return null; + + const endIdx = findMatchingBrace(responseText, startIdx); + if (endIdx > startIdx) { + log.debug('Extracting JSON by brace matching'); + return JSON.parse(responseText.slice(startIdx, endIdx)); + } + return null; + }, + + // Strategy 5: Find JSON using first { to last } (may be less accurate) + () => { + const firstBrace = responseText.indexOf('{'); + const lastBrace = responseText.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + log.debug('Extracting JSON from first { to last }'); + return JSON.parse(responseText.slice(firstBrace, lastBrace + 1)); + } + return null; + }, + + // Strategy 6: Try parsing the entire response as JSON + () => { + const trimmed = responseText.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + log.debug('Parsing entire response as JSON'); + return JSON.parse(trimmed); + } + return null; + }, + ]; + + for (const strategy of strategies) { + try { + const result = strategy(); + if (validateResult(result)) { + log.debug('Successfully extracted JSON'); + return result as T; + } + } catch { + // Strategy failed, try next + } + } + + log.debug('Failed to extract JSON from response'); + return null; +} + +/** + * Extract JSON with a specific required key. + * Convenience wrapper around extractJson. + * + * @param responseText - The raw response text + * @param requiredKey - Key that must be present in the extracted JSON + * @param options - Additional options + * @returns Parsed JSON object or null + */ +export function extractJsonWithKey>( + responseText: string, + requiredKey: string, + options: Omit = {} +): T | null { + return extractJson(responseText, { ...options, requiredKey }); +} + +/** + * Extract JSON that has a required array property. + * Useful for extracting responses like { "suggestions": [...] } + * + * @param responseText - The raw response text + * @param arrayKey - Key that must contain an array + * @param options - Additional options + * @returns Parsed JSON object or null + */ +export function extractJsonWithArray>( + responseText: string, + arrayKey: string, + options: Omit = {} +): T | null { + return extractJson(responseText, { ...options, requiredKey: arrayKey, requireArray: true }); +} diff --git a/jules_branch/apps/server/src/lib/permission-enforcer.ts b/jules_branch/apps/server/src/lib/permission-enforcer.ts new file mode 100644 index 0000000000000000000000000000000000000000..714f7d40a06c4285c1065bea956b7414bef3c3c4 --- /dev/null +++ b/jules_branch/apps/server/src/lib/permission-enforcer.ts @@ -0,0 +1,184 @@ +/** + * Permission enforcement utilities for Cursor provider + */ + +import type { CursorCliConfigFile } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('PermissionEnforcer'); + +export interface PermissionCheckResult { + allowed: boolean; + reason?: string; +} + +/** Minimal shape of a Cursor tool call used for permission checking */ +interface CursorToolCall { + shellToolCall?: { args?: { command: string } }; + readToolCall?: { args?: { path: string } }; + writeToolCall?: { args?: { path: string } }; +} + +/** + * Check if a tool call is allowed based on permissions + */ +export function checkToolCallPermission( + toolCall: CursorToolCall, + permissions: CursorCliConfigFile | null +): PermissionCheckResult { + if (!permissions || !permissions.permissions) { + // If no permissions are configured, allow everything (backward compatibility) + return { allowed: true }; + } + + const { allow = [], deny = [] } = permissions.permissions; + + // Check shell tool calls + if (toolCall.shellToolCall?.args?.command) { + const command = toolCall.shellToolCall.args.command; + const toolName = `Shell(${extractCommandName(command)})`; + + // Check deny list first (deny takes precedence) + for (const denyRule of deny) { + if (matchesRule(toolName, denyRule)) { + return { + allowed: false, + reason: `Operation blocked by permission rule: ${denyRule}`, + }; + } + } + + // Then check allow list + for (const allowRule of allow) { + if (matchesRule(toolName, allowRule)) { + return { allowed: true }; + } + } + + return { + allowed: false, + reason: `Operation not in allow list: ${toolName}`, + }; + } + + // Check read tool calls + if (toolCall.readToolCall?.args?.path) { + const path = toolCall.readToolCall.args.path; + const toolName = `Read(${path})`; + + // Check deny list first + for (const denyRule of deny) { + if (matchesRule(toolName, denyRule)) { + return { + allowed: false, + reason: `Read operation blocked by permission rule: ${denyRule}`, + }; + } + } + + // Then check allow list + for (const allowRule of allow) { + if (matchesRule(toolName, allowRule)) { + return { allowed: true }; + } + } + + return { + allowed: false, + reason: `Read operation not in allow list: ${toolName}`, + }; + } + + // Check write tool calls + if (toolCall.writeToolCall?.args?.path) { + const path = toolCall.writeToolCall.args.path; + const toolName = `Write(${path})`; + + // Check deny list first + for (const denyRule of deny) { + if (matchesRule(toolName, denyRule)) { + return { + allowed: false, + reason: `Write operation blocked by permission rule: ${denyRule}`, + }; + } + } + + // Then check allow list + for (const allowRule of allow) { + if (matchesRule(toolName, allowRule)) { + return { allowed: true }; + } + } + + return { + allowed: false, + reason: `Write operation not in allow list: ${toolName}`, + }; + } + + // For other tool types, allow by default for now + return { allowed: true }; +} + +/** + * Extract the base command name from a shell command + */ +function extractCommandName(command: string): string { + // Remove leading spaces and get the first word + const trimmed = command.trim(); + const firstWord = trimmed.split(/\s+/)[0]; + return firstWord || 'unknown'; +} + +/** + * Check if a tool name matches a permission rule + */ +function matchesRule(toolName: string, rule: string): boolean { + // Exact match + if (toolName === rule) { + return true; + } + + // Wildcard patterns + if (rule.includes('*')) { + const regex = new RegExp(rule.replace(/\*/g, '.*')); + return regex.test(toolName); + } + + // Prefix match for shell commands (e.g., "Shell(git)" matches "Shell(git status)") + if (rule.startsWith('Shell(') && toolName.startsWith('Shell(')) { + const ruleCommand = rule.slice(6, -1); // Remove "Shell(" and ")" + const toolCommand = extractCommandName(toolName.slice(6, -1)); // Remove "Shell(" and ")" + return toolCommand.startsWith(ruleCommand); + } + + return false; +} + +/** + * Log permission violations + */ +export function logPermissionViolation( + toolCall: CursorToolCall, + reason: string, + sessionId?: string +): void { + const sessionIdStr = sessionId ? ` [${sessionId}]` : ''; + + if (toolCall.shellToolCall?.args?.command) { + logger.warn( + `Permission violation${sessionIdStr}: Shell command blocked - ${toolCall.shellToolCall.args.command} (${reason})` + ); + } else if (toolCall.readToolCall?.args?.path) { + logger.warn( + `Permission violation${sessionIdStr}: Read operation blocked - ${toolCall.readToolCall.args.path} (${reason})` + ); + } else if (toolCall.writeToolCall?.args?.path) { + logger.warn( + `Permission violation${sessionIdStr}: Write operation blocked - ${toolCall.writeToolCall.args.path} (${reason})` + ); + } else { + logger.warn(`Permission violation${sessionIdStr}: Tool call blocked (${reason})`, { toolCall }); + } +} diff --git a/jules_branch/apps/server/src/lib/sdk-options.ts b/jules_branch/apps/server/src/lib/sdk-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..7044221e17de2568f7fbe5ef6061fd08130c3b60 --- /dev/null +++ b/jules_branch/apps/server/src/lib/sdk-options.ts @@ -0,0 +1,623 @@ +/** + * SDK Options Factory - Centralized configuration for Claude Agent SDK + * + * Provides presets for common use cases: + * - Spec generation: Long-running analysis with read-only tools + * - Feature generation: Quick JSON generation from specs + * - Feature building: Autonomous feature implementation with full tool access + * - Suggestions: Analysis with read-only tools + * - Chat: Full tool access for interactive coding + * + * Uses model-resolver for consistent model handling across the application. + * + * SECURITY: All factory functions validate the working directory (cwd) against + * ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized + * security check that applies to ALL AI model invocations, regardless of provider. + */ + +import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import path from 'path'; +import { resolveModelString } from '@automaker/model-resolver'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('SdkOptions'); +import { + DEFAULT_MODELS, + CLAUDE_MODEL_MAP, + type McpServerConfig, + type ThinkingLevel, + getThinkingTokenBudget, +} from '@automaker/types'; +import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; + +/** + * Result of sandbox compatibility check + */ +export interface SandboxCompatibilityResult { + /** Whether sandbox mode can be enabled for this path */ + enabled: boolean; + /** Optional message explaining why sandbox is disabled */ + message?: string; +} + +/** + * Check if a working directory is compatible with sandbox mode. + * Some paths (like cloud storage mounts) may not work with sandboxed execution. + * + * @param cwd - The working directory to check + * @param sandboxRequested - Whether sandbox mode was requested by settings + * @returns Object indicating if sandbox can be enabled and why not if disabled + */ +export function checkSandboxCompatibility( + cwd: string, + sandboxRequested: boolean +): SandboxCompatibilityResult { + if (!sandboxRequested) { + return { enabled: false }; + } + + const resolvedCwd = path.resolve(cwd); + + // Check for cloud storage paths that may not be compatible with sandbox + const cloudStoragePatterns = [ + // macOS mounted volumes + /^\/Volumes\/GoogleDrive/i, + /^\/Volumes\/Dropbox/i, + /^\/Volumes\/OneDrive/i, + /^\/Volumes\/iCloud/i, + // macOS home directory + /^\/Users\/[^/]+\/Google Drive/i, + /^\/Users\/[^/]+\/Dropbox/i, + /^\/Users\/[^/]+\/OneDrive/i, + /^\/Users\/[^/]+\/Library\/Mobile Documents/i, // iCloud + // Linux home directory + /^\/home\/[^/]+\/Google Drive/i, + /^\/home\/[^/]+\/Dropbox/i, + /^\/home\/[^/]+\/OneDrive/i, + // Windows + /^C:\\Users\\[^\\]+\\Google Drive/i, + /^C:\\Users\\[^\\]+\\Dropbox/i, + /^C:\\Users\\[^\\]+\\OneDrive/i, + ]; + + for (const pattern of cloudStoragePatterns) { + if (pattern.test(resolvedCwd)) { + return { + enabled: false, + message: `Sandbox disabled: Cloud storage path detected (${resolvedCwd}). Sandbox mode may not work correctly with cloud-synced directories.`, + }; + } + } + + return { enabled: true }; +} + +/** + * Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY. + * This is the centralized security check for ALL AI model invocations. + * + * @param cwd - The working directory to validate + * @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY + * + * This function is called by all create*Options() factory functions to ensure + * that AI models can only operate within allowed directories. This applies to: + * - All current models (Claude, future models) + * - All invocation types (chat, auto-mode, spec generation, etc.) + */ +export function validateWorkingDirectory(cwd: string): void { + const resolvedCwd = path.resolve(cwd); + + if (!isPathAllowed(resolvedCwd)) { + const allowedRoot = getAllowedRootDirectory(); + throw new PathNotAllowedError( + `Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` + + (allowedRoot + ? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` + : 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.') + ); + } +} + +/** + * Tool presets for different use cases + */ +export const TOOL_PRESETS = { + /** Read-only tools for analysis */ + readOnly: ['Read', 'Glob', 'Grep'] as const, + + /** Tools for spec generation that needs to read the codebase */ + specGeneration: ['Read', 'Glob', 'Grep'] as const, + + /** Full tool access for feature implementation */ + fullAccess: [ + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'Glob', + 'Grep', + 'LS', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + 'Task', + 'Skill', + ] as const, + + /** Tools for chat/interactive mode */ + chat: [ + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'Glob', + 'Grep', + 'LS', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + 'Task', + 'Skill', + ] as const, +} as const; + +/** + * Max turns presets for different use cases + */ +export const MAX_TURNS = { + /** Quick operations that shouldn't need many iterations */ + quick: 50, + + /** Standard operations */ + standard: 100, + + /** Long-running operations like full spec generation */ + extended: 250, + + /** Very long operations that may require extensive exploration */ + maximum: 1000, +} as const; + +/** + * Model presets for different use cases + * + * These can be overridden via environment variables: + * - AUTOMAKER_MODEL_SPEC: Model for spec generation + * - AUTOMAKER_MODEL_FEATURES: Model for feature generation + * - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions + * - AUTOMAKER_MODEL_CHAT: Model for chat + * - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations + */ +export function getModelForUseCase( + useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default', + explicitModel?: string +): string { + // Explicit model takes precedence + if (explicitModel) { + return resolveModelString(explicitModel); + } + + // Check environment variable override for this use case + const envVarMap: Record = { + spec: process.env.AUTOMAKER_MODEL_SPEC, + features: process.env.AUTOMAKER_MODEL_FEATURES, + suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS, + chat: process.env.AUTOMAKER_MODEL_CHAT, + auto: process.env.AUTOMAKER_MODEL_AUTO, + default: process.env.AUTOMAKER_MODEL_DEFAULT, + }; + + const envModel = envVarMap[useCase] || envVarMap.default; + if (envModel) { + return resolveModelString(envModel); + } + + const defaultModels: Record = { + spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs + features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs + suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions + chat: CLAUDE_MODEL_MAP['haiku'], // used for chat + auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards + default: CLAUDE_MODEL_MAP['opus'], + }; + + return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude); +} + +/** + * Base options that apply to all SDK calls + * AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation + */ +function getBaseOptions(): Partial { + return { + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + }; +} + +/** + * MCP options result + */ +interface McpOptions { + /** Options to spread for MCP servers */ + mcpServerOptions: Partial; +} + +/** + * Build MCP-related options based on configuration. + * + * @param config - The SDK options config + * @returns Object with MCP server settings to spread into final options + */ +function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions { + return { + // Include MCP servers if configured + mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, + }; +} + +/** + * Build thinking options for SDK configuration. + * Converts ThinkingLevel to maxThinkingTokens for the Claude SDK. + * For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model + * decide its own reasoning depth. + * + * @param thinkingLevel - The thinking level to convert + * @returns Object with maxThinkingTokens if thinking is enabled with a budget + */ +function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial { + if (!thinkingLevel || thinkingLevel === 'none') { + return {}; + } + + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens + // The model will use adaptive thinking by default + if (thinkingLevel === 'adaptive') { + logger.debug( + `buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)` + ); + return {}; + } + + // Manual budget-based thinking for Haiku/Sonnet + const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel); + logger.debug( + `buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}` + ); + return maxThinkingTokens ? { maxThinkingTokens } : {}; +} + +/** + * Build system prompt and settingSources based on two independent settings: + * - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt + * - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files + * + * These combine independently (4 possible states): + * 1. Both ON: preset + settingSources (full Claude Code experience) + * 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading) + * 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources + * 4. Both OFF: plain string only + * + * @param config - The SDK options config + * @returns Object with systemPrompt and settingSources for SDK options + */ +function buildClaudeMdOptions(config: CreateSdkOptionsConfig): { + systemPrompt?: string | SystemPromptConfig; + settingSources?: Array<'user' | 'project' | 'local'>; +} { + const result: { + systemPrompt?: string | SystemPromptConfig; + settingSources?: Array<'user' | 'project' | 'local'>; + } = {}; + + // Determine system prompt format based on useClaudeCodeSystemPrompt + if (config.useClaudeCodeSystemPrompt) { + // Use Claude Code's built-in system prompt as the base + const presetConfig: SystemPromptConfig = { + type: 'preset', + preset: 'claude_code', + }; + // If there's a custom system prompt, append it to the preset + if (config.systemPrompt) { + presetConfig.append = config.systemPrompt; + } + result.systemPrompt = presetConfig; + } else { + // Standard mode - just pass through the system prompt as-is + if (config.systemPrompt) { + result.systemPrompt = config.systemPrompt; + } + } + + // Determine settingSources based on autoLoadClaudeMd + if (config.autoLoadClaudeMd) { + // Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings + result.settingSources = ['user', 'project']; + } + + return result; +} + +/** + * System prompt configuration for SDK options + * The 'claude_code' preset provides the system prompt only — it does NOT auto-load + * CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by + * settingSources (set via autoLoadClaudeMd). These two settings are orthogonal. + */ +export interface SystemPromptConfig { + /** Use preset mode to select the base system prompt */ + type: 'preset'; + /** The preset to use - 'claude_code' uses the Claude Code system prompt */ + preset: 'claude_code'; + /** Optional additional prompt to append to the preset */ + append?: string; +} + +/** + * Options configuration for creating SDK options + */ +export interface CreateSdkOptionsConfig { + /** Working directory for the agent */ + cwd: string; + + /** Optional explicit model override */ + model?: string; + + /** Optional session model (used as fallback if explicit model not provided) */ + sessionModel?: string; + + /** Optional system prompt */ + systemPrompt?: string; + + /** Optional abort controller for cancellation */ + abortController?: AbortController; + + /** Optional output format for structured outputs */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + + /** Enable auto-loading of CLAUDE.md files via SDK's settingSources */ + autoLoadClaudeMd?: boolean; + + /** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */ + useClaudeCodeSystemPrompt?: boolean; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Extended thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; + + /** Optional user-configured max turns override (from settings). + * When provided, overrides the preset MAX_TURNS for the use case. + * Range: 1-2000. */ + maxTurns?: number; +} + +// Re-export MCP types from @automaker/types for convenience +export type { + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; + +/** + * Create SDK options for spec generation + * + * Configuration: + * - Uses read-only tools for codebase analysis + * - Extended turns for thorough exploration + * - Opus model by default (can be overridden) + * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + return { + ...getBaseOptions(), + // Override permissionMode - spec generation only needs read-only tools + // Using "acceptEdits" can cause Claude to write files to unexpected locations + // See: https://github.com/AutoMaker-Org/automaker/issues/149 + permissionMode: 'default', + model: getModelForUseCase('spec', config.model), + maxTurns: config.maxTurns ?? MAX_TURNS.maximum, + cwd: config.cwd, + allowedTools: [...TOOL_PRESETS.specGeneration], + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + ...(config.outputFormat && { outputFormat: config.outputFormat }), + }; +} + +/** + * Create SDK options for feature generation from specs + * + * Configuration: + * - Uses read-only tools (just needs to read the spec) + * - Quick turns since it's mostly JSON generation + * - Sonnet model by default for speed + * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + return { + ...getBaseOptions(), + // Override permissionMode - feature generation only needs read-only tools + permissionMode: 'default', + model: getModelForUseCase('features', config.model), + maxTurns: config.maxTurns ?? MAX_TURNS.quick, + cwd: config.cwd, + allowedTools: [...TOOL_PRESETS.readOnly], + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + }; +} + +/** + * Create SDK options for generating suggestions + * + * Configuration: + * - Uses read-only tools for analysis + * - Standard turns to allow thorough codebase exploration and structured output generation + * - Opus model by default for thorough analysis + * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + return { + ...getBaseOptions(), + model: getModelForUseCase('suggestions', config.model), + maxTurns: config.maxTurns ?? MAX_TURNS.extended, + cwd: config.cwd, + allowedTools: [...TOOL_PRESETS.readOnly], + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + ...(config.outputFormat && { outputFormat: config.outputFormat }), + }; +} + +/** + * Create SDK options for chat/interactive mode + * + * Configuration: + * - Full tool access for code modification + * - Standard turns for interactive sessions + * - Model priority: explicit model > session model > chat default + * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createChatOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Model priority: explicit model > session model > chat default + const effectiveModel = config.model || config.sessionModel; + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + return { + ...getBaseOptions(), + model: getModelForUseCase('chat', effectiveModel), + maxTurns: config.maxTurns ?? MAX_TURNS.standard, + cwd: config.cwd, + allowedTools: [...TOOL_PRESETS.chat], + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, + }; +} + +/** + * Create SDK options for autonomous feature building/implementation + * + * Configuration: + * - Full tool access for code modification and implementation + * - Extended turns for thorough feature implementation + * - Uses default model (can be overridden) + * - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + return { + ...getBaseOptions(), + model: getModelForUseCase('auto', config.model), + maxTurns: config.maxTurns ?? MAX_TURNS.maximum, + cwd: config.cwd, + allowedTools: [...TOOL_PRESETS.fullAccess], + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, + }; +} + +/** + * Create custom SDK options with explicit configuration + * + * Use this when the preset options don't fit your use case. + * When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading + */ +export function createCustomOptions( + config: CreateSdkOptionsConfig & { + maxTurns?: number; + allowedTools?: readonly string[]; + } +): Options { + // Validate working directory before creating options + validateWorkingDirectory(config.cwd); + + // Build CLAUDE.md auto-loading options if enabled + const claudeMdOptions = buildClaudeMdOptions(config); + + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + + // Build thinking options + const thinkingOptions = buildThinkingOptions(config.thinkingLevel); + + // For custom options: use explicit allowedTools if provided, otherwise default to readOnly + const effectiveAllowedTools = config.allowedTools + ? [...config.allowedTools] + : [...TOOL_PRESETS.readOnly]; + + return { + ...getBaseOptions(), + model: getModelForUseCase('default', config.model), + maxTurns: config.maxTurns ?? MAX_TURNS.maximum, + cwd: config.cwd, + allowedTools: effectiveAllowedTools, + ...claudeMdOptions, + ...thinkingOptions, + ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, + }; +} diff --git a/jules_branch/apps/server/src/lib/secure-fs.ts b/jules_branch/apps/server/src/lib/secure-fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..de8dba26de401b0932f1dd6b7e306542fc00e05a --- /dev/null +++ b/jules_branch/apps/server/src/lib/secure-fs.ts @@ -0,0 +1,39 @@ +/** + * Re-export secure file system utilities from @automaker/platform + * This file exists for backward compatibility with existing imports + */ + +import { secureFs } from '@automaker/platform'; + +export const { + // Async methods + access, + readFile, + writeFile, + mkdir, + readdir, + stat, + rm, + unlink, + copyFile, + appendFile, + rename, + lstat, + joinPath, + resolvePath, + // Sync methods + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, + accessSync, + unlinkSync, + rmSync, + // Throttling configuration and monitoring + configureThrottling, + getThrottlingConfig, + getPendingOperations, + getActiveOperations, +} = secureFs; diff --git a/jules_branch/apps/server/src/lib/settings-helpers.ts b/jules_branch/apps/server/src/lib/settings-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..66db5b1ac232a207576166fa9a38c7cc4eaee780 --- /dev/null +++ b/jules_branch/apps/server/src/lib/settings-helpers.ts @@ -0,0 +1,954 @@ +/** + * Helper utilities for loading settings and context file handling across different parts of the server + */ + +import type { SettingsService } from '../services/settings-service.js'; +import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; +import { createLogger } from '@automaker/utils'; +import type { + MCPServerConfig, + McpServerConfig, + PromptCustomization, + ClaudeApiProfile, + ClaudeCompatibleProvider, + PhaseModelKey, + PhaseModelEntry, + Credentials, +} from '@automaker/types'; +import { DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { + mergeAutoModePrompts, + mergeAgentPrompts, + mergeBacklogPlanPrompts, + mergeEnhancementPrompts, + mergeCommitMessagePrompts, + mergeTitleGenerationPrompts, + mergeIssueValidationPrompts, + mergeIdeationPrompts, + mergeAppSpecPrompts, + mergeContextDescriptionPrompts, + mergeSuggestionsPrompts, + mergeTaskExecutionPrompts, +} from '@automaker/prompts'; + +const logger = createLogger('SettingsHelper'); + +/** Default number of agent turns used when no value is configured. */ +export const DEFAULT_MAX_TURNS = 10000; + +/** Upper bound for the max-turns clamp; values above this are capped here. */ +export const MAX_ALLOWED_TURNS = 10000; + +/** + * Get the autoLoadClaudeMd setting, with project settings taking precedence over global. + * Falls back to global settings and defaults to true when unset. + * Returns true if settings service is not available. + * + * @param projectPath - Path to the project + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[DescribeImage]') + * @returns Promise resolving to the autoLoadClaudeMd setting value + */ +export async function getAutoLoadClaudeMdSetting( + projectPath: string, + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + if (!settingsService) { + logger.info(`${logPrefix} SettingsService not available, autoLoadClaudeMd defaulting to true`); + return true; + } + + try { + // Check project settings first (takes precedence) + const projectSettings = await settingsService.getProjectSettings(projectPath); + if (projectSettings.autoLoadClaudeMd !== undefined) { + logger.info( + `${logPrefix} autoLoadClaudeMd from project settings: ${projectSettings.autoLoadClaudeMd}` + ); + return projectSettings.autoLoadClaudeMd; + } + + // Fall back to global settings + const globalSettings = await settingsService.getGlobalSettings(); + const result = globalSettings.autoLoadClaudeMd ?? true; + logger.info(`${logPrefix} autoLoadClaudeMd from global settings: ${result}`); + return result; + } catch (error) { + logger.error(`${logPrefix} Failed to load autoLoadClaudeMd setting:`, error); + throw error; + } +} + +/** + * Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global. + * Falls back to global settings and defaults to true when unset. + * Returns true if settings service is not available. + * + * @param projectPath - Path to the project + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to the useClaudeCodeSystemPrompt setting value + */ +export async function getUseClaudeCodeSystemPromptSetting( + projectPath: string, + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + if (!settingsService) { + logger.info( + `${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true` + ); + return true; + } + + try { + // Check project settings first (takes precedence) + const projectSettings = await settingsService.getProjectSettings(projectPath); + if (projectSettings.useClaudeCodeSystemPrompt !== undefined) { + logger.info( + `${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}` + ); + return projectSettings.useClaudeCodeSystemPrompt; + } + + // Fall back to global settings + const globalSettings = await settingsService.getGlobalSettings(); + const result = globalSettings.useClaudeCodeSystemPrompt ?? true; + logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`); + return result; + } catch (error) { + logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error); + throw error; + } +} + +/** + * Get the default max turns setting from global settings. + * + * Reads the user's configured `defaultMaxTurns` setting, which controls the maximum + * number of agent turns (tool-call round-trips) for feature execution. + * + * @param settingsService - Settings service instance (may be null) + * @param logPrefix - Logging prefix for debugging + * @returns The user's configured max turns, or {@link DEFAULT_MAX_TURNS} as default + */ +export async function getDefaultMaxTurnsSetting( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise { + if (!settingsService) { + logger.info( + `${logPrefix} SettingsService not available, using default maxTurns=${DEFAULT_MAX_TURNS}` + ); + return DEFAULT_MAX_TURNS; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const raw = globalSettings.defaultMaxTurns; + const result = Number.isFinite(raw) ? (raw as number) : DEFAULT_MAX_TURNS; + // Clamp to valid range + const clamped = Math.max(1, Math.min(MAX_ALLOWED_TURNS, Math.floor(result))); + logger.debug(`${logPrefix} defaultMaxTurns from global settings: ${clamped}`); + return clamped; + } catch (error) { + logger.error(`${logPrefix} Failed to load defaultMaxTurns setting:`, error); + return DEFAULT_MAX_TURNS; + } +} + +/** + * Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled + * and rebuilds the formatted prompt without it. + * + * When autoLoadClaudeMd is true, the SDK handles CLAUDE.md loading via settingSources, + * so we need to exclude it from the manual context loading to avoid duplication. + * Other context files (CODE_QUALITY.md, CONVENTIONS.md, etc.) are preserved. + * + * @param contextResult - Result from loadContextFiles + * @param autoLoadClaudeMd - Whether SDK auto-loading is enabled + * @returns Filtered context prompt (empty string if no non-CLAUDE.md files) + */ +export function filterClaudeMdFromContext( + contextResult: ContextFilesResult, + autoLoadClaudeMd: boolean +): string { + // If autoLoadClaudeMd is disabled, return the original prompt unchanged + if (!autoLoadClaudeMd || contextResult.files.length === 0) { + return contextResult.formattedPrompt; + } + + // Filter out CLAUDE.md (case-insensitive) + const nonClaudeFiles = contextResult.files.filter((f) => f.name.toLowerCase() !== 'claude.md'); + + // If all files were CLAUDE.md, return empty string + if (nonClaudeFiles.length === 0) { + return ''; + } + + // Rebuild prompt without CLAUDE.md using the same format as loadContextFiles + const formattedFiles = nonClaudeFiles.map((file) => formatContextFileEntry(file)); + + return `# Project Context Files + +The following context files provide project-specific rules, conventions, and guidelines. +Each file serves a specific purpose - use the description to understand when to reference it. +If you need more details about a context file, you can read the full file at the path provided. + +**IMPORTANT**: You MUST follow the rules and conventions specified in these files. +- Follow ALL commands exactly as shown (e.g., if the project uses \`pnpm\`, NEVER use \`npm\` or \`npx\`) +- Follow ALL coding conventions, commit message formats, and architectural patterns specified +- Reference these rules before running ANY shell commands or making commits + +--- + +${formattedFiles.join('\n\n---\n\n')} + +--- + +**REMINDER**: Before taking any action, verify you are following the conventions specified above. +`; +} + +/** + * Format a single context file entry for the prompt + * (Matches the format used in @automaker/utils/context-loader.ts) + */ +function formatContextFileEntry(file: ContextFileInfo): string { + const header = `## ${file.name}`; + const pathInfo = `**Path:** \`${file.path}\``; + const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : ''; + return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`; +} + +/** + * Get enabled MCP servers from global settings, converted to SDK format. + * Returns an empty object if settings service is not available or no servers are configured. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to MCP servers in SDK format (keyed by name) + */ +export async function getMCPServersFromSettings( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise> { + if (!settingsService) { + return {}; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const mcpServers = globalSettings.mcpServers || []; + + // Filter to only enabled servers and convert to SDK format + const enabledServers = mcpServers.filter((s) => s.enabled !== false); + + if (enabledServers.length === 0) { + return {}; + } + + // Convert settings format to SDK format (keyed by name) + const sdkServers: Record = {}; + for (const server of enabledServers) { + sdkServers[server.name] = convertToSdkFormat(server); + } + + logger.info( + `${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}` + ); + + return sdkServers; + } catch (error) { + logger.error(`${logPrefix} Failed to load MCP servers setting:`, error); + return {}; + } +} + +/** + * Convert a settings MCPServerConfig to SDK McpServerConfig format. + * Validates required fields and throws informative errors if missing. + */ +function convertToSdkFormat(server: MCPServerConfig): McpServerConfig { + if (server.type === 'sse') { + if (!server.url) { + throw new Error(`SSE MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'sse', + url: server.url, + headers: server.headers, + }; + } + + if (server.type === 'http') { + if (!server.url) { + throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'http', + url: server.url, + headers: server.headers, + }; + } + + // Default to stdio + if (!server.command) { + throw new Error(`Stdio MCP server "${server.name}" is missing a command.`); + } + return { + type: 'stdio', + command: server.command, + args: server.args, + env: server.env, + }; +} + +/** + * Get prompt customization from global settings and merge with defaults. + * Returns prompts merged with built-in defaults - custom prompts override defaults. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to merged prompts for all categories + */ +export async function getPromptCustomization( + settingsService?: SettingsService | null, + logPrefix = '[PromptHelper]' +): Promise<{ + autoMode: ReturnType; + agent: ReturnType; + backlogPlan: ReturnType; + enhancement: ReturnType; + commitMessage: ReturnType; + titleGeneration: ReturnType; + issueValidation: ReturnType; + ideation: ReturnType; + appSpec: ReturnType; + contextDescription: ReturnType; + suggestions: ReturnType; + taskExecution: ReturnType; +}> { + let customization: PromptCustomization = {}; + + if (settingsService) { + try { + const globalSettings = await settingsService.getGlobalSettings(); + customization = globalSettings.promptCustomization || {}; + logger.info(`${logPrefix} Loaded prompt customization from settings`); + } catch (error) { + logger.error(`${logPrefix} Failed to load prompt customization:`, error); + // Fall through to use empty customization (all defaults) + } + } else { + logger.info(`${logPrefix} SettingsService not available, using default prompts`); + } + + return { + autoMode: mergeAutoModePrompts(customization.autoMode), + agent: mergeAgentPrompts(customization.agent), + backlogPlan: mergeBacklogPlanPrompts(customization.backlogPlan), + enhancement: mergeEnhancementPrompts(customization.enhancement), + commitMessage: mergeCommitMessagePrompts(customization.commitMessage), + titleGeneration: mergeTitleGenerationPrompts(customization.titleGeneration), + issueValidation: mergeIssueValidationPrompts(customization.issueValidation), + ideation: mergeIdeationPrompts(customization.ideation), + appSpec: mergeAppSpecPrompts(customization.appSpec), + contextDescription: mergeContextDescriptionPrompts(customization.contextDescription), + suggestions: mergeSuggestionsPrompts(customization.suggestions), + taskExecution: mergeTaskExecutionPrompts(customization.taskExecution), + }; +} + +/** + * Get Skills configuration from settings. + * Returns configuration for enabling skills and which sources to load from. + * + * @param settingsService - Settings service instance + * @returns Skills configuration with enabled state, sources, and tool inclusion flag + */ +export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{ + enabled: boolean; + sources: Array<'user' | 'project'>; + shouldIncludeInTools: boolean; +}> { + const settings = await settingsService.getGlobalSettings(); + const enabled = settings.enableSkills ?? true; // Default enabled + const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources + + return { + enabled, + sources, + shouldIncludeInTools: enabled && sources.length > 0, + }; +} + +/** + * Get Subagents configuration from settings. + * Returns configuration for enabling subagents and which sources to load from. + * + * @param settingsService - Settings service instance + * @returns Subagents configuration with enabled state, sources, and tool inclusion flag + */ +export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{ + enabled: boolean; + sources: Array<'user' | 'project'>; + shouldIncludeInTools: boolean; +}> { + const settings = await settingsService.getGlobalSettings(); + const enabled = settings.enableSubagents ?? true; // Default enabled + const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources + + return { + enabled, + sources, + shouldIncludeInTools: enabled && sources.length > 0, + }; +} + +/** + * Get custom subagents from settings, merging global and project-level definitions. + * Project-level subagents take precedence over global ones with the same name. + * + * @param settingsService - Settings service instance + * @param projectPath - Path to the project for loading project-specific subagents + * @returns Record of agent names to definitions, or undefined if none configured + */ +export async function getCustomSubagents( + settingsService: SettingsService, + projectPath?: string +): Promise | undefined> { + // Get global subagents + const globalSettings = await settingsService.getGlobalSettings(); + const globalSubagents = globalSettings.customSubagents || {}; + + // If no project path, return only global subagents + if (!projectPath) { + return Object.keys(globalSubagents).length > 0 ? globalSubagents : undefined; + } + + // Get project-specific subagents + const projectSettings = await settingsService.getProjectSettings(projectPath); + const projectSubagents = projectSettings.customSubagents || {}; + + // Merge: project-level takes precedence + const merged = { + ...globalSubagents, + ...projectSubagents, + }; + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +/** Result from getActiveClaudeApiProfile */ +export interface ActiveClaudeApiProfileResult { + /** The active profile, or undefined if using direct Anthropic API */ + profile: ClaudeApiProfile | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: import('@automaker/types').Credentials | undefined; +} + +/** + * Get the active Claude API profile and credentials from settings. + * Checks project settings first for per-project overrides, then falls back to global settings. + * Returns both the profile and credentials for resolving 'credentials' apiKeySource. + * + * @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system. + * This function is kept for backward compatibility during migration. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @param projectPath - Optional project path for per-project override + * @returns Promise resolving to object with profile and credentials + */ +export async function getActiveClaudeApiProfile( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]', + projectPath?: string +): Promise { + if (!settingsService) { + return { profile: undefined, credentials: undefined }; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const profiles = globalSettings.claudeApiProfiles || []; + + // Check for project-level override first + let activeProfileId: string | null | undefined; + let isProjectOverride = false; + + if (projectPath) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + // undefined = use global, null = explicit no profile, string = specific profile + if (projectSettings.activeClaudeApiProfileId !== undefined) { + activeProfileId = projectSettings.activeClaudeApiProfileId; + isProjectOverride = true; + } + } + + // Fall back to global if project doesn't specify + if (activeProfileId === undefined && !isProjectOverride) { + activeProfileId = globalSettings.activeClaudeApiProfileId; + } + + // No active profile selected - use direct Anthropic API + if (!activeProfileId) { + if (isProjectOverride && activeProfileId === null) { + logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`); + } + return { profile: undefined, credentials }; + } + + // Find the active profile by ID + const activeProfile = profiles.find((p) => p.id === activeProfileId); + + if (activeProfile) { + const overrideSuffix = isProjectOverride ? ' (project override)' : ''; + logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`); + return { profile: activeProfile, credentials }; + } else { + logger.warn( + `${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API` + ); + return { profile: undefined, credentials }; + } + } catch (error) { + logger.error(`${logPrefix} Failed to load Claude API profile:`, error); + return { profile: undefined, credentials: undefined }; + } +} + +// ============================================================================ +// New Provider System Helpers +// ============================================================================ + +/** Result from getProviderById */ +export interface ProviderByIdResult { + /** The provider, or undefined if not found */ + provider: ClaudeCompatibleProvider | undefined; + /** Credentials for resolving 'credentials' apiKeySource */ + credentials: Credentials | undefined; +} + +/** + * Get a ClaudeCompatibleProvider by its ID. + * Returns the provider configuration and credentials for API key resolution. + * + * @param providerId - The provider ID to look up + * @param settingsService - Settings service instance + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to object with provider and credentials + */ +export async function getProviderById( + providerId: string, + settingsService: SettingsService, + logPrefix = '[SettingsHelper]' +): Promise { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const providers = globalSettings.claudeCompatibleProviders || []; + + const provider = providers.find((p) => p.id === providerId); + + if (provider) { + if (provider.enabled === false) { + logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`); + } else { + logger.debug(`${logPrefix} Found provider: ${provider.name}`); + } + return { provider, credentials }; + } else { + logger.warn(`${logPrefix} Provider not found: ${providerId}`); + return { provider: undefined, credentials }; + } + } catch (error) { + logger.error(`${logPrefix} Failed to load provider by ID:`, error); + return { provider: undefined, credentials: undefined }; + } +} + +/** Result from getPhaseModelWithOverrides */ +export interface PhaseModelWithOverridesResult { + /** The resolved phase model entry */ + phaseModel: PhaseModelEntry; + /** Whether a project override was applied */ + isProjectOverride: boolean; + /** The provider if providerId is set and found */ + provider: ClaudeCompatibleProvider | undefined; + /** Credentials for API key resolution */ + credentials: Credentials | undefined; +} + +/** + * Get the phase model configuration for a specific phase, applying project overrides if available. + * Also resolves the provider if the phase model has a providerId. + * + * @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel') + * @param settingsService - Optional settings service instance (returns defaults if undefined) + * @param projectPath - Optional project path for checking overrides + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to phase model with provider info + */ +export async function getPhaseModelWithOverrides( + phase: PhaseModelKey, + settingsService?: SettingsService | null, + projectPath?: string, + logPrefix = '[SettingsHelper]' +): Promise { + // Handle undefined settingsService gracefully + if (!settingsService) { + logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`); + return { + phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' }, + isProjectOverride: false, + provider: undefined, + credentials: undefined, + }; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const globalPhaseModels = globalSettings.phaseModels || {}; + + // Start with global phase model + let phaseModel = globalPhaseModels[phase]; + let isProjectOverride = false; + + // Check for project override + if (projectPath) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + const projectOverrides = projectSettings.phaseModelOverrides || {}; + + if (projectOverrides[phase]) { + phaseModel = projectOverrides[phase]; + isProjectOverride = true; + logger.debug(`${logPrefix} Using project override for ${phase}`); + } + } + + // If no phase model found, use per-phase default + if (!phaseModel) { + phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' }; + logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`); + } + + // Resolve provider if providerId is set + let provider: ClaudeCompatibleProvider | undefined; + if (phaseModel.providerId) { + const providers = globalSettings.claudeCompatibleProviders || []; + provider = providers.find((p) => p.id === phaseModel.providerId); + + if (provider) { + if (provider.enabled === false) { + logger.warn( + `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API` + ); + provider = undefined; + } else { + logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`); + } + } else { + logger.warn( + `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API` + ); + } + } + + return { + phaseModel, + isProjectOverride, + provider, + credentials, + }; + } catch (error) { + logger.error(`${logPrefix} Failed to get phase model with overrides:`, error); + // Return a safe default + return { + phaseModel: { model: 'sonnet' }, + isProjectOverride: false, + provider: undefined, + credentials: undefined, + }; + } +} + +/** Result from getProviderByModelId */ +export interface ProviderByModelIdResult { + /** The provider that contains this model, or undefined if not found */ + provider: ClaudeCompatibleProvider | undefined; + /** The model configuration if found */ + modelConfig: import('@automaker/types').ProviderModel | undefined; + /** Credentials for API key resolution */ + credentials: Credentials | undefined; + /** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */ + resolvedModel: string | undefined; +} + +/** Result from resolveProviderContext */ +export interface ProviderContextResult { + /** The provider configuration */ + provider: ClaudeCompatibleProvider | undefined; + /** Credentials for API key resolution */ + credentials: Credentials | undefined; + /** The resolved Claude model ID for SDK configuration */ + resolvedModel: string | undefined; + /** The original model config from the provider if found */ + modelConfig: import('@automaker/types').ProviderModel | undefined; +} + +/** + * Checks if a provider is enabled. + * Providers with enabled: undefined are treated as enabled (default state). + * Only explicitly set enabled: false means the provider is disabled. + */ +function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean { + return provider.enabled !== false; +} + +/** + * Finds a model config in a provider's models array by ID (case-insensitive). + */ +function findModelInProvider( + provider: ClaudeCompatibleProvider, + modelId: string +): import('@automaker/types').ProviderModel | undefined { + return provider.models?.find( + (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase() + ); +} + +/** + * Resolves the provider and Claude-compatible model configuration. + * + * This is the central logic for resolving provider context, supporting: + * 1. Explicit lookup by providerId (most reliable for persistence) + * 2. Fallback lookup by modelId across all enabled providers + * 3. Resolution of mapsToClaudeModel for SDK configuration + * + * @param settingsService - Settings service instance + * @param modelId - The model ID to resolve + * @param providerId - Optional explicit provider ID + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to the provider context + */ +export async function resolveProviderContext( + settingsService: SettingsService, + modelId: string, + providerId?: string, + logPrefix = '[SettingsHelper]' +): Promise { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const providers = globalSettings.claudeCompatibleProviders || []; + + logger.debug( + `${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}` + ); + + let provider: ClaudeCompatibleProvider | undefined; + let modelConfig: import('@automaker/types').ProviderModel | undefined; + + // 1. Try resolving by explicit providerId first (most reliable) + if (providerId) { + provider = providers.find((p) => p.id === providerId); + if (provider) { + if (!isProviderEnabled(provider)) { + logger.warn( + `${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})` + ); + } else { + logger.debug( + `${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}` + ); + // Find the model config within this provider to check for mappings + modelConfig = findModelInProvider(provider, modelId); + if (!modelConfig && provider.models && provider.models.length > 0) { + logger.debug( + `${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}` + ); + } + } + } else { + logger.warn( + `${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}` + ); + } + } + + // 2. Fallback to model-based lookup across all providers if modelConfig not found + // Note: We still search even if provider was found, to get the modelConfig for mapping + if (!modelConfig) { + for (const p of providers) { + if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked + + const config = findModelInProvider(p, modelId); + + if (config) { + // Only override provider if we didn't find one by explicit ID + if (!provider) { + provider = p; + } + modelConfig = config; + logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`); + break; + } + } + } + + // 3. Resolve the mapped Claude model if specified + let resolvedModel: string | undefined; + if (modelConfig?.mapsToClaudeModel) { + const { resolveModelString } = await import('@automaker/model-resolver'); + resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel); + logger.debug( + `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"` + ); + } + + // Log final result for debugging + logger.debug( + `${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}` + ); + + return { provider, credentials, resolvedModel, modelConfig }; + } catch (error) { + logger.error(`${logPrefix} Failed to resolve provider context:`, error); + return { + provider: undefined, + credentials: undefined, + resolvedModel: undefined, + modelConfig: undefined, + }; + } +} + +/** + * Find a ClaudeCompatibleProvider by one of its model IDs. + * Searches through all enabled providers to find one that contains the specified model. + * This is useful when you have a model string from the UI but need the provider config. + * + * Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use + * when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"). + * + * @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1") + * @param settingsService - Settings service instance + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to object with provider, model config, credentials, and resolved model + */ +export async function getProviderByModelId( + modelId: string, + settingsService: SettingsService, + logPrefix = '[SettingsHelper]' +): Promise { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const providers = globalSettings.claudeCompatibleProviders || []; + + // Search through all enabled providers for this model + for (const provider of providers) { + // Skip disabled providers + if (provider.enabled === false) { + continue; + } + + // Check if this provider has the model + const modelConfig = provider.models?.find( + (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase() + ); + + if (modelConfig) { + logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`); + + // Resolve the mapped Claude model if specified + let resolvedModel: string | undefined; + if (modelConfig.mapsToClaudeModel) { + // Import resolveModelString to convert alias to full model ID + const { resolveModelString } = await import('@automaker/model-resolver'); + resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel); + logger.info( + `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"` + ); + } + + return { provider, modelConfig, credentials, resolvedModel }; + } + } + + // Model not found in any provider + logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`); + return { + provider: undefined, + modelConfig: undefined, + credentials: undefined, + resolvedModel: undefined, + }; + } catch (error) { + logger.error(`${logPrefix} Failed to find provider by model ID:`, error); + return { + provider: undefined, + modelConfig: undefined, + credentials: undefined, + resolvedModel: undefined, + }; + } +} + +/** + * Get all enabled provider models for use in model dropdowns. + * Returns models from all enabled ClaudeCompatibleProviders. + * + * @param settingsService - Settings service instance + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to array of provider models with their provider info + */ +export async function getAllProviderModels( + settingsService: SettingsService, + logPrefix = '[SettingsHelper]' +): Promise< + Array<{ + providerId: string; + providerName: string; + model: import('@automaker/types').ProviderModel; + }> +> { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const providers = globalSettings.claudeCompatibleProviders || []; + + const allModels: Array<{ + providerId: string; + providerName: string; + model: import('@automaker/types').ProviderModel; + }> = []; + + for (const provider of providers) { + // Skip disabled providers + if (provider.enabled === false) { + continue; + } + + for (const model of provider.models || []) { + allModels.push({ + providerId: provider.id, + providerName: provider.name, + model, + }); + } + } + + logger.debug( + `${logPrefix} Found ${allModels.length} models from ${providers.length} providers` + ); + return allModels; + } catch (error) { + logger.error(`${logPrefix} Failed to get all provider models:`, error); + return []; + } +} diff --git a/jules_branch/apps/server/src/lib/terminal-themes-data.ts b/jules_branch/apps/server/src/lib/terminal-themes-data.ts new file mode 100644 index 0000000000000000000000000000000000000000..854bf1a80e91b730ab90c13866dddf486f656a96 --- /dev/null +++ b/jules_branch/apps/server/src/lib/terminal-themes-data.ts @@ -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 { + return terminalThemeColors; +} + +export default terminalThemeColors; diff --git a/jules_branch/apps/server/src/lib/validation-storage.ts b/jules_branch/apps/server/src/lib/validation-storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ca66653402a323e45834a8c35d95c3dc1aa008c --- /dev/null +++ b/jules_branch/apps/server/src/lib/validation-storage.ts @@ -0,0 +1,181 @@ +/** + * Validation Storage - CRUD operations for GitHub issue validation results + * + * Stores validation results in .automaker/validations/{issueNumber}/validation.json + * Results include the validation verdict, metadata, and timestamp for cache invalidation. + */ + +import * as secureFs from './secure-fs.js'; +import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform'; +import type { StoredValidation } from '@automaker/types'; + +// Re-export StoredValidation for convenience +export type { StoredValidation }; + +/** Number of hours before a validation is considered stale */ +const VALIDATION_CACHE_TTL_HOURS = 24; + +/** + * Write validation result to storage + * + * Creates the validation directory if needed and stores the result as JSON. + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @param data - Validation data to store + */ +export async function writeValidation( + projectPath: string, + issueNumber: number, + data: StoredValidation +): Promise { + const validationDir = getValidationDir(projectPath, issueNumber); + const validationPath = getValidationPath(projectPath, issueNumber); + + // Ensure directory exists + await secureFs.mkdir(validationDir, { recursive: true }); + + // Write validation result + await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Read validation result from storage + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Stored validation or null if not found + */ +export async function readValidation( + projectPath: string, + issueNumber: number +): Promise { + try { + const validationPath = getValidationPath(projectPath, issueNumber); + const content = (await secureFs.readFile(validationPath, 'utf-8')) as string; + return JSON.parse(content) as StoredValidation; + } catch { + // File doesn't exist or can't be read + return null; + } +} + +/** + * Get all stored validations for a project + * + * @param projectPath - Absolute path to project directory + * @returns Array of stored validations + */ +export async function getAllValidations(projectPath: string): Promise { + const validationsDir = getValidationsDir(projectPath); + + try { + const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true }); + + // Read all validation files in parallel for better performance + const promises = dirs + .filter((dir) => dir.isDirectory()) + .map((dir) => { + const issueNumber = parseInt(dir.name, 10); + if (!isNaN(issueNumber)) { + return readValidation(projectPath, issueNumber); + } + return Promise.resolve(null); + }); + + const results = await Promise.all(promises); + const validations = results.filter((v): v is StoredValidation => v !== null); + + // Sort by issue number + validations.sort((a, b) => a.issueNumber - b.issueNumber); + + return validations; + } catch { + // Directory doesn't exist + return []; + } +} + +/** + * Delete a validation from storage + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns true if validation was deleted, false if not found + */ +export async function deleteValidation(projectPath: string, issueNumber: number): Promise { + try { + const validationDir = getValidationDir(projectPath, issueNumber); + await secureFs.rm(validationDir, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +/** + * Check if a validation is stale (older than TTL) + * + * @param validation - Stored validation to check + * @returns true if validation is older than 24 hours + */ +export function isValidationStale(validation: StoredValidation): boolean { + const validatedAt = new Date(validation.validatedAt); + const now = new Date(); + const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60); + return hoursDiff > VALIDATION_CACHE_TTL_HOURS; +} + +/** + * Get validation with freshness info + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns Object with validation and isStale flag, or null if not found + */ +export async function getValidationWithFreshness( + projectPath: string, + issueNumber: number +): Promise<{ validation: StoredValidation; isStale: boolean } | null> { + const validation = await readValidation(projectPath, issueNumber); + if (!validation) { + return null; + } + + return { + validation, + isStale: isValidationStale(validation), + }; +} + +/** + * Mark a validation as viewed by the user + * + * @param projectPath - Absolute path to project directory + * @param issueNumber - GitHub issue number + * @returns true if validation was marked as viewed, false if not found + */ +export async function markValidationViewed( + projectPath: string, + issueNumber: number +): Promise { + const validation = await readValidation(projectPath, issueNumber); + if (!validation) { + return false; + } + + validation.viewedAt = new Date().toISOString(); + await writeValidation(projectPath, issueNumber, validation); + return true; +} + +/** + * Get count of unviewed, non-stale validations for a project + * + * @param projectPath - Absolute path to project directory + * @returns Number of unviewed validations + */ +export async function getUnviewedValidationsCount(projectPath: string): Promise { + const validations = await getAllValidations(projectPath); + return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length; +} diff --git a/jules_branch/apps/server/src/lib/version.ts b/jules_branch/apps/server/src/lib/version.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fd8eeec75f280f01afcd651b8d62597ee1c7464 --- /dev/null +++ b/jules_branch/apps/server/src/lib/version.ts @@ -0,0 +1,49 @@ +/** + * Version utility - Reads version from package.json + */ + +import { readFileSync, existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Version'); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let cachedVersion: string | null = null; + +/** + * Get the version from package.json + * Caches the result for performance + */ +export function getVersion(): string { + if (cachedVersion) { + return cachedVersion; + } + + try { + const candidatePaths = [ + // Development via tsx: src/lib -> project root + join(__dirname, '..', '..', 'package.json'), + // Packaged/build output: lib -> server bundle root + join(__dirname, '..', 'package.json'), + ]; + + const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate)); + if (!packageJsonPath) { + throw new Error( + `package.json not found in any expected location: ${candidatePaths.join(', ')}` + ); + } + + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + const version = packageJson.version || '0.0.0'; + cachedVersion = version; + return version; + } catch (error) { + logger.warn('Failed to read version from package.json:', error); + return '0.0.0'; + } +} diff --git a/jules_branch/apps/server/src/lib/worktree-metadata.ts b/jules_branch/apps/server/src/lib/worktree-metadata.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa6e24870777d6f2f6c3446bd2a36c46d5ee9ad0 --- /dev/null +++ b/jules_branch/apps/server/src/lib/worktree-metadata.ts @@ -0,0 +1,182 @@ +/** + * Worktree metadata storage utilities + * Stores worktree-specific data in .automaker/worktrees/:branch/worktree.json + */ + +import * as secureFs from './secure-fs.js'; +import * as path from 'path'; +import type { PRState, WorktreePRInfo } from '@automaker/types'; + +// Re-export types for backwards compatibility +export type { PRState, WorktreePRInfo }; + +/** Maximum length for sanitized branch names in filesystem paths */ +const MAX_SANITIZED_BRANCH_PATH_LENGTH = 200; + +export interface WorktreeMetadata { + branch: string; + createdAt: string; + pr?: WorktreePRInfo; + /** Whether the init script has been executed for this worktree */ + initScriptRan?: boolean; + /** Status of the init script execution */ + initScriptStatus?: 'running' | 'success' | 'failed'; + /** Error message if init script failed */ + initScriptError?: string; +} + +/** + * Sanitize branch name for cross-platform filesystem safety + */ +function sanitizeBranchName(branch: string): string { + // Replace characters that are invalid or problematic on various filesystems: + // - Forward and backslashes (path separators) + // - Windows invalid chars: : * ? " < > | + // - Other potentially problematic chars + let safeBranch = branch + .replace(/[/\\:*?"<>|]/g, '-') // Replace invalid chars with dash + .replace(/\s+/g, '_') // Replace spaces with underscores + .replace(/\.+$/g, '') // Remove trailing dots (Windows issue) + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-|-$/g, ''); // Remove leading/trailing dashes + + // Truncate to safe length (leave room for path components) + safeBranch = safeBranch.substring(0, MAX_SANITIZED_BRANCH_PATH_LENGTH); + + // Handle Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) + const windowsReserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i; + if (windowsReserved.test(safeBranch) || safeBranch.length === 0) { + safeBranch = `_${safeBranch || 'branch'}`; + } + + return safeBranch; +} + +/** + * Get the path to the worktree metadata directory + */ +function getWorktreeMetadataDir(projectPath: string, branch: string): string { + const safeBranch = sanitizeBranchName(branch); + return path.join(projectPath, '.automaker', 'worktrees', safeBranch); +} + +/** + * Get the path to the worktree metadata file + */ +function getWorktreeMetadataPath(projectPath: string, branch: string): string { + return path.join(getWorktreeMetadataDir(projectPath, branch), 'worktree.json'); +} + +/** + * Read worktree metadata for a branch + */ +export async function readWorktreeMetadata( + projectPath: string, + branch: string +): Promise { + try { + const metadataPath = getWorktreeMetadataPath(projectPath, branch); + const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string; + return JSON.parse(content) as WorktreeMetadata; + } catch (_error) { + // File doesn't exist or can't be read + return null; + } +} + +/** + * Write worktree metadata for a branch + */ +export async function writeWorktreeMetadata( + projectPath: string, + branch: string, + metadata: WorktreeMetadata +): Promise { + const metadataDir = getWorktreeMetadataDir(projectPath, branch); + const metadataPath = getWorktreeMetadataPath(projectPath, branch); + + // Ensure directory exists + await secureFs.mkdir(metadataDir, { recursive: true }); + + // Write metadata + await secureFs.writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8'); +} + +/** + * Update PR info in worktree metadata + */ +export async function updateWorktreePRInfo( + projectPath: string, + branch: string, + prInfo: WorktreePRInfo +): Promise { + // Read existing metadata or create new + let metadata = await readWorktreeMetadata(projectPath, branch); + + if (!metadata) { + metadata = { + branch, + createdAt: new Date().toISOString(), + }; + } + + // Update PR info + metadata.pr = prInfo; + + // Write back + await writeWorktreeMetadata(projectPath, branch, metadata); +} + +/** + * Get PR info for a branch from metadata + */ +export async function getWorktreePRInfo( + projectPath: string, + branch: string +): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.pr || null; +} + +/** + * Read all worktree metadata for a project + */ +export async function readAllWorktreeMetadata( + projectPath: string +): Promise> { + const result = new Map(); + const worktreesDir = path.join(projectPath, '.automaker', 'worktrees'); + + try { + const dirs = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const dir of dirs) { + if (dir.isDirectory()) { + const metadataPath = path.join(worktreesDir, dir.name, 'worktree.json'); + try { + const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string; + const metadata = JSON.parse(content) as WorktreeMetadata; + result.set(metadata.branch, metadata); + } catch { + // Skip if file doesn't exist or can't be read + } + } + } + } catch { + // Directory doesn't exist + } + + return result; +} + +/** + * Delete worktree metadata for a branch + */ +export async function deleteWorktreeMetadata(projectPath: string, branch: string): Promise { + const metadataDir = getWorktreeMetadataDir(projectPath, branch); + try { + await secureFs.rm(metadataDir, { recursive: true, force: true }); + } catch { + // Ignore errors if directory doesn't exist + } +} diff --git a/jules_branch/apps/server/src/lib/xml-extractor.ts b/jules_branch/apps/server/src/lib/xml-extractor.ts new file mode 100644 index 0000000000000000000000000000000000000000..72963b825dc7d4a3322c677ed9c507d77b394f3c --- /dev/null +++ b/jules_branch/apps/server/src/lib/xml-extractor.ts @@ -0,0 +1,611 @@ +/** + * XML Extraction Utilities + * + * Robust XML parsing utilities for extracting and updating sections + * from app_spec.txt XML content. Uses regex-based parsing which is + * sufficient for our controlled XML structure. + * + * Note: If more complex XML parsing is needed in the future, consider + * using a library like 'fast-xml-parser' or 'xml2js'. + */ + +import { createLogger } from '@automaker/utils'; +import type { SpecOutput } from '@automaker/types'; + +const logger = createLogger('XmlExtractor'); + +/** + * Represents an implemented feature extracted from XML + */ +export interface ImplementedFeature { + name: string; + description: string; + file_locations?: string[]; +} + +/** + * Logger interface for optional custom logging + */ +export interface XmlExtractorLogger { + debug: (message: string, ...args: unknown[]) => void; + warn?: (message: string, ...args: unknown[]) => void; +} + +/** + * Options for XML extraction operations + */ +export interface ExtractXmlOptions { + /** Custom logger (defaults to internal logger) */ + logger?: XmlExtractorLogger; +} + +/** + * Escape special XML characters + * Handles undefined/null values by converting them to empty strings + */ +export function escapeXml(str: string | undefined | null): string { + if (str == null) { + return ''; + } + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Unescape XML entities back to regular characters + */ +export function unescapeXml(str: string): string { + return str + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +/** + * Extract the content of a specific XML section + * + * @param xmlContent - The full XML content + * @param tagName - The tag name to extract (e.g., 'implemented_features') + * @param options - Optional extraction options + * @returns The content between the tags, or null if not found + */ +export function extractXmlSection( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string | null { + const log = options.logger || logger; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i'); + const match = xmlContent.match(regex); + + if (match) { + log.debug(`Extracted <${tagName}> section`); + return match[1]; + } + + log.debug(`Section <${tagName}> not found`); + return null; +} + +/** + * Extract all values from repeated XML elements + * + * @param xmlContent - The XML content to search + * @param tagName - The tag name to extract values from + * @param options - Optional extraction options + * @returns Array of extracted values (unescaped) + */ +export function extractXmlElements( + xmlContent: string, + tagName: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + const values: string[] = []; + + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g'); + const matches = xmlContent.matchAll(regex); + + for (const match of matches) { + values.push(unescapeXml(match[1].trim())); + } + + log.debug(`Extracted ${values.length} <${tagName}> elements`); + return values; +} + +/** + * Extract implemented features from app_spec.txt XML content + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of implemented features with name, description, and optional file_locations + */ +export function extractImplementedFeatures( + specContent: string, + options: ExtractXmlOptions = {} +): ImplementedFeature[] { + const log = options.logger || logger; + const features: ImplementedFeature[] = []; + + // Match ... section + const implementedSection = extractXmlSection(specContent, 'implemented_features', options); + + if (!implementedSection) { + log.debug('No implemented_features section found'); + return features; + } + + // Extract individual feature blocks + const featureRegex = /([\s\S]*?)<\/feature>/g; + const featureMatches = implementedSection.matchAll(featureRegex); + + for (const featureMatch of featureMatches) { + const featureContent = featureMatch[1]; + + // Extract name + const nameMatch = featureContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + // Extract description + const descMatch = featureContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : ''; + + // Extract file_locations if present + const locationsSection = extractXmlSection(featureContent, 'file_locations', options); + const file_locations = locationsSection + ? extractXmlElements(locationsSection, 'location', options) + : undefined; + + if (name) { + features.push({ + name, + description, + ...(file_locations && file_locations.length > 0 ? { file_locations } : {}), + }); + } + } + + log.debug(`Extracted ${features.length} implemented features`); + return features; +} + +/** + * Extract only the feature names from implemented_features section + * + * @param specContent - The full XML content of app_spec.txt + * @param options - Optional extraction options + * @returns Array of feature names + */ +export function extractImplementedFeatureNames( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const features = extractImplementedFeatures(specContent, options); + return features.map((f) => f.name); +} + +/** + * Generate XML for a single implemented feature + * + * @param feature - The feature to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the feature + */ +export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string { + const i2 = indent.repeat(2); + const i3 = indent.repeat(3); + const i4 = indent.repeat(4); + + let xml = `${i2} +${i3}${escapeXml(feature.name)} +${i3}${escapeXml(feature.description)}`; + + if (feature.file_locations && feature.file_locations.length > 0) { + xml += ` +${i3} +${feature.file_locations.map((loc) => `${i4}${escapeXml(loc)}`).join('\n')} +${i3}`; + } + + xml += ` +${i2}`; + + return xml; +} + +/** + * Generate XML for an array of implemented features + * + * @param features - Array of features to convert to XML + * @param indent - The base indentation level (default: 2 spaces) + * @returns XML string for the implemented_features section content + */ +export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string { + return features.map((f) => featureToXml(f, indent)).join('\n'); +} + +/** + * Update the implemented_features section in XML content + * + * @param specContent - The full XML content + * @param newFeatures - The new features to set + * @param options - Optional extraction options + * @returns Updated XML content with the new implemented_features section + */ +export function updateImplementedFeaturesSection( + specContent: string, + newFeatures: ImplementedFeature[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + + // Generate new section content + const newSectionContent = featuresToXml(newFeatures, indent); + + // Build the new section + const newSection = ` +${newSectionContent} +${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/implemented_features>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing implemented_features section'); + return specContent.replace(sectionRegex, newSection); + } + + // If section doesn't exist, try to insert after core_capabilities + const coreCapabilitiesEnd = ''; + const insertIndex = specContent.indexOf(coreCapabilitiesEnd); + + if (insertIndex !== -1) { + const insertPosition = insertIndex + coreCapabilitiesEnd.length; + log.debug('Inserting implemented_features after core_capabilities'); + return ( + specContent.slice(0, insertPosition) + + '\n\n' + + indent + + newSection + + specContent.slice(insertPosition) + ); + } + + // As a fallback, insert before + const projectSpecEnd = ''; + const fallbackIndex = specContent.indexOf(projectSpecEnd); + + if (fallbackIndex !== -1) { + log.debug('Inserting implemented_features before '); + return ( + specContent.slice(0, fallbackIndex) + + indent + + newSection + + '\n' + + specContent.slice(fallbackIndex) + ); + } + + log.warn?.('Could not find appropriate insertion point for implemented_features'); + log.debug('Could not find appropriate insertion point for implemented_features'); + return specContent; +} + +/** + * Add a new feature to the implemented_features section + * + * @param specContent - The full XML content + * @param newFeature - The feature to add + * @param options - Optional extraction options + * @returns Updated XML content with the new feature added + */ +export function addImplementedFeature( + specContent: string, + newFeature: ImplementedFeature, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Check for duplicates by name + const isDuplicate = existingFeatures.some( + (f) => f.name.toLowerCase() === newFeature.name.toLowerCase() + ); + + if (isDuplicate) { + log.debug(`Feature "${newFeature.name}" already exists, skipping`); + return specContent; + } + + // Add the new feature + const updatedFeatures = [...existingFeatures, newFeature]; + + log.debug(`Adding feature "${newFeature.name}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Remove a feature from the implemented_features section by name + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to remove + * @param options - Optional extraction options + * @returns Updated XML content with the feature removed + */ +export function removeImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Filter out the feature to remove + const updatedFeatures = existingFeatures.filter( + (f) => f.name.toLowerCase() !== featureName.toLowerCase() + ); + + if (updatedFeatures.length === existingFeatures.length) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Removing feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Update an existing feature in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to update + * @param updates - Partial updates to apply to the feature + * @param options - Optional extraction options + * @returns Updated XML content with the feature modified + */ +export function updateImplementedFeature( + specContent: string, + featureName: string, + updates: Partial, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Extract existing features + const existingFeatures = extractImplementedFeatures(specContent, options); + + // Find and update the feature + let found = false; + const updatedFeatures = existingFeatures.map((f) => { + if (f.name.toLowerCase() === featureName.toLowerCase()) { + found = true; + return { + ...f, + ...updates, + // Preserve the original name if not explicitly updated + name: updates.name ?? f.name, + }; + } + return f; + }); + + if (!found) { + log.debug(`Feature "${featureName}" not found, no changes made`); + return specContent; + } + + log.debug(`Updating feature "${featureName}"`); + return updateImplementedFeaturesSection(specContent, updatedFeatures, options); +} + +/** + * Check if a feature exists in the implemented_features section + * + * @param specContent - The full XML content + * @param featureName - The name of the feature to check + * @param options - Optional extraction options + * @returns True if the feature exists + */ +export function hasImplementedFeature( + specContent: string, + featureName: string, + options: ExtractXmlOptions = {} +): boolean { + const features = extractImplementedFeatures(specContent, options); + return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase()); +} + +/** + * Convert extracted features to SpecOutput.implemented_features format + * + * @param features - Array of extracted features + * @returns Features in SpecOutput format + */ +export function toSpecOutputFeatures( + features: ImplementedFeature[] +): SpecOutput['implemented_features'] { + return features.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} + +/** + * Convert SpecOutput.implemented_features to ImplementedFeature format + * + * @param specFeatures - Features from SpecOutput + * @returns Features in ImplementedFeature format + */ +export function fromSpecOutputFeatures( + specFeatures: SpecOutput['implemented_features'] +): ImplementedFeature[] { + return specFeatures.map((f) => ({ + name: f.name, + description: f.description, + ...(f.file_locations && f.file_locations.length > 0 + ? { file_locations: f.file_locations } + : {}), + })); +} + +/** + * Represents a roadmap phase extracted from XML + */ +export interface RoadmapPhase { + name: string; + status: string; + description?: string; +} + +/** + * Extract the technology stack from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of technology names + */ +export function extractTechnologyStack( + specContent: string, + options: ExtractXmlOptions = {} +): string[] { + const log = options.logger || logger; + + const techSection = extractXmlSection(specContent, 'technology_stack', options); + if (!techSection) { + log.debug('No technology_stack section found'); + return []; + } + + const technologies = extractXmlElements(techSection, 'technology', options); + log.debug(`Extracted ${technologies.length} technologies`); + return technologies; +} + +/** + * Update the technology_stack section in XML content + * + * @param specContent - The full XML content + * @param technologies - The new technology list + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateTechnologyStack( + specContent: string, + technologies: string[], + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + const indent = ' '; + const i2 = indent.repeat(2); + + // Generate new section content + const techXml = technologies + .map((t) => `${i2}${escapeXml(t)}`) + .join('\n'); + const newSection = `\n${techXml}\n${indent}`; + + // Check if section exists + const sectionRegex = /[\s\S]*?<\/technology_stack>/; + + if (sectionRegex.test(specContent)) { + log.debug('Replacing existing technology_stack section'); + return specContent.replace(sectionRegex, newSection); + } + + log.debug('No technology_stack section found to update'); + return specContent; +} + +/** + * Extract roadmap phases from app_spec.txt XML content + * + * @param specContent - The full XML content + * @param options - Optional extraction options + * @returns Array of roadmap phases + */ +export function extractRoadmapPhases( + specContent: string, + options: ExtractXmlOptions = {} +): RoadmapPhase[] { + const log = options.logger || logger; + const phases: RoadmapPhase[] = []; + + const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options); + if (!roadmapSection) { + log.debug('No implementation_roadmap section found'); + return phases; + } + + // Extract individual phase blocks + const phaseRegex = /([\s\S]*?)<\/phase>/g; + const phaseMatches = roadmapSection.matchAll(phaseRegex); + + for (const phaseMatch of phaseMatches) { + const phaseContent = phaseMatch[1]; + + const nameMatch = phaseContent.match(/([\s\S]*?)<\/name>/); + const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; + + const statusMatch = phaseContent.match(/([\s\S]*?)<\/status>/); + const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending'; + + const descMatch = phaseContent.match(/([\s\S]*?)<\/description>/); + const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined; + + if (name) { + phases.push({ name, status, description }); + } + } + + log.debug(`Extracted ${phases.length} roadmap phases`); + return phases; +} + +/** + * Update a roadmap phase status in XML content + * + * @param specContent - The full XML content + * @param phaseName - The name of the phase to update + * @param newStatus - The new status value + * @param options - Optional extraction options + * @returns Updated XML content + */ +export function updateRoadmapPhaseStatus( + specContent: string, + phaseName: string, + newStatus: string, + options: ExtractXmlOptions = {} +): string { + const log = options.logger || logger; + + // Find the phase and update its status + // Match the phase block containing the specific name + const phaseRegex = new RegExp( + `(\\s*\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*)[\\s\\S]*?(<\\/status>)`, + 'i' + ); + + if (phaseRegex.test(specContent)) { + log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`); + return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`); + } + + log.debug(`Phase "${phaseName}" not found`); + return specContent; +} diff --git a/jules_branch/apps/server/src/middleware/require-json-content-type.ts b/jules_branch/apps/server/src/middleware/require-json-content-type.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea02f480a8a18139a5fe3d8983e29920d854e357 --- /dev/null +++ b/jules_branch/apps/server/src/middleware/require-json-content-type.ts @@ -0,0 +1,50 @@ +/** + * Middleware to enforce Content-Type: application/json for request bodies + * + * This security middleware prevents malicious requests by requiring proper + * Content-Type headers for all POST, PUT, and PATCH requests. + * + * Rejecting requests without proper Content-Type helps prevent: + * - CSRF attacks via form submissions (which use application/x-www-form-urlencoded) + * - Content-type confusion attacks + * - Malformed request exploitation + */ + +import type { Request, Response, NextFunction } from 'express'; + +// HTTP methods that typically include request bodies +const METHODS_REQUIRING_JSON = ['POST', 'PUT', 'PATCH']; + +/** + * Middleware that requires Content-Type: application/json for POST/PUT/PATCH requests + * + * Returns 415 Unsupported Media Type if: + * - The request method is POST, PUT, or PATCH + * - AND the Content-Type header is missing or not application/json + * + * Allows requests to pass through if: + * - The request method is GET, DELETE, OPTIONS, HEAD, etc. + * - OR the Content-Type is properly set to application/json (with optional charset) + */ +export function requireJsonContentType(req: Request, res: Response, next: NextFunction): void { + // Skip validation for methods that don't require a body + if (!METHODS_REQUIRING_JSON.includes(req.method)) { + next(); + return; + } + + const contentType = req.headers['content-type']; + + // Check if Content-Type header exists and contains application/json + // Allows for charset parameter: "application/json; charset=utf-8" + if (!contentType || !contentType.toLowerCase().includes('application/json')) { + res.status(415).json({ + success: false, + error: 'Unsupported Media Type', + message: 'Content-Type header must be application/json', + }); + return; + } + + next(); +} diff --git a/jules_branch/apps/server/src/middleware/validate-paths.ts b/jules_branch/apps/server/src/middleware/validate-paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f7f38760bfcf5204da873717857b0c7406a8790 --- /dev/null +++ b/jules_branch/apps/server/src/middleware/validate-paths.ts @@ -0,0 +1,87 @@ +/** + * Middleware for validating path parameters against ALLOWED_ROOT_DIRECTORY + * Provides a clean, reusable way to validate paths without repeating the same + * try-catch block in every route handler + */ + +import type { Request, Response, NextFunction } from 'express'; +import { validatePath, PathNotAllowedError } from '@automaker/platform'; + +/** + * Helper to get parameter value from request (checks body first, then query) + */ +function getParamValue(req: Request, paramName: string): unknown { + // Check body first (for POST/PUT/PATCH requests) + if (req.body && req.body[paramName] !== undefined) { + return req.body[paramName]; + } + // Fall back to query params (for GET requests) + if (req.query && req.query[paramName] !== undefined) { + return req.query[paramName]; + } + return undefined; +} + +/** + * Creates a middleware that validates specified path parameters in req.body or req.query + * @param paramNames - Names of parameters to validate (e.g., 'projectPath', 'worktreePath') + * @example + * router.post('/create', validatePathParams('projectPath'), handler); + * router.post('/delete', validatePathParams('projectPath', 'worktreePath'), handler); + * router.post('/send', validatePathParams('workingDirectory?', 'imagePaths[]'), handler); + * router.get('/logs', validatePathParams('worktreePath'), handler); // Works with query params too + * + * Special syntax: + * - 'paramName?' - Optional parameter (only validated if present) + * - 'paramName[]' - Array parameter (validates each element) + */ +export function validatePathParams(...paramNames: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + try { + for (const paramName of paramNames) { + // Handle optional parameters (paramName?) + if (paramName.endsWith('?')) { + const actualName = paramName.slice(0, -1); + const value = getParamValue(req, actualName); + if (value && typeof value === 'string') { + validatePath(value); + } + continue; + } + + // Handle array parameters (paramName[]) + if (paramName.endsWith('[]')) { + const actualName = paramName.slice(0, -2); + const values = getParamValue(req, actualName); + if (Array.isArray(values) && values.length > 0) { + for (const value of values) { + if (typeof value === 'string') { + validatePath(value); + } + } + } + continue; + } + + // Handle regular parameters + const value = getParamValue(req, paramName); + if (value && typeof value === 'string') { + validatePath(value); + } + } + + next(); + } catch (error) { + if (error instanceof PathNotAllowedError) { + res.status(403).json({ + success: false, + error: error.message, + }); + return; + } + + // Re-throw unexpected errors + throw error; + } + }; +} diff --git a/jules_branch/apps/server/src/providers/base-provider.ts b/jules_branch/apps/server/src/providers/base-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b1880d3c39e72d6fa9a7fb4fbc5a4a555f0f3ef --- /dev/null +++ b/jules_branch/apps/server/src/providers/base-provider.ts @@ -0,0 +1,94 @@ +/** + * Abstract base class for AI model providers + */ + +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ValidationResult, + ModelDefinition, +} from './types.js'; + +/** + * Base provider class that all provider implementations must extend + */ +export abstract class BaseProvider { + protected config: ProviderConfig; + protected name: string; + + constructor(config: ProviderConfig = {}) { + this.config = config; + this.name = this.getName(); + } + + /** + * Get the provider name (e.g., "claude", "cursor") + */ + abstract getName(): string; + + /** + * Execute a query and stream responses + * @param options Execution options + * @returns AsyncGenerator yielding provider messages + */ + abstract executeQuery(options: ExecuteOptions): AsyncGenerator; + + /** + * Detect if the provider is installed and configured + * @returns Installation status + */ + abstract detectInstallation(): Promise; + + /** + * Get available models for this provider + * @returns Array of model definitions + */ + abstract getAvailableModels(): ModelDefinition[]; + + /** + * Validate the provider configuration + * @returns Validation result + */ + validateConfig(): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Base validation (can be overridden) + if (!this.config) { + errors.push('Provider config is missing'); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Check if the provider supports a specific feature + * @param feature Feature name (e.g., "vision", "tools", "mcp") + * @returns Whether the feature is supported + */ + supportsFeature(feature: string): boolean { + // Default implementation - override in subclasses + const commonFeatures = ['tools', 'text']; + return commonFeatures.includes(feature); + } + + /** + * Get provider configuration + */ + getConfig(): ProviderConfig { + return this.config; + } + + /** + * Update provider configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} diff --git a/jules_branch/apps/server/src/providers/claude-provider.ts b/jules_branch/apps/server/src/providers/claude-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe471e210ae9fb699d37f292994fd3b4673763ed --- /dev/null +++ b/jules_branch/apps/server/src/providers/claude-provider.ts @@ -0,0 +1,448 @@ +/** + * Claude Provider - Executes queries using Claude Agent SDK + * + * Wraps the @anthropic-ai/claude-agent-sdk for seamless integration + * with the provider architecture. + */ + +import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import { BaseProvider } from './base-provider.js'; +import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils'; +import { getClaudeAuthIndicators } from '@automaker/platform'; +import { + getThinkingTokenBudget, + validateBareModelId, + type ClaudeApiProfile, + type ClaudeCompatibleProvider, + type Credentials, +} from '@automaker/types'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; + +const logger = createLogger('ClaudeProvider'); + +/** + * ProviderConfig - Union type for provider configuration + * + * Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider. + * Both share the same connection settings structure. + */ +type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider; + +// System vars are always passed from process.env regardless of profile. +// Includes filesystem, locale, and temp directory vars that the Claude CLI +// needs internally for config resolution and temp file creation. +const SYSTEM_ENV_VARS = [ + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', + 'TMPDIR', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'XDG_STATE_HOME', +]; + +/** + * Check if the config is a ClaudeCompatibleProvider (new system) + * by checking for the 'models' array property + */ +function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider { + return 'models' in config && Array.isArray(config.models); +} + +/** + * Build environment for the SDK with only explicitly allowed variables. + * When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env). + * When no provider is provided, uses direct Anthropic API settings from process.env. + * + * Supports both: + * - ClaudeCompatibleProvider (new system with models[] array) + * - ClaudeApiProfile (legacy system with modelMappings) + * + * @param providerConfig - Optional provider configuration for alternative endpoint + * @param credentials - Optional credentials object for resolving 'credentials' apiKeySource + */ +function buildEnv( + providerConfig?: ProviderConfig, + credentials?: Credentials +): Record { + const env: Record = {}; + + if (providerConfig) { + // Use provider configuration (clean switch - don't inherit non-system vars from process.env) + logger.debug('[buildEnv] Using provider configuration:', { + name: providerConfig.name, + baseUrl: providerConfig.baseUrl, + apiKeySource: providerConfig.apiKeySource ?? 'inline', + isNewProvider: isClaudeCompatibleProvider(providerConfig), + }); + + // Resolve API key based on source strategy + let apiKey: string | undefined; + const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat + + switch (source) { + case 'inline': + apiKey = providerConfig.apiKey; + break; + case 'env': + apiKey = process.env.ANTHROPIC_API_KEY; + break; + case 'credentials': + apiKey = credentials?.apiKeys?.anthropic; + break; + } + + // Warn if no API key found + if (!apiKey) { + logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`); + } + + // Authentication + if (providerConfig.useAuthToken) { + env['ANTHROPIC_AUTH_TOKEN'] = apiKey; + } else { + env['ANTHROPIC_API_KEY'] = apiKey; + } + + // Endpoint configuration + env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl; + logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`); + + if (providerConfig.timeoutMs) { + env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs); + } + + // Model mappings - only for legacy ClaudeApiProfile + // For ClaudeCompatibleProvider, the model is passed directly (no mapping needed) + if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) { + if (providerConfig.modelMappings.haiku) { + env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku; + } + if (providerConfig.modelMappings.sonnet) { + env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet; + } + if (providerConfig.modelMappings.opus) { + env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus; + } + } + + // Traffic control + if (providerConfig.disableNonessentialTraffic) { + env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1'; + } + } else { + // Use direct Anthropic API - pass through credentials or environment variables + // This supports: + // 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env + // 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically) + // 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility) + // + // Priority: credentials file (UI settings) -> environment variable + // Note: Only auth and endpoint vars are passed. Model mappings and traffic + // control are NOT passed (those require a profile for explicit configuration). + if (credentials?.apiKeys?.anthropic) { + env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic; + } else if (process.env.ANTHROPIC_API_KEY) { + env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY; + } + // If using Claude Max plan via CLI auth, the SDK handles auth automatically + // when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here + // unless it was explicitly set in process.env (rare edge case). + if (process.env.ANTHROPIC_AUTH_TOKEN) { + env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN; + } + // Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility) + if (process.env.ANTHROPIC_BASE_URL) { + env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL; + } + } + + // Always add system vars from process.env + for (const key of SYSTEM_ENV_VARS) { + if (process.env[key]) { + env[key] = process.env[key]; + } + } + + return env; +} + +export class ClaudeProvider extends BaseProvider { + getName(): string { + return 'claude'; + } + + /** + * Execute a query using Claude Agent SDK + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Validate that model doesn't have a provider prefix + // AgentService should strip prefixes before passing to providers + // Claude doesn't use a provider prefix, so we don't need to specify an expected provider + validateBareModelId(options.model, 'ClaudeProvider'); + + const { + prompt, + model, + cwd, + systemPrompt, + maxTurns = 1000, + allowedTools, + abortController, + conversationHistory, + sdkSessionId, + thinkingLevel, + claudeApiProfile, + claudeCompatibleProvider, + credentials, + } = options; + + // Determine which provider config to use + // claudeCompatibleProvider takes precedence over claudeApiProfile + const providerConfig = claudeCompatibleProvider || claudeApiProfile; + + // Build thinking configuration + // Adaptive thinking (Opus 4.6): don't set maxThinkingTokens, model uses adaptive by default + // Manual thinking (Haiku/Sonnet): use budget_tokens + const maxThinkingTokens = + thinkingLevel === 'adaptive' ? undefined : getThinkingTokenBudget(thinkingLevel); + + // Build Claude SDK options + const sdkOptions: Options = { + model, + systemPrompt, + maxTurns, + cwd, + // Pass only explicitly allowed environment variables to SDK + // When a provider is active, uses provider settings (clean switch) + // When no provider, uses direct Anthropic API (from process.env or CLI OAuth) + env: buildEnv(providerConfig, credentials), + // Pass through allowedTools if provided by caller (decided by sdk-options.ts) + ...(allowedTools && { allowedTools }), + // Restrict available built-in tools if specified (tools: [] disables all tools) + ...(options.tools && { tools: options.tools }), + // AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + abortController, + // Resume existing SDK session if we have a session ID + ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 + ? { resume: sdkSessionId } + : {}), + // Forward settingSources for CLAUDE.md file loading + ...(options.settingSources && { settingSources: options.settingSources }), + // Forward MCP servers configuration + ...(options.mcpServers && { mcpServers: options.mcpServers }), + // Extended thinking configuration + ...(maxThinkingTokens && { maxThinkingTokens }), + // Subagents configuration for specialized task delegation + ...(options.agents && { agents: options.agents }), + // Pass through outputFormat for structured JSON outputs + ...(options.outputFormat && { outputFormat: options.outputFormat }), + }; + + // Build prompt payload + let promptPayload: string | AsyncIterable; + + if (Array.isArray(prompt)) { + // Multi-part prompt (with images) + promptPayload = (async function* () { + const multiPartPrompt: SDKUserMessage = { + type: 'user' as const, + session_id: sdkSessionId || '', + message: { + role: 'user' as const, + content: prompt, + }, + parent_tool_use_id: null, + }; + yield multiPartPrompt; + })(); + } else { + // Simple text prompt + promptPayload = prompt; + } + + // Log the environment being passed to the SDK for debugging + const envForSdk = sdkOptions.env as Record; + logger.debug('[ClaudeProvider] SDK Configuration:', { + model: sdkOptions.model, + baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)', + hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'], + hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'], + providerName: providerConfig?.name || '(direct Anthropic)', + maxTurns: sdkOptions.maxTurns, + maxThinkingTokens: sdkOptions.maxThinkingTokens, + }); + + // Execute via Claude Agent SDK + try { + const stream = query({ prompt: promptPayload, options: sdkOptions }); + + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + yield msg as ProviderMessage; + } + } catch (error) { + // Enhance error with user-friendly message and classification + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + + logger.error('executeQuery() error during execution:', { + type: errorInfo.type, + message: errorInfo.message, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: (error as Error).stack, + }); + + // Build enhanced error message with additional guidance for rate limits + const message = errorInfo.isRateLimit + ? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.` + : userMessage; + + const enhancedError = new Error(message) as Error & { + originalError: unknown; + type: string; + retryAfter?: number; + }; + enhancedError.originalError = error; + enhancedError.type = errorInfo.type; + + if (errorInfo.isRateLimit) { + enhancedError.retryAfter = errorInfo.retryAfter; + } + + throw enhancedError; + } + } + + /** + * Detect Claude SDK installation (always available via npm) + */ + async detectInstallation(): Promise { + // Claude SDK is always available since it's a dependency + // Check all four supported auth methods, mirroring the logic in buildEnv(): + // 1. ANTHROPIC_API_KEY environment variable + // 2. ANTHROPIC_AUTH_TOKEN environment variable + // 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators) + // 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators) + const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN; + + // Check credentials file and CLI OAuth indicators (same sources used by buildEnv) + let hasCredentialsApiKey = false; + let hasCliOAuth = false; + try { + const indicators = await getClaudeAuthIndicators(); + hasCredentialsApiKey = !!indicators.credentials?.hasApiKey; + hasCliOAuth = !!( + indicators.credentials?.hasOAuthToken || + indicators.hasStatsCacheWithActivity || + (indicators.hasSettingsFile && indicators.hasProjectsSessions) + ); + } catch { + // If we can't check indicators, fall back to env vars only + } + + const hasApiKey = hasEnvApiKey || hasCredentialsApiKey; + const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth; + + const status: InstallationStatus = { + installed: true, + method: 'sdk', + hasApiKey, + authenticated, + }; + + return status; + } + + /** + * Get available Claude models + */ + getAvailableModels(): ModelDefinition[] { + const models = [ + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + modelString: 'claude-opus-4-6', + provider: 'anthropic', + description: 'Most capable Claude model with adaptive thinking', + contextWindow: 200000, + maxOutputTokens: 128000, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + default: true, + }, + { + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + modelString: 'claude-sonnet-4-6', + provider: 'anthropic', + description: 'Balanced performance and cost with enhanced reasoning', + contextWindow: 200000, + maxOutputTokens: 64000, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + }, + { + id: 'claude-sonnet-4-20250514', + name: 'Claude Sonnet 4', + modelString: 'claude-sonnet-4-20250514', + provider: 'anthropic', + description: 'Balanced performance and cost', + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + }, + { + id: 'claude-3-5-sonnet-20241022', + name: 'Claude 3.5 Sonnet', + modelString: 'claude-3-5-sonnet-20241022', + provider: 'anthropic', + description: 'Fast and capable', + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + }, + { + id: 'claude-haiku-4-5-20251001', + name: 'Claude Haiku 4.5', + modelString: 'claude-haiku-4-5-20251001', + provider: 'anthropic', + description: 'Fastest Claude model', + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: 'basic' as const, + }, + ] satisfies ModelDefinition[]; + return models; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ['tools', 'text', 'vision', 'thinking']; + return supportedFeatures.includes(feature); + } +} diff --git a/jules_branch/apps/server/src/providers/cli-provider.ts b/jules_branch/apps/server/src/providers/cli-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea636cb8e68e3b48c0dced97c7e179d253324523 --- /dev/null +++ b/jules_branch/apps/server/src/providers/cli-provider.ts @@ -0,0 +1,625 @@ +/** + * CliProvider - Abstract base class for CLI-based AI providers + * + * Provides common infrastructure for CLI tools that spawn subprocesses + * and stream JSONL output. Handles: + * - Platform-specific CLI detection (PATH, common locations) + * - Windows execution strategies (WSL, npx, direct, cmd) + * - JSONL subprocess spawning and streaming + * - Error mapping infrastructure + * + * @example + * ```typescript + * class CursorProvider extends CliProvider { + * getCliName(): string { return 'cursor-agent'; } + * getSpawnConfig(): CliSpawnConfig { + * return { + * windowsStrategy: 'wsl', + * commonPaths: { + * linux: ['~/.local/bin/cursor-agent'], + * darwin: ['~/.local/bin/cursor-agent'], + * } + * }; + * } + * // ... implement abstract methods + * } + * ``` + */ + +import { + createWslCommand, + findCliInWsl, + isWslAvailable, + spawnJSONLProcess, + windowsToWslPath, + type SubprocessOptions, + type WslCliResult, +} from '@automaker/platform'; +import { calculateReasoningTimeout } from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { BaseProvider } from './base-provider.js'; +import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js'; + +/** + * Spawn strategy for CLI tools on Windows + * + * Different CLI tools require different execution strategies: + * - 'wsl': Requires WSL, CLI only available on Linux/macOS (e.g., cursor-agent) + * - 'npx': Installed globally via npm/npx, use `npx ` to run + * - 'direct': Native Windows binary, can spawn directly + * - 'cmd': Windows batch file (.cmd/.bat), needs cmd.exe shell + */ +export type SpawnStrategy = 'wsl' | 'npx' | 'direct' | 'cmd'; + +/** + * Configuration for CLI tool spawning + */ +export interface CliSpawnConfig { + /** How to spawn on Windows */ + windowsStrategy: SpawnStrategy; + + /** NPX package name (required if windowsStrategy is 'npx') */ + npxPackage?: string; + + /** Preferred WSL distribution (if windowsStrategy is 'wsl') */ + wslDistribution?: string; + + /** + * Common installation paths per platform + * Use ~ for home directory (will be expanded) + * Keys: 'linux', 'darwin', 'win32' + */ + commonPaths: Record; + + /** Version check command (defaults to --version) */ + versionCommand?: string; +} + +/** + * CLI error information for consistent error handling + */ +export interface CliErrorInfo { + code: string; + message: string; + recoverable: boolean; + suggestion?: string; +} + +/** + * Detection result from CLI path finding + */ +export interface CliDetectionResult { + /** Path to the CLI (or 'npx' for npx strategy) */ + cliPath: string | null; + /** Whether using WSL mode */ + useWsl: boolean; + /** WSL path if using WSL */ + wslCliPath?: string; + /** WSL distribution if using WSL */ + wslDistribution?: string; + /** Detected strategy used */ + strategy: SpawnStrategy | 'native'; +} + +// Create logger for CLI operations +const cliLogger = createLogger('CliProvider'); + +/** + * Base timeout for CLI operations in milliseconds. + * CLI tools have longer startup and processing times compared to direct API calls, + * so we use a higher base timeout (120s) than the default provider timeout (30s). + * This is multiplied by reasoning effort multipliers when applicable. + * @see calculateReasoningTimeout from @automaker/types + */ +const CLI_BASE_TIMEOUT_MS = 120000; + +/** + * Abstract base class for CLI-based providers + * + * Subclasses must implement: + * - getCliName(): CLI executable name + * - getSpawnConfig(): Platform-specific spawn configuration + * - buildCliArgs(): Convert ExecuteOptions to CLI arguments + * - normalizeEvent(): Convert CLI output to ProviderMessage + */ +export abstract class CliProvider extends BaseProvider { + // CLI detection results (cached after first detection) + protected cliPath: string | null = null; + protected useWsl: boolean = false; + protected wslCliPath: string | null = null; + protected wslDistribution: string | undefined = undefined; + protected detectedStrategy: SpawnStrategy | 'native' = 'native'; + + // NPX args (used when strategy is 'npx') + protected npxArgs: string[] = []; + + constructor(config: ProviderConfig = {}) { + super(config); + // Detection happens lazily on first use + } + + // ========================================================================== + // Abstract methods - must be implemented by subclasses + // ========================================================================== + + /** + * Get the CLI executable name (e.g., 'cursor-agent', 'aider') + */ + abstract getCliName(): string; + + /** + * Get spawn configuration for this CLI + */ + abstract getSpawnConfig(): CliSpawnConfig; + + /** + * Build CLI arguments from execution options + * @param options Execution options + * @returns Array of CLI arguments + */ + abstract buildCliArgs(options: ExecuteOptions): string[]; + + /** + * Normalize a raw CLI event to ProviderMessage format + * @param event Raw event from CLI JSONL output + * @returns Normalized ProviderMessage or null to skip + */ + abstract normalizeEvent(event: unknown): ProviderMessage | null; + + // ========================================================================== + // Optional overrides + // ========================================================================== + + /** + * Map CLI stderr/exit code to error info + * Override to provide CLI-specific error mapping + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + // Common authentication errors + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') + ) { + return { + code: 'NOT_AUTHENTICATED', + message: `${this.getCliName()} is not authenticated`, + recoverable: true, + suggestion: `Run "${this.getCliName()} login" to authenticate`, + }; + } + + // Rate limiting + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') + ) { + return { + code: 'RATE_LIMITED', + message: 'API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again', + }; + } + + // Network errors + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: 'NETWORK_ERROR', + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + // Process killed + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: 'PROCESS_CRASHED', + message: 'Process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + // Generic error + return { + code: 'UNKNOWN_ERROR', + message: stderr || `Process exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Get installation instructions for this CLI + * Override to provide CLI-specific instructions + */ + protected getInstallInstructions(): string { + const cliName = this.getCliName(); + const config = this.getSpawnConfig(); + + if (process.platform === 'win32') { + switch (config.windowsStrategy) { + case 'wsl': + return `${cliName} requires WSL on Windows. Install WSL, then run inside WSL to install.`; + case 'npx': + return `Install with: npm install -g ${config.npxPackage || cliName}`; + case 'cmd': + case 'direct': + return `${cliName} is not installed. Check the documentation for installation instructions.`; + } + } + + return `${cliName} is not installed. Check the documentation for installation instructions.`; + } + + // ========================================================================== + // CLI Detection + // ========================================================================== + + /** + * Expand ~ to home directory in path + */ + private expandPath(p: string): string { + if (p.startsWith('~')) { + return path.join(os.homedir(), p.slice(1)); + } + return p; + } + + /** + * Find CLI in PATH using 'which' (Unix) or 'where' (Windows) + */ + private findCliInPath(): string | null { + const cliName = this.getCliName(); + + try { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = execSync(`${command} ${cliName}`, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + .trim() + .split('\n')[0]; + + if (result && fs.existsSync(result)) { + cliLogger.debug(`Found ${cliName} in PATH: ${result}`); + return result; + } + } catch { + // Not in PATH + } + + return null; + } + + /** + * Find CLI in common installation paths for current platform + */ + private findCliInCommonPaths(): string | null { + const config = this.getSpawnConfig(); + const cliName = this.getCliName(); + const platform = process.platform as 'linux' | 'darwin' | 'win32'; + const paths = config.commonPaths[platform] || []; + + for (const p of paths) { + const expandedPath = this.expandPath(p); + if (fs.existsSync(expandedPath)) { + cliLogger.debug(`Found ${cliName} at: ${expandedPath}`); + return expandedPath; + } + } + + return null; + } + + /** + * Detect CLI installation using appropriate strategy + */ + protected detectCli(): CliDetectionResult { + const config = this.getSpawnConfig(); + const cliName = this.getCliName(); + const wslLogger = (msg: string) => cliLogger.debug(msg); + + // Windows - use configured strategy + if (process.platform === 'win32') { + switch (config.windowsStrategy) { + case 'wsl': { + // Check WSL for CLI + if (isWslAvailable({ logger: wslLogger })) { + const wslResult: WslCliResult | null = findCliInWsl(cliName, { + logger: wslLogger, + distribution: config.wslDistribution, + }); + if (wslResult) { + cliLogger.debug( + `Using ${cliName} via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}` + ); + return { + cliPath: 'wsl.exe', + useWsl: true, + wslCliPath: wslResult.wslPath, + wslDistribution: wslResult.distribution, + strategy: 'wsl', + }; + } + } + cliLogger.debug(`${cliName} not found (WSL not available or CLI not installed in WSL)`); + return { cliPath: null, useWsl: false, strategy: 'wsl' }; + } + + case 'npx': { + // For npx, we don't need to find the CLI, just return npx + cliLogger.debug(`Using ${cliName} via npx (package: ${config.npxPackage})`); + return { + cliPath: 'npx', + useWsl: false, + strategy: 'npx', + }; + } + + case 'direct': + case 'cmd': { + // Native Windows - check PATH and common paths + const pathResult = this.findCliInPath(); + if (pathResult) { + return { cliPath: pathResult, useWsl: false, strategy: config.windowsStrategy }; + } + + const commonResult = this.findCliInCommonPaths(); + if (commonResult) { + return { cliPath: commonResult, useWsl: false, strategy: config.windowsStrategy }; + } + + cliLogger.debug(`${cliName} not found on Windows`); + return { cliPath: null, useWsl: false, strategy: config.windowsStrategy }; + } + } + } + + // Linux/macOS - native execution + const pathResult = this.findCliInPath(); + if (pathResult) { + return { cliPath: pathResult, useWsl: false, strategy: 'native' }; + } + + const commonResult = this.findCliInCommonPaths(); + if (commonResult) { + return { cliPath: commonResult, useWsl: false, strategy: 'native' }; + } + + cliLogger.debug(`${cliName} not found`); + return { cliPath: null, useWsl: false, strategy: 'native' }; + } + + /** + * Ensure CLI is detected (lazy initialization) + */ + protected ensureCliDetected(): void { + if (this.cliPath !== null || this.detectedStrategy !== 'native') { + return; // Already detected + } + + const result = this.detectCli(); + this.cliPath = result.cliPath; + this.useWsl = result.useWsl; + this.wslCliPath = result.wslCliPath || null; + this.wslDistribution = result.wslDistribution; + this.detectedStrategy = result.strategy; + + // Set up npx args if using npx strategy + const config = this.getSpawnConfig(); + if (result.strategy === 'npx' && config.npxPackage) { + this.npxArgs = [config.npxPackage]; + } + } + + /** + * Check if CLI is installed + */ + async isInstalled(): Promise { + this.ensureCliDetected(); + return this.cliPath !== null; + } + + // ========================================================================== + // Subprocess Spawning + // ========================================================================== + + /** + * Build subprocess options based on detected strategy + */ + protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { + this.ensureCliDetected(); + + if (!this.cliPath) { + throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); + } + + const cwd = options.cwd || process.cwd(); + + // Filter undefined values from process.env + const filteredEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + filteredEnv[key] = value; + } + } + + // Calculate dynamic timeout based on reasoning effort. + // This addresses GitHub issue #530 where reasoning models with 'xhigh' effort would timeout. + const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS); + + // WSL strategy + if (this.useWsl && this.wslCliPath) { + const wslCwd = windowsToWslPath(cwd); + const wslCmd = createWslCommand(this.wslCliPath, cliArgs, { + distribution: this.wslDistribution, + }); + + // Add --cd flag to change directory inside WSL + let args: string[]; + if (this.wslDistribution) { + args = ['-d', this.wslDistribution, '--cd', wslCwd, this.wslCliPath, ...cliArgs]; + } else { + args = ['--cd', wslCwd, this.wslCliPath, ...cliArgs]; + } + + cliLogger.debug(`WSL spawn: ${wslCmd.command} ${args.slice(0, 6).join(' ')}...`); + + return { + command: wslCmd.command, + args, + cwd, // Windows cwd for spawn + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + + // NPX strategy + if (this.detectedStrategy === 'npx') { + const allArgs = [...this.npxArgs, ...cliArgs]; + cliLogger.debug(`NPX spawn: npx ${allArgs.slice(0, 6).join(' ')}...`); + + return { + command: 'npx', + args: allArgs, + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + + // Direct strategy (native Unix or Windows direct/cmd) + cliLogger.debug(`Direct spawn: ${this.cliPath} ${cliArgs.slice(0, 6).join(' ')}...`); + + return { + command: this.cliPath, + args: cliArgs, + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout, + }; + } + + /** + * Execute a query using the CLI with JSONL streaming + * + * This is a default implementation that: + * 1. Builds CLI args from options + * 2. Spawns the subprocess with appropriate strategy + * 3. Streams and normalizes events + * + * Subclasses can override for custom behavior. + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + if (!this.cliPath) { + throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); + } + + // Many CLI-based providers do not support a separate "system" message. + // If a systemPrompt is provided, embed it into the prompt so downstream models + // still receive critical formatting/schema instructions (e.g., JSON-only outputs). + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + const cliArgs = this.buildCliArgs(effectiveOptions); + const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs); + + try { + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const normalized = this.normalizeEvent(rawEvent); + if (normalized) { + yield normalized; + } + } + } catch (error) { + if (isAbortError(error)) { + cliLogger.debug('Query aborted'); + return; + } + + // Map CLI errors + if (error instanceof Error && 'stderr' in error) { + const errorInfo = this.mapError( + (error as { stderr?: string }).stderr || error.message, + (error as { exitCode?: number | null }).exitCode ?? null + ); + + const cliError = new Error(errorInfo.message) as Error & CliErrorInfo; + cliError.code = errorInfo.code; + cliError.recoverable = errorInfo.recoverable; + cliError.suggestion = errorInfo.suggestion; + throw cliError; + } + + throw error; + } + } + + /** + * Embed system prompt text into the user prompt for CLI providers. + * + * Most CLI providers we integrate with only accept a single prompt via stdin/args. + * When upstream code supplies `options.systemPrompt`, we prepend it to the prompt + * content and clear `systemPrompt` to avoid any accidental double-injection by + * subclasses. + */ + protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions { + if (!options.systemPrompt) { + return options; + } + + // Only string system prompts can be reliably embedded for CLI providers. + // Presets are provider-specific (e.g., Claude SDK) and cannot be represented + // universally. If a preset is provided, we only embed its optional `append`. + const systemText = + typeof options.systemPrompt === 'string' + ? options.systemPrompt + : options.systemPrompt.append + ? options.systemPrompt.append + : ''; + + if (!systemText) { + return { ...options, systemPrompt: undefined }; + } + + // Preserve original prompt structure. + if (typeof options.prompt === 'string') { + return { + ...options, + prompt: `${systemText}\n\n---\n\n${options.prompt}`, + systemPrompt: undefined, + }; + } + + if (Array.isArray(options.prompt)) { + return { + ...options, + prompt: [{ type: 'text', text: systemText }, ...options.prompt], + systemPrompt: undefined, + }; + } + + // Should be unreachable due to ExecuteOptions typing, but keep safe. + return { ...options, systemPrompt: undefined }; + } +} diff --git a/jules_branch/apps/server/src/providers/codex-config-manager.ts b/jules_branch/apps/server/src/providers/codex-config-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..33031c4a25c180bfe1cec3cdf25a4ed4d7fd23fd --- /dev/null +++ b/jules_branch/apps/server/src/providers/codex-config-manager.ts @@ -0,0 +1,85 @@ +/** + * Codex Config Manager - Writes MCP server configuration for Codex CLI + */ + +import path from 'path'; +import type { McpServerConfig } from '@automaker/types'; +import * as secureFs from '../lib/secure-fs.js'; + +const CODEX_CONFIG_DIR = '.codex'; +const CODEX_CONFIG_FILENAME = 'config.toml'; +const CODEX_MCP_SECTION = 'mcp_servers'; + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function formatTomlArray(values: string[]): string { + const formatted = values.map((value) => formatTomlString(value)).join(', '); + return `[${formatted}]`; +} + +function formatTomlInlineTable(values: Record): string { + const entries = Object.entries(values).map( + ([key, value]) => `${key} = ${formatTomlString(value)}` + ); + return `{ ${entries.join(', ')} }`; +} + +function formatTomlKey(key: string): string { + return `"${key.replace(/"/g, '\\"')}"`; +} + +function buildServerBlock(name: string, server: McpServerConfig): string[] { + const lines: string[] = []; + const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`; + lines.push(`[${section}]`); + + if (server.type) { + lines.push(`type = ${formatTomlString(server.type)}`); + } + + if ('command' in server && server.command) { + lines.push(`command = ${formatTomlString(server.command)}`); + } + + if ('args' in server && server.args && server.args.length > 0) { + lines.push(`args = ${formatTomlArray(server.args)}`); + } + + if ('env' in server && server.env && Object.keys(server.env).length > 0) { + lines.push(`env = ${formatTomlInlineTable(server.env)}`); + } + + if ('url' in server && server.url) { + lines.push(`url = ${formatTomlString(server.url)}`); + } + + if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) { + lines.push(`headers = ${formatTomlInlineTable(server.headers)}`); + } + + return lines; +} + +export class CodexConfigManager { + async configureMcpServers( + cwd: string, + mcpServers: Record + ): Promise { + const configDir = path.join(cwd, CODEX_CONFIG_DIR); + const configPath = path.join(configDir, CODEX_CONFIG_FILENAME); + + await secureFs.mkdir(configDir, { recursive: true }); + + const blocks: string[] = []; + for (const [name, server] of Object.entries(mcpServers)) { + blocks.push(...buildServerBlock(name, server), ''); + } + + const content = blocks.join('\n').trim(); + if (content) { + await secureFs.writeFile(configPath, content + '\n', 'utf-8'); + } + } +} diff --git a/jules_branch/apps/server/src/providers/codex-models.ts b/jules_branch/apps/server/src/providers/codex-models.ts new file mode 100644 index 0000000000000000000000000000000000000000..22839e28b341eb9fa7b9d80393b1bd3b33bf2e4c --- /dev/null +++ b/jules_branch/apps/server/src/providers/codex-models.ts @@ -0,0 +1,188 @@ +/** + * Codex Model Definitions + * + * Official Codex CLI models as documented at https://developers.openai.com/codex/models/ + */ + +import { CODEX_MODEL_MAP } from '@automaker/types'; +import type { ModelDefinition } from './types.js'; + +const CONTEXT_WINDOW_256K = 256000; +const CONTEXT_WINDOW_128K = 128000; +const MAX_OUTPUT_32K = 32000; +const MAX_OUTPUT_16K = 16000; + +/** + * All available Codex models with their specifications + * Based on https://developers.openai.com/codex/models/ + */ +export const CODEX_MODELS: ModelDefinition[] = [ + // ========== Recommended Codex Models ========== + { + id: CODEX_MODEL_MAP.gpt53Codex, + name: 'GPT-5.3-Codex', + modelString: CODEX_MODEL_MAP.gpt53Codex, + provider: 'openai', + description: 'Latest frontier agentic coding model.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + default: true, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt53CodexSpark, + name: 'GPT-5.3-Codex-Spark', + modelString: CODEX_MODEL_MAP.gpt53CodexSpark, + provider: 'openai', + description: 'Near-instant real-time coding model, 1000+ tokens/sec.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt52Codex, + name: 'GPT-5.2-Codex', + modelString: CODEX_MODEL_MAP.gpt52Codex, + provider: 'openai', + description: 'Frontier agentic coding model.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt51CodexMax, + name: 'GPT-5.1-Codex-Max', + modelString: CODEX_MODEL_MAP.gpt51CodexMax, + provider: 'openai', + description: 'Codex-optimized flagship for deep and fast reasoning.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt51CodexMini, + name: 'GPT-5.1-Codex-Mini', + modelString: CODEX_MODEL_MAP.gpt51CodexMini, + provider: 'openai', + description: 'Optimized for codex. Cheaper, faster, but less capable.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: true, + supportsTools: true, + tier: 'basic' as const, + hasReasoning: false, + }, + { + id: CODEX_MODEL_MAP.gpt51Codex, + name: 'GPT-5.1-Codex', + modelString: CODEX_MODEL_MAP.gpt51Codex, + provider: 'openai', + description: 'Original GPT-5.1 Codex agentic coding model.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5Codex, + name: 'GPT-5-Codex', + modelString: CODEX_MODEL_MAP.gpt5Codex, + provider: 'openai', + description: 'Original GPT-5 Codex model.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5CodexMini, + name: 'GPT-5-Codex-Mini', + modelString: CODEX_MODEL_MAP.gpt5CodexMini, + provider: 'openai', + description: 'Smaller, cheaper GPT-5 Codex variant.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: true, + supportsTools: true, + tier: 'basic' as const, + hasReasoning: false, + }, + + // ========== General-Purpose GPT Models ========== + { + id: CODEX_MODEL_MAP.gpt52, + name: 'GPT-5.2', + modelString: CODEX_MODEL_MAP.gpt52, + provider: 'openai', + description: 'Latest frontier model with improvements across knowledge, reasoning and coding.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt51, + name: 'GPT-5.1', + modelString: CODEX_MODEL_MAP.gpt51, + provider: 'openai', + description: 'Great for coding and agentic tasks across domains.', + contextWindow: CONTEXT_WINDOW_256K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5, + name: 'GPT-5', + modelString: CODEX_MODEL_MAP.gpt5, + provider: 'openai', + description: 'Base GPT-5 model.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, +]; + +/** + * Get model definition by ID + */ +export function getCodexModelById(modelId: string): ModelDefinition | undefined { + return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId); +} + +/** + * Get all models that support reasoning + */ +export function getReasoningModels(): ModelDefinition[] { + return CODEX_MODELS.filter((m) => m.hasReasoning); +} + +/** + * Get models by tier + */ +export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] { + return CODEX_MODELS.filter((m) => m.tier === tier); +} diff --git a/jules_branch/apps/server/src/providers/codex-provider.ts b/jules_branch/apps/server/src/providers/codex-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..3288f42ffd7907e43a488a69e4b23ddb5ca715c2 --- /dev/null +++ b/jules_branch/apps/server/src/providers/codex-provider.ts @@ -0,0 +1,1229 @@ +/** + * Codex Provider - Executes queries using Codex CLI + * + * Spawns the Codex CLI and converts JSONL output into ProviderMessage format. + */ + +import path from 'path'; +import { BaseProvider } from './base-provider.js'; +import { + spawnJSONLProcess, + spawnProcess, + findCodexCliPath, + getCodexAuthIndicators, + secureFs, + getDataDirectory, + getCodexConfigDir, +} from '@automaker/platform'; +import { checkCodexAuthentication } from '../lib/codex-auth.js'; +import { + formatHistoryAsText, + extractTextFromContent, + classifyError, + getUserFriendlyErrorMessage, + createLogger, +} from '@automaker/utils'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; +import { + supportsReasoningEffort, + validateBareModelId, + calculateReasoningTimeout, + type CodexApprovalPolicy, + type CodexSandboxMode, + type CodexAuthStatus, +} from '@automaker/types'; +import { CodexConfigManager } from './codex-config-manager.js'; +import { executeCodexSdkQuery } from './codex-sdk-client.js'; +import { + resolveCodexToolCall, + extractCodexTodoItems, + getCodexTodoToolName, +} from './codex-tool-mapping.js'; +import { SettingsService } from '../services/settings-service.js'; +import { createTempEnvOverride } from '../lib/auth-utils.js'; +import { checkSandboxCompatibility } from '../lib/sdk-options.js'; +import { CODEX_MODELS } from './codex-models.js'; + +const CODEX_COMMAND = 'codex'; +const CODEX_EXEC_SUBCOMMAND = 'exec'; +const CODEX_RESUME_SUBCOMMAND = 'resume'; +const CODEX_JSON_FLAG = '--json'; +const CODEX_MODEL_FLAG = '--model'; +const CODEX_VERSION_FLAG = '--version'; +const CODEX_CONFIG_FLAG = '--config'; +const CODEX_ADD_DIR_FLAG = '--add-dir'; +const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema'; +const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check'; +const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort'; +const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox'; +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const CODEX_EXECUTION_MODE_CLI = 'cli'; +const CODEX_EXECUTION_MODE_SDK = 'sdk'; +const ERROR_CODEX_CLI_REQUIRED = + 'Codex CLI is required for tool-enabled requests. Please install Codex CLI and run `codex login`.'; +const ERROR_CODEX_AUTH_REQUIRED = "Codex authentication is required. Please run 'codex login'."; +const ERROR_CODEX_SDK_AUTH_REQUIRED = 'OpenAI API key required for Codex SDK execution.'; + +const CODEX_EVENT_TYPES = { + itemCompleted: 'item.completed', + itemStarted: 'item.started', + itemUpdated: 'item.updated', + turnCompleted: 'turn.completed', + error: 'error', +} as const; + +const CODEX_ITEM_TYPES = { + reasoning: 'reasoning', + agentMessage: 'agent_message', + commandExecution: 'command_execution', + todoList: 'todo_list', +} as const; + +const SYSTEM_PROMPT_LABEL = 'System instructions'; +const HISTORY_HEADER = 'Current request:\n'; +const TEXT_ENCODING = 'utf-8'; +/** + * Default timeout for Codex CLI operations in milliseconds. + * This is the "no output" timeout - if the CLI doesn't produce any JSONL output + * for this duration, the process is killed. For reasoning models with high + * reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout(). + * + * For feature generation (which can generate 50+ features), we use a much longer + * base timeout (5 minutes) since Codex models are slower at generating large JSON responses. + * + * @see calculateReasoningTimeout from @automaker/types + */ +const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout +const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation +const SYSTEM_PROMPT_SEPARATOR = '\n\n'; +const CODEX_INSTRUCTIONS_DIR = '.codex'; +const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions'; +const CODEX_INSTRUCTIONS_PATH_LABEL = 'Path'; +const CODEX_INSTRUCTIONS_SOURCE_LABEL = 'Source'; +const CODEX_INSTRUCTIONS_USER_SOURCE = 'User instructions'; +const CODEX_INSTRUCTIONS_PROJECT_SOURCE = 'Project instructions'; +const CODEX_USER_INSTRUCTIONS_FILE = 'AGENTS.md'; +const CODEX_PROJECT_INSTRUCTIONS_FILES = ['AGENTS.md'] as const; +const CODEX_SETTINGS_DIR_FALLBACK = './data'; +const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false; +const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write'; +const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request'; +const TOOL_USE_ID_PREFIX = 'codex-tool-'; +const ITEM_ID_KEYS = ['id', 'item_id', 'call_id', 'tool_use_id', 'command_id'] as const; +const EVENT_ID_KEYS = ['id', 'event_id', 'request_id'] as const; +const COMMAND_OUTPUT_FIELDS = ['output', 'stdout', 'stderr', 'result'] as const; +const COMMAND_OUTPUT_SEPARATOR = '\n'; +const OUTPUT_SCHEMA_FILENAME = 'output-schema.json'; +const OUTPUT_SCHEMA_INDENT_SPACES = 2; +const IMAGE_TEMP_DIR = '.codex-images'; +const IMAGE_FILE_PREFIX = 'image-'; +const IMAGE_FILE_EXT = '.png'; +const DEFAULT_ALLOWED_TOOLS = [ + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'Glob', + 'Grep', + 'LS', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + 'Task', + 'Skill', +] as const; +const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']); +const MIN_MAX_TURNS = 1; +const CONFIG_KEY_MAX_TURNS = 'max_turns'; +const CONSTRAINTS_SECTION_TITLE = 'Codex Execution Constraints'; +const CONSTRAINTS_MAX_TURNS_LABEL = 'Max turns'; +const CONSTRAINTS_ALLOWED_TOOLS_LABEL = 'Allowed tools'; +const CONSTRAINTS_OUTPUT_SCHEMA_LABEL = 'Output format'; +const CONSTRAINTS_SESSION_ID_LABEL = 'Session ID'; +const CONSTRAINTS_NO_TOOLS_VALUE = 'none'; +const CONSTRAINTS_OUTPUT_SCHEMA_VALUE = 'Respond with JSON that matches the provided schema.'; + +type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTION_MODE_SDK; +type CodexExecutionPlan = { + mode: CodexExecutionMode; + cliPath: string | null; + openAiApiKey?: string | null; +}; + +const ALLOWED_ENV_VARS = [ + OPENAI_API_KEY_ENV, + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; + +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + const value = process.env[key]; + if (value) { + env[key] = value; + } + } + return env; +} + +async function resolveOpenAiApiKey(): Promise { + const envKey = process.env[OPENAI_API_KEY_ENV]; + if (envKey) { + return envKey; + } + + try { + const settingsService = new SettingsService(getCodexSettingsDir()); + const credentials = await settingsService.getCredentials(); + const storedKey = credentials.apiKeys.openai?.trim(); + return storedKey ? storedKey : null; + } catch { + return null; + } +} + +function hasMcpServersConfigured(options: ExecuteOptions): boolean { + return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0); +} + +function isNoToolsRequested(options: ExecuteOptions): boolean { + return Array.isArray(options.allowedTools) && options.allowedTools.length === 0; +} + +function isSdkEligible(options: ExecuteOptions): boolean { + return isNoToolsRequested(options) && !hasMcpServersConfigured(options); +} + +function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean { + // When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues. + // SDK mode is used when MCP servers are not configured (MCP requires CLI). + // Tool requests are handled by the SDK, so we allow SDK mode even with tools. + return !hasMcpServersConfigured(options); +} + +async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { + const cliPath = await findCodexCliPath(); + const authIndicators = await getCodexAuthIndicators(); + const openAiApiKey = await resolveOpenAiApiKey(); + const hasApiKey = Boolean(openAiApiKey); + const cliAvailable = Boolean(cliPath); + // CLI OAuth login takes priority: if the user has logged in via `codex login`, + // use the CLI regardless of whether an API key is also stored. + // hasOAuthToken = OAuth session from `codex login` + // authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`) + // Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials. + const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey; + const sdkEligible = isSdkEligible(options); + + // If CLI is available and the user authenticated via the CLI (`codex login`), + // prefer CLI mode over SDK. This ensures `codex login` sessions take priority + // over API keys stored in Automaker's credentials. + if (cliAvailable && hasCliNativeAuth) { + return { + mode: CODEX_EXECUTION_MODE_CLI, + cliPath, + openAiApiKey, + }; + } + + // No CLI-native auth — prefer SDK when an API key is available. + // Using SDK with an API key avoids OAuth issues that can arise with the CLI. + // MCP servers still require CLI mode since the SDK doesn't support MCP. + if (hasApiKey && isSdkEligibleWithApiKey(options)) { + return { + mode: CODEX_EXECUTION_MODE_SDK, + cliPath, + openAiApiKey, + }; + } + + // MCP servers are requested with an API key but no CLI-native auth — use CLI mode + // with the API key passed as an environment variable. + if (hasApiKey && cliAvailable) { + return { + mode: CODEX_EXECUTION_MODE_CLI, + cliPath, + openAiApiKey, + }; + } + + if (sdkEligible) { + if (!cliAvailable) { + throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED); + } + } + + if (!cliAvailable) { + throw new Error(ERROR_CODEX_CLI_REQUIRED); + } + + // At this point, neither hasCliNativeAuth nor hasApiKey is true, + // so authentication is required regardless. + throw new Error(ERROR_CODEX_AUTH_REQUIRED); +} + +function getEventType(event: Record): string | null { + if (typeof event.type === 'string') { + return event.type; + } + if (typeof event.event === 'string') { + return event.event; + } + return null; +} + +function extractText(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value + .map((item) => extractText(item)) + .filter(Boolean) + .join('\n'); + } + if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.text === 'string') { + return record.text; + } + if (typeof record.content === 'string') { + return record.content; + } + if (typeof record.message === 'string') { + return record.message; + } + } + return null; +} + +function extractCommandText(item: Record): string | null { + const direct = extractText(item.command ?? item.input ?? item.content); + if (direct) { + return direct; + } + return null; +} + +function extractCommandOutput(item: Record): string | null { + const outputs: string[] = []; + for (const field of COMMAND_OUTPUT_FIELDS) { + const value = item[field]; + const text = extractText(value); + if (text) { + outputs.push(text); + } + } + + if (outputs.length === 0) { + return null; + } + + const uniqueOutputs = outputs.filter((output, index) => outputs.indexOf(output) === index); + return uniqueOutputs.join(COMMAND_OUTPUT_SEPARATOR); +} + +function extractItemType(item: Record): string | null { + if (typeof item.type === 'string') { + return item.type; + } + if (typeof item.kind === 'string') { + return item.kind; + } + return null; +} + +function resolveSystemPrompt(systemPrompt?: unknown): string | null { + if (!systemPrompt) { + return null; + } + if (typeof systemPrompt === 'string') { + return systemPrompt; + } + if (typeof systemPrompt === 'object' && systemPrompt !== null) { + const record = systemPrompt as Record; + if (typeof record.append === 'string') { + return record.append; + } + } + return null; +} + +function buildPromptText(options: ExecuteOptions): string { + return typeof options.prompt === 'string' + ? options.prompt + : extractTextFromContent(options.prompt); +} + +function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { + const promptText = buildPromptText(options); + const historyText = options.conversationHistory + ? formatHistoryAsText(options.conversationHistory) + : ''; + const resolvedSystemPrompt = systemPromptText ?? resolveSystemPrompt(options.systemPrompt); + + const systemSection = resolvedSystemPrompt + ? `${SYSTEM_PROMPT_LABEL}:\n${resolvedSystemPrompt}\n\n` + : ''; + + return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; +} + +function buildResumePrompt(options: ExecuteOptions): string { + const promptText = buildPromptText(options); + return `${HISTORY_HEADER}${promptText}`; +} + +function formatConfigValue(value: string | number | boolean): string { + return String(value); +} + +function buildConfigOverrides( + overrides: Array<{ key: string; value: string | number | boolean }> +): string[] { + const args: string[] = []; + for (const override of overrides) { + args.push(CODEX_CONFIG_FLAG, `${override.key}=${formatConfigValue(override.value)}`); + } + return args; +} + +function resolveMaxTurns(maxTurns?: number): number | null { + if (typeof maxTurns !== 'number' || Number.isNaN(maxTurns) || !Number.isFinite(maxTurns)) { + return null; + } + const normalized = Math.floor(maxTurns); + return normalized >= MIN_MAX_TURNS ? normalized : null; +} + +function resolveSearchEnabled(allowedTools: string[], restrictTools: boolean): boolean { + const toolsToCheck = restrictTools ? allowedTools : Array.from(DEFAULT_ALLOWED_TOOLS); + return toolsToCheck.some((tool) => SEARCH_TOOL_NAMES.has(tool)); +} + +function buildCodexConstraintsPrompt( + options: ExecuteOptions, + config: { + allowedTools: string[]; + restrictTools: boolean; + maxTurns: number | null; + hasOutputSchema: boolean; + } +): string | null { + const lines: string[] = []; + + if (config.maxTurns !== null) { + lines.push(`${CONSTRAINTS_MAX_TURNS_LABEL}: ${config.maxTurns}`); + } + + if (config.restrictTools) { + const allowed = + config.allowedTools.length > 0 ? config.allowedTools.join(', ') : CONSTRAINTS_NO_TOOLS_VALUE; + lines.push(`${CONSTRAINTS_ALLOWED_TOOLS_LABEL}: ${allowed}`); + } + + if (config.hasOutputSchema) { + lines.push(`${CONSTRAINTS_OUTPUT_SCHEMA_LABEL}: ${CONSTRAINTS_OUTPUT_SCHEMA_VALUE}`); + } + + if (options.sdkSessionId) { + lines.push(`${CONSTRAINTS_SESSION_ID_LABEL}: ${options.sdkSessionId}`); + } + + if (lines.length === 0) { + return null; + } + + return `## ${CONSTRAINTS_SECTION_TITLE}\n${lines.map((line) => `- ${line}`).join('\n')}`; +} + +async function writeOutputSchemaFile( + cwd: string, + outputFormat?: ExecuteOptions['outputFormat'] +): Promise { + if (!outputFormat || outputFormat.type !== 'json_schema') { + return null; + } + if (!outputFormat.schema || typeof outputFormat.schema !== 'object') { + throw new Error('Codex output schema must be a JSON object.'); + } + + const schemaDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR); + await secureFs.mkdir(schemaDir, { recursive: true }); + const schemaPath = path.join(schemaDir, OUTPUT_SCHEMA_FILENAME); + const schemaContent = JSON.stringify(outputFormat.schema, null, OUTPUT_SCHEMA_INDENT_SPACES); + await secureFs.writeFile(schemaPath, schemaContent, TEXT_ENCODING); + return schemaPath; +} + +type ImageBlock = { + type: 'image'; + source: { + type: string; + media_type: string; + data: string; + }; +}; + +function extractImageBlocks(prompt: ExecuteOptions['prompt']): ImageBlock[] { + if (typeof prompt === 'string') { + return []; + } + if (!Array.isArray(prompt)) { + return []; + } + + const images: ImageBlock[] = []; + for (const block of prompt) { + if ( + block && + typeof block === 'object' && + 'type' in block && + block.type === 'image' && + 'source' in block && + block.source && + typeof block.source === 'object' && + 'data' in block.source && + 'media_type' in block.source + ) { + images.push(block as ImageBlock); + } + } + return images; +} + +async function writeImageFiles(cwd: string, imageBlocks: ImageBlock[]): Promise { + if (imageBlocks.length === 0) { + return []; + } + + const imageDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); + await secureFs.mkdir(imageDir, { recursive: true }); + + const imagePaths: string[] = []; + for (let i = 0; i < imageBlocks.length; i++) { + const imageBlock = imageBlocks[i]; + const imageName = `${IMAGE_FILE_PREFIX}${Date.now()}-${i}${IMAGE_FILE_EXT}`; + const imagePath = path.join(imageDir, imageName); + + // Convert base64 to buffer + const imageData = Buffer.from(imageBlock.source.data, 'base64'); + await secureFs.writeFile(imagePath, imageData); + imagePaths.push(imagePath); + } + + return imagePaths; +} + +function normalizeIdentifier(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function getIdentifierFromRecord( + record: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const id = normalizeIdentifier(record[key]); + if (id) { + return id; + } + } + return null; +} + +function getItemIdentifier( + event: Record, + item: Record +): string | null { + return ( + getIdentifierFromRecord(item, ITEM_ID_KEYS) ?? getIdentifierFromRecord(event, EVENT_ID_KEYS) + ); +} + +class CodexToolUseTracker { + private readonly toolUseIdsByItem = new Map(); + private readonly anonymousToolUses: string[] = []; + private sequence = 0; + + register(event: Record, item: Record): string { + const itemId = getItemIdentifier(event, item); + const toolUseId = this.nextToolUseId(); + if (itemId) { + this.toolUseIdsByItem.set(itemId, toolUseId); + } else { + this.anonymousToolUses.push(toolUseId); + } + return toolUseId; + } + + resolve(event: Record, item: Record): string | null { + const itemId = getItemIdentifier(event, item); + if (itemId) { + const toolUseId = this.toolUseIdsByItem.get(itemId); + if (toolUseId) { + this.toolUseIdsByItem.delete(itemId); + return toolUseId; + } + } + + if (this.anonymousToolUses.length > 0) { + return this.anonymousToolUses.shift() || null; + } + + return null; + } + + private nextToolUseId(): string { + this.sequence += 1; + return `${TOOL_USE_ID_PREFIX}${this.sequence}`; + } +} + +type CodexCliSettings = { + autoLoadAgents: boolean; + sandboxMode: CodexSandboxMode; + approvalPolicy: CodexApprovalPolicy; + enableWebSearch: boolean; + enableImages: boolean; + additionalDirs: string[]; + threadId?: string; +}; + +function getCodexSettingsDir(): string { + const configured = getDataDirectory() ?? process.env.DATA_DIR; + return configured ? path.resolve(configured) : path.resolve(CODEX_SETTINGS_DIR_FALLBACK); +} + +async function loadCodexCliSettings( + overrides?: ExecuteOptions['codexSettings'] +): Promise { + const defaults: CodexCliSettings = { + autoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, + sandboxMode: DEFAULT_CODEX_SANDBOX_MODE, + approvalPolicy: DEFAULT_CODEX_APPROVAL_POLICY, + enableWebSearch: false, + enableImages: true, + additionalDirs: [], + threadId: undefined, + }; + + try { + const settingsService = new SettingsService(getCodexSettingsDir()); + const settings = await settingsService.getGlobalSettings(); + const resolved: CodexCliSettings = { + autoLoadAgents: settings.codexAutoLoadAgents ?? defaults.autoLoadAgents, + sandboxMode: settings.codexSandboxMode ?? defaults.sandboxMode, + approvalPolicy: settings.codexApprovalPolicy ?? defaults.approvalPolicy, + enableWebSearch: settings.codexEnableWebSearch ?? defaults.enableWebSearch, + enableImages: settings.codexEnableImages ?? defaults.enableImages, + additionalDirs: settings.codexAdditionalDirs ?? defaults.additionalDirs, + threadId: settings.codexThreadId, + }; + + if (!overrides) { + return resolved; + } + + return { + autoLoadAgents: overrides.autoLoadAgents ?? resolved.autoLoadAgents, + sandboxMode: overrides.sandboxMode ?? resolved.sandboxMode, + approvalPolicy: overrides.approvalPolicy ?? resolved.approvalPolicy, + enableWebSearch: overrides.enableWebSearch ?? resolved.enableWebSearch, + enableImages: overrides.enableImages ?? resolved.enableImages, + additionalDirs: overrides.additionalDirs ?? resolved.additionalDirs, + threadId: overrides.threadId ?? resolved.threadId, + }; + } catch { + return { + autoLoadAgents: overrides?.autoLoadAgents ?? defaults.autoLoadAgents, + sandboxMode: overrides?.sandboxMode ?? defaults.sandboxMode, + approvalPolicy: overrides?.approvalPolicy ?? defaults.approvalPolicy, + enableWebSearch: overrides?.enableWebSearch ?? defaults.enableWebSearch, + enableImages: overrides?.enableImages ?? defaults.enableImages, + additionalDirs: overrides?.additionalDirs ?? defaults.additionalDirs, + threadId: overrides?.threadId ?? defaults.threadId, + }; + } +} + +function buildCodexInstructionsPrompt( + filePath: string, + content: string, + sourceLabel: string +): string { + return `## ${CODEX_INSTRUCTIONS_SECTION}\n**${CODEX_INSTRUCTIONS_SOURCE_LABEL}:** ${sourceLabel}\n**${CODEX_INSTRUCTIONS_PATH_LABEL}:** \`${filePath}\`\n\n${content}`; +} + +async function readCodexInstructionFile(filePath: string): Promise { + try { + const raw = await secureFs.readFile(filePath, TEXT_ENCODING); + const content = String(raw).trim(); + return content ? content : null; + } catch { + return null; + } +} + +async function loadCodexInstructions(cwd: string, enabled: boolean): Promise { + if (!enabled) { + return null; + } + + const sources: Array<{ path: string; content: string; sourceLabel: string }> = []; + const userInstructionsPath = path.join(getCodexConfigDir(), CODEX_USER_INSTRUCTIONS_FILE); + const userContent = await readCodexInstructionFile(userInstructionsPath); + if (userContent) { + sources.push({ + path: userInstructionsPath, + content: userContent, + sourceLabel: CODEX_INSTRUCTIONS_USER_SOURCE, + }); + } + + for (const fileName of CODEX_PROJECT_INSTRUCTIONS_FILES) { + const projectPath = path.join(cwd, CODEX_INSTRUCTIONS_DIR, fileName); + const projectContent = await readCodexInstructionFile(projectPath); + if (projectContent) { + sources.push({ + path: projectPath, + content: projectContent, + sourceLabel: CODEX_INSTRUCTIONS_PROJECT_SOURCE, + }); + } + } + + if (sources.length === 0) { + return null; + } + + const seen = new Set(); + const uniqueSources = sources.filter((source) => { + const normalized = source.content.trim(); + if (seen.has(normalized)) { + return false; + } + seen.add(normalized); + return true; + }); + + return uniqueSources + .map((source) => buildCodexInstructionsPrompt(source.path, source.content, source.sourceLabel)) + .join('\n\n'); +} + +const logger = createLogger('CodexProvider'); + +export class CodexProvider extends BaseProvider { + getName(): string { + return 'codex'; + } + + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // Validate that model doesn't have a provider prefix (except codex- which should already be stripped) + // AgentService should strip prefixes before passing to providers + validateBareModelId(options.model, 'CodexProvider', 'codex'); + + try { + const mcpServers = options.mcpServers ?? {}; + const hasMcpServers = Object.keys(mcpServers).length > 0; + const codexSettings = await loadCodexCliSettings(options.codexSettings); + const codexInstructions = await loadCodexInstructions( + options.cwd, + codexSettings.autoLoadAgents + ); + const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt); + const resolvedMaxTurns = resolveMaxTurns(options.maxTurns); + if (resolvedMaxTurns === null && options.maxTurns === undefined) { + logger.warn( + `[executeQuery] maxTurns not provided — Codex CLI will use its internal default. ` + + `This may cause premature completion. Model: ${options.model}` + ); + } else { + logger.info( + `[executeQuery] maxTurns: requested=${options.maxTurns}, resolved=${resolvedMaxTurns}, model=${options.model}` + ); + } + const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS); + const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false; + const wantsOutputSchema = Boolean( + options.outputFormat && options.outputFormat.type === 'json_schema' + ); + const constraintsPrompt = buildCodexConstraintsPrompt(options, { + allowedTools: resolvedAllowedTools, + restrictTools, + maxTurns: resolvedMaxTurns, + hasOutputSchema: wantsOutputSchema, + }); + const systemPromptParts = [codexInstructions, baseSystemPrompt, constraintsPrompt].filter( + (part): part is string => Boolean(part) + ); + const combinedSystemPrompt = systemPromptParts.length + ? systemPromptParts.join(SYSTEM_PROMPT_SEPARATOR) + : null; + + const executionPlan = await resolveCodexExecutionPlan(options); + if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) { + const cleanupEnv = executionPlan.openAiApiKey + ? createTempEnvOverride({ [OPENAI_API_KEY_ENV]: executionPlan.openAiApiKey }) + : null; + try { + yield* executeCodexSdkQuery(options, combinedSystemPrompt); + } finally { + cleanupEnv?.(); + } + return; + } + + if (hasMcpServers) { + const configManager = new CodexConfigManager(); + await configManager.configureMcpServers(options.cwd, options.mcpServers!); + } + + const toolUseTracker = new CodexToolUseTracker(); + const sandboxCheck = checkSandboxCompatibility( + options.cwd, + codexSettings.sandboxMode !== 'danger-full-access' + ); + if (!sandboxCheck.enabled && sandboxCheck.message) { + console.warn(`[CodexProvider] ${sandboxCheck.message}`); + } + const searchEnabled = + codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); + const isResumeQuery = Boolean(options.sdkSessionId); + const schemaPath = isResumeQuery + ? null + : await writeOutputSchemaFile(options.cwd, options.outputFormat); + const imageBlocks = + !isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; + const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks); + const approvalPolicy = + hasMcpServers && options.mcpAutoApproveTools !== undefined + ? options.mcpAutoApproveTools + ? 'never' + : 'on-request' + : codexSettings.approvalPolicy; + const promptText = isResumeQuery + ? buildResumePrompt(options) + : buildCombinedPrompt(options, combinedSystemPrompt); + const commandPath = executionPlan.cliPath || CODEX_COMMAND; + + // Build config overrides for max turns and reasoning effort + const overrides: Array<{ key: string; value: string | number | boolean }> = []; + if (resolvedMaxTurns !== null) { + overrides.push({ key: CONFIG_KEY_MAX_TURNS, value: resolvedMaxTurns }); + } + + // Add reasoning effort if model supports it and reasoningEffort is specified + if ( + options.reasoningEffort && + supportsReasoningEffort(options.model) && + options.reasoningEffort !== 'none' + ) { + overrides.push({ key: CODEX_REASONING_EFFORT_KEY, value: options.reasoningEffort }); + } + + // Add approval policy + overrides.push({ key: 'approval_policy', value: approvalPolicy }); + + // Add web search if enabled + if (searchEnabled) { + overrides.push({ key: 'features.web_search_request', value: true }); + } + + const configOverrideArgs = buildConfigOverrides(overrides); + const preExecArgs: string[] = []; + + // Add additional directories with write access + if ( + !isResumeQuery && + codexSettings.additionalDirs && + codexSettings.additionalDirs.length > 0 + ) { + for (const dir of codexSettings.additionalDirs) { + preExecArgs.push(CODEX_ADD_DIR_FLAG, dir); + } + } + + // If images were written to disk, add the image directory so the CLI can access them. + // Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient. + if (imagePaths.length > 0) { + const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); + preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir); + } + + // Model is already bare (no prefix) - validated by executeQuery + const codexCommand = isResumeQuery + ? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND] + : [CODEX_EXEC_SUBCOMMAND]; + + const args = [ + ...codexCommand, + CODEX_YOLO_FLAG, + CODEX_SKIP_GIT_REPO_CHECK_FLAG, + ...preExecArgs, + CODEX_MODEL_FLAG, + options.model, + CODEX_JSON_FLAG, + ...configOverrideArgs, + ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []), + ...(options.sdkSessionId ? [options.sdkSessionId] : []), + '-', // Read prompt from stdin to avoid shell escaping issues + ]; + + const envOverrides = buildEnv(); + if (executionPlan.openAiApiKey && !envOverrides[OPENAI_API_KEY_ENV]) { + envOverrides[OPENAI_API_KEY_ENV] = executionPlan.openAiApiKey; + } + + // Calculate dynamic timeout based on reasoning effort. + // Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time + // for the model to generate reasoning tokens before producing output. + // This fixes GitHub issue #530 where features would get stuck with reasoning models. + // + // For feature generation with 'xhigh', use the extended 5-minute base timeout + // since generating 50+ features takes significantly longer than normal operations. + const baseTimeout = + options.reasoningEffort === 'xhigh' + ? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS + : CODEX_CLI_TIMEOUT_MS; + const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout); + + const stream = spawnJSONLProcess({ + command: commandPath, + args, + cwd: options.cwd, + env: envOverrides, + abortController: options.abortController, + timeout, + stdinData: promptText, // Pass prompt via stdin + }); + + for await (const rawEvent of stream) { + const event = rawEvent as Record; + const eventType = getEventType(event); + + // Track thread/session ID from events + const threadId = event.thread_id; + if (threadId && typeof threadId === 'string') { + this._lastSessionId = threadId; + } + + if (eventType === CODEX_EVENT_TYPES.error) { + const errorText = extractText(event.error ?? event.message) || 'Codex CLI error'; + + // Enhance error message with helpful context + let enhancedError = errorText; + const errorLower = errorText.toLowerCase(); + if (errorLower.includes('rate limit')) { + enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`; + } else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) { + enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`; + } else if ( + errorLower.includes('model does not exist') || + errorLower.includes('requested model does not exist') || + errorLower.includes('do not have access') || + errorLower.includes('model_not_found') || + errorLower.includes('invalid_model') + ) { + enhancedError = + `${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` + + `See https://platform.openai.com/docs/models for available models. ` + + `Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`; + } else if ( + errorLower.includes('stream disconnected') || + errorLower.includes('stream ended') || + errorLower.includes('connection reset') + ) { + enhancedError = + `${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` + + `- Network instability\n` + + `- The model not being available on your plan\n` + + `- Server-side timeouts for long-running requests\n` + + `Try again, or switch to a different model.`; + } else if ( + errorLower.includes('command not found') || + errorLower.includes('is not recognized as an internal or external command') + ) { + enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`; + } + + console.error('[CodexProvider] CLI error event:', { errorText, event }); + yield { type: 'error', error: enhancedError }; + continue; + } + + if (eventType === CODEX_EVENT_TYPES.turnCompleted) { + const resultText = extractText(event.result) || undefined; + yield { type: 'result', subtype: 'success', result: resultText }; + continue; + } + + if (!eventType) { + const fallbackText = extractText(event); + if (fallbackText) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: fallbackText }], + }, + }; + } + continue; + } + + const item = (event.item ?? {}) as Record; + const itemType = extractItemType(item); + + if ( + eventType === CODEX_EVENT_TYPES.itemStarted && + itemType === CODEX_ITEM_TYPES.commandExecution + ) { + const commandText = extractCommandText(item) || ''; + const tool = resolveCodexToolCall(commandText); + const toolUseId = toolUseTracker.register(event, item); + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: tool.name, + input: tool.input, + tool_use_id: toolUseId, + }, + ], + }, + }; + continue; + } + + if (eventType === CODEX_EVENT_TYPES.itemUpdated && itemType === CODEX_ITEM_TYPES.todoList) { + const todos = extractCodexTodoItems(item); + if (todos) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: getCodexTodoToolName(), + input: { todos }, + }, + ], + }, + }; + } else { + const todoText = extractText(item) || ''; + const formatted = todoText ? `Updated TODO list:\n${todoText}` : 'Updated TODO list'; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: formatted }], + }, + }; + } + continue; + } + + if (eventType === CODEX_EVENT_TYPES.itemCompleted) { + if (itemType === CODEX_ITEM_TYPES.reasoning) { + const thinkingText = extractText(item) || ''; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'thinking', thinking: thinkingText }], + }, + }; + continue; + } + + if (itemType === CODEX_ITEM_TYPES.commandExecution) { + const commandOutput = + extractCommandOutput(item) ?? extractCommandText(item) ?? extractText(item) ?? ''; + if (commandOutput) { + const toolUseId = toolUseTracker.resolve(event, item); + const toolResultBlock: { + type: 'tool_result'; + content: string; + tool_use_id?: string; + } = { type: 'tool_result', content: commandOutput }; + if (toolUseId) { + toolResultBlock.tool_use_id = toolUseId; + } + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [toolResultBlock], + }, + }; + } + continue; + } + + const text = extractText(item) || extractText(event); + if (text) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + }; + } + } + } + } catch (error) { + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + const enhancedMessage = errorInfo.isRateLimit + ? `${userMessage}\n\nTip: If you're rate limited, try reducing concurrent tasks or waiting a few minutes.` + : userMessage; + + console.error('[CodexProvider] executeQuery() error:', { + type: errorInfo.type, + message: errorInfo.message, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: error instanceof Error ? error.stack : undefined, + }); + + yield { type: 'error', error: enhancedMessage }; + } + } + + async detectInstallation(): Promise { + const cliPath = await findCodexCliPath(); + const hasApiKey = Boolean(await resolveOpenAiApiKey()); + const installed = !!cliPath; + + let version = ''; + if (installed) { + try { + const result = await spawnProcess({ + command: cliPath || CODEX_COMMAND, + args: [CODEX_VERSION_FLAG], + cwd: process.cwd(), + }); + version = result.stdout.trim(); + } catch { + version = ''; + } + } + + // Determine auth status - always verify with CLI, never assume authenticated + const authCheck = await checkCodexAuthentication(cliPath); + const authenticated = authCheck.authenticated; + + return { + installed, + path: cliPath || undefined, + version: version || undefined, + method: 'cli' as const, // Installation method + hasApiKey, + authenticated, + }; + } + + getAvailableModels(): ModelDefinition[] { + // Return all available Codex/OpenAI models + return CODEX_MODELS; + } + + /** + * Check authentication status for Codex CLI + */ + async checkAuth(): Promise { + const cliPath = await findCodexCliPath(); + const hasApiKey = Boolean(await resolveOpenAiApiKey()); + const authIndicators = await getCodexAuthIndicators(); + + // Check for API key in environment + if (hasApiKey) { + return { authenticated: true, method: 'api_key' }; + } + + // Check for OAuth/token from Codex CLI + if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) { + return { authenticated: true, method: 'oauth' }; + } + + // CLI is installed but not authenticated via indicators - try CLI command + if (cliPath) { + try { + // Try 'codex login status' first (same as checkCodexAuthentication) + const result = await spawnProcess({ + command: cliPath || CODEX_COMMAND, + args: ['login', 'status'], + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'dumb', + }, + }); + + // Check both stdout and stderr - Codex CLI outputs to stderr + const combinedOutput = (result.stdout + result.stderr).toLowerCase(); + const isLoggedIn = combinedOutput.includes('logged in'); + + if (result.exitCode === 0 && isLoggedIn) { + return { authenticated: true, method: 'oauth' }; + } + } catch (error) { + logger.warn('Error running login status command during auth check:', error); + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + async getCliPath(): Promise { + const path = await findCodexCliPath(); + return path || null; + } + + /** + * Get the last CLI session ID (for tracking across queries) + * This can be used to resume sessions in subsequent requests + */ + getLastSessionId(): string | null { + return this._lastSessionId ?? null; + } + + /** + * Set a session ID to use for CLI session resumption + */ + setSessionId(sessionId: string | null): void { + this._lastSessionId = sessionId; + } + + private _lastSessionId: string | null = null; +} diff --git a/jules_branch/apps/server/src/providers/codex-sdk-client.ts b/jules_branch/apps/server/src/providers/codex-sdk-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc885c7219f14f8ef9fff8dfeb40b2ab78e85622 --- /dev/null +++ b/jules_branch/apps/server/src/providers/codex-sdk-client.ts @@ -0,0 +1,222 @@ +/** + * Codex SDK client - Executes Codex queries via official @openai/codex-sdk + * + * Used for programmatic control of Codex from within the application. + * Provides cleaner integration than spawning CLI processes. + */ + +import { Codex } from '@openai/codex-sdk'; +import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils'; +import { supportsReasoningEffort } from '@automaker/types'; +import type { ExecuteOptions, ProviderMessage } from './types.js'; + +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const SDK_HISTORY_HEADER = 'Current request:\n'; +const DEFAULT_RESPONSE_TEXT = ''; +const SDK_ERROR_DETAILS_LABEL = 'Details:'; + +type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; +const SDK_REASONING_EFFORTS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); + +type PromptBlock = { + type: string; + text?: string; + source?: { + type?: string; + media_type?: string; + data?: string; + }; +}; + +function resolveApiKey(): string { + const apiKey = process.env[OPENAI_API_KEY_ENV]; + if (!apiKey) { + throw new Error('OPENAI_API_KEY is not set.'); + } + return apiKey; +} + +function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] { + if (Array.isArray(prompt)) { + return prompt as PromptBlock[]; + } + return [{ type: 'text', text: prompt }]; +} + +function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string { + const historyText = + options.conversationHistory && options.conversationHistory.length > 0 + ? formatHistoryAsText(options.conversationHistory) + : ''; + + const promptBlocks = normalizePromptBlocks(options.prompt); + const promptTexts: string[] = []; + + for (const block of promptBlocks) { + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + promptTexts.push(block.text); + } + } + + const promptContent = promptTexts.join('\n\n'); + if (!promptContent.trim()) { + throw new Error('Codex SDK prompt is empty.'); + } + + const parts: string[] = []; + if (systemPrompt) { + parts.push(`System: ${systemPrompt}`); + } + if (historyText) { + parts.push(historyText); + } + parts.push(`${SDK_HISTORY_HEADER}${promptContent}`); + + return parts.join('\n\n'); +} + +function buildSdkErrorMessage(rawMessage: string, userMessage: string): string { + if (!rawMessage) { + return userMessage; + } + if (!userMessage || rawMessage === userMessage) { + return rawMessage; + } + return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`; +} + +/** + * Execute a query using the official Codex SDK + * + * The SDK provides a cleaner interface than spawning CLI processes: + * - Handles authentication automatically + * - Provides TypeScript types + * - Supports thread management and resumption + * - Better error handling + */ +export async function* executeCodexSdkQuery( + options: ExecuteOptions, + systemPrompt: string | null +): AsyncGenerator { + try { + const apiKey = resolveApiKey(); + const codex = new Codex({ apiKey }); + + // Build thread options with model + // The model must be passed to startThread/resumeThread so the SDK + // knows which model to use for the conversation. Without this, + // the SDK may use a default model that the user doesn't have access to. + const threadOptions: { + model?: string; + modelReasoningEffort?: SdkReasoningEffort; + } = {}; + + if (options.model) { + threadOptions.model = options.model; + } + + // Add reasoning effort to thread options if model supports it + if ( + options.reasoningEffort && + options.model && + supportsReasoningEffort(options.model) && + options.reasoningEffort !== 'none' && + SDK_REASONING_EFFORTS.has(options.reasoningEffort) + ) { + threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort; + } + + // Resume existing thread or start new one + let thread; + if (options.sdkSessionId) { + try { + thread = codex.resumeThread(options.sdkSessionId, threadOptions); + } catch { + // If resume fails, start a new thread + thread = codex.startThread(threadOptions); + } + } else { + thread = codex.startThread(threadOptions); + } + + const promptText = buildPromptText(options, systemPrompt); + + // Build run options + const runOptions: { + signal?: AbortSignal; + } = { + signal: options.abortController?.signal, + }; + + // Run the query + const result = await thread.run(promptText, runOptions); + + // Extract response text (from finalResponse property) + const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT; + + // Get thread ID (may be null if not populated yet) + const threadId = thread.id ?? undefined; + + // Yield assistant message + yield { + type: 'assistant', + session_id: threadId, + message: { + role: 'assistant', + content: [{ type: 'text', text: outputText }], + }, + }; + + // Yield result + yield { + type: 'result', + subtype: 'success', + session_id: threadId, + result: outputText, + }; + } catch (error) { + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage); + + // Enhance error messages with actionable tips for common Codex issues + // Normalize inputs to avoid crashes from nullish values + const errorLower = (errorInfo?.message ?? '').toLowerCase(); + const modelLabel = options?.model ?? ''; + + if ( + errorLower.includes('does not exist') || + errorLower.includes('model_not_found') || + errorLower.includes('invalid_model') + ) { + // Model not found - provide helpful guidance + combinedMessage += + `\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` + + `Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` + + `Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`; + } else if ( + errorLower.includes('stream disconnected') || + errorLower.includes('stream ended') || + errorLower.includes('connection reset') || + errorLower.includes('socket hang up') + ) { + // Stream disconnection - provide helpful guidance + combinedMessage += + `\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` + + `- Network instability\n` + + `- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` + + `- Server-side timeouts for long-running requests\n` + + `Try again, or switch to a different model.`; + } + + console.error('[CodexSDK] executeQuery() error during execution:', { + type: errorInfo.type, + message: errorInfo.message, + model: options.model, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: error instanceof Error ? error.stack : undefined, + }); + yield { type: 'error', error: combinedMessage }; + } +} diff --git a/jules_branch/apps/server/src/providers/codex-tool-mapping.ts b/jules_branch/apps/server/src/providers/codex-tool-mapping.ts new file mode 100644 index 0000000000000000000000000000000000000000..f951e0f0214aa24e4d170ebff8a42fee77a28b31 --- /dev/null +++ b/jules_branch/apps/server/src/providers/codex-tool-mapping.ts @@ -0,0 +1,436 @@ +export type CodexToolResolution = { + name: string; + input: Record; +}; + +export type CodexTodoItem = { + content: string; + status: 'pending' | 'in_progress' | 'completed'; + activeForm?: string; +}; + +const TOOL_NAME_BASH = 'Bash'; +const TOOL_NAME_READ = 'Read'; +const TOOL_NAME_EDIT = 'Edit'; +const TOOL_NAME_WRITE = 'Write'; +const TOOL_NAME_GREP = 'Grep'; +const TOOL_NAME_GLOB = 'Glob'; +const TOOL_NAME_TODO = 'TodoWrite'; +const TOOL_NAME_DELETE = 'Delete'; +const TOOL_NAME_LS = 'Ls'; + +const INPUT_KEY_COMMAND = 'command'; +const INPUT_KEY_FILE_PATH = 'file_path'; +const INPUT_KEY_PATTERN = 'pattern'; + +const SHELL_WRAPPER_PATTERNS = [ + /^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/, + /^bash\s+-lc\s+["']([\s\S]+)["']$/, + /^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/, + /^sh\s+-lc\s+["']([\s\S]+)["']$/, + /^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i, + /^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, + /^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, +] as const; + +const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/; +const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const; +const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']); +const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']); +const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']); +const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']); +const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']); +const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']); +const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']); +const APPLY_PATCH_COMMAND = 'apply_patch'; +const APPLY_PATCH_PATTERN = /\bapply_patch\b/; +const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/; +const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']); +const PERL_IN_PLACE_FLAG = /-.*i/; +const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']); +const SEARCH_VALUE_FLAGS = new Set([ + '-g', + '--glob', + '--iglob', + '--type', + '--type-add', + '--type-clear', + '--encoding', +]); +const SEARCH_FILE_LIST_FLAGS = new Set(['--files']); +const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?[ x~])\]\s*)?(?.+)$/; +const TODO_STATUS_COMPLETED = 'completed'; +const TODO_STATUS_IN_PROGRESS = 'in_progress'; +const TODO_STATUS_PENDING = 'pending'; +const PATCH_FILE_MARKERS = [ + '*** Update File: ', + '*** Add File: ', + '*** Delete File: ', + '*** Move to: ', +] as const; + +function stripShellWrapper(command: string): string { + const trimmed = command.trim(); + for (const pattern of SHELL_WRAPPER_PATTERNS) { + const match = trimmed.match(pattern); + if (match && match[1]) { + return unescapeCommand(match[1].trim()); + } + } + return trimmed; +} + +function unescapeCommand(command: string): string { + return command.replace(/\\(["'])/g, '$1'); +} + +function extractPrimarySegment(command: string): string { + const segments = command + .split(COMMAND_SEPARATOR_PATTERN) + .map((segment) => segment.trim()) + .filter(Boolean); + + for (const segment of segments) { + const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix)); + if (!shouldSkip) { + return segment; + } + } + + return command.trim(); +} + +function tokenizeCommand(command: string): string[] { + const tokens: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let isEscaped = false; + + for (const char of command) { + if (isEscaped) { + current += char; + isEscaped = false; + continue; + } + + if (char === '\\') { + isEscaped = true; + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +function stripWrapperTokens(tokens: string[]): string[] { + let index = 0; + while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) { + index += 1; + } + return tokens.slice(index); +} + +function extractFilePathFromTokens(tokens: string[]): string | null { + const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-')); + if (candidates.length === 0) return null; + return candidates[candidates.length - 1]; +} + +function extractSearchPattern(tokens: string[]): string | null { + const remaining = tokens.slice(1); + + for (let index = 0; index < remaining.length; index += 1) { + const token = remaining[index]; + if (token === '--') { + return remaining[index + 1] ?? null; + } + if (SEARCH_PATTERN_FLAGS.has(token)) { + return remaining[index + 1] ?? null; + } + if (SEARCH_VALUE_FLAGS.has(token)) { + index += 1; + continue; + } + if (token.startsWith('-')) { + continue; + } + return token; + } + + return null; +} + +function extractTeeTarget(tokens: string[]): string | null { + const teeIndex = tokens.findIndex((token) => token === 'tee'); + if (teeIndex < 0) return null; + const candidate = tokens[teeIndex + 1]; + return candidate && !candidate.startsWith('-') ? candidate : null; +} + +function extractRedirectionTarget(command: string): string | null { + const match = command.match(REDIRECTION_TARGET_PATTERN); + return match?.[1] ?? null; +} + +function extractFilePathFromDeleteTokens(tokens: string[]): string | null { + // rm file.txt or rm /path/to/file.txt + // Skip flags and get the first non-flag argument + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i]; + if (token && !token.startsWith('-')) { + return token; + } + } + return null; +} + +function hasSedInPlaceFlag(tokens: string[]): boolean { + return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i')); +} + +function hasPerlInPlaceFlag(tokens: string[]): boolean { + return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token)); +} + +function extractPatchFilePath(command: string): string | null { + for (const marker of PATCH_FILE_MARKERS) { + const index = command.indexOf(marker); + if (index < 0) continue; + const start = index + marker.length; + const end = command.indexOf('\n', start); + const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim(); + if (rawPath) return rawPath; + } + return null; +} + +function buildInputWithFilePath(filePath: string | null): Record { + return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {}; +} + +function buildInputWithPattern(pattern: string | null): Record { + return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {}; +} + +export function resolveCodexToolCall(command: string): CodexToolResolution { + const normalized = stripShellWrapper(command); + const primarySegment = extractPrimarySegment(normalized); + const tokens = stripWrapperTokens(tokenizeCommand(primarySegment)); + const commandToken = tokens[0]?.toLowerCase() ?? ''; + + const redirectionTarget = extractRedirectionTarget(primarySegment); + if (redirectionTarget) { + return { + name: TOOL_NAME_WRITE, + input: buildInputWithFilePath(redirectionTarget), + }; + } + + if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractPatchFilePath(primarySegment)), + }; + } + + if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + if (WRITE_COMMANDS.has(commandToken)) { + const filePath = + commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens); + return { + name: TOOL_NAME_WRITE, + input: buildInputWithFilePath(filePath), + }; + } + + if (SEARCH_COMMANDS.has(commandToken)) { + if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) { + return { + name: TOOL_NAME_GLOB, + input: buildInputWithPattern(extractFilePathFromTokens(tokens)), + }; + } + + return { + name: TOOL_NAME_GREP, + input: buildInputWithPattern(extractSearchPattern(tokens)), + }; + } + + // Handle Delete commands (rm, del, erase, remove, unlink) + if (DELETE_COMMANDS.has(commandToken)) { + // Skip if -r or -rf flags (recursive delete should go to Bash) + if ( + tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf') + ) { + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + // Simple file deletion - extract the file path + const filePath = extractFilePathFromDeleteTokens(tokens); + if (filePath) { + return { + name: TOOL_NAME_DELETE, + input: { path: filePath }, + }; + } + // Fall back to bash if we can't determine the file path + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + + // Handle simple Ls commands (just listing, not find/glob) + if (LIST_COMMANDS.has(commandToken)) { + const filePath = extractFilePathFromTokens(tokens); + return { + name: TOOL_NAME_LS, + input: { path: filePath || '.' }, + }; + } + + if (GLOB_COMMANDS.has(commandToken)) { + return { + name: TOOL_NAME_GLOB, + input: buildInputWithPattern(extractFilePathFromTokens(tokens)), + }; + } + + if (READ_COMMANDS.has(commandToken)) { + return { + name: TOOL_NAME_READ, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; +} + +function parseTodoLines(lines: string[]): CodexTodoItem[] { + const todos: CodexTodoItem[] = []; + + for (const line of lines) { + const match = line.match(TODO_LINE_PATTERN); + if (!match?.groups?.content) continue; + + const statusToken = match.groups.status; + const status = + statusToken === 'x' + ? TODO_STATUS_COMPLETED + : statusToken === '~' + ? TODO_STATUS_IN_PROGRESS + : TODO_STATUS_PENDING; + + todos.push({ content: match.groups.content.trim(), status }); + } + + return todos; +} + +function extractTodoFromArray(value: unknown[]): CodexTodoItem[] { + return value + .map((entry) => { + if (typeof entry === 'string') { + return { content: entry, status: TODO_STATUS_PENDING }; + } + if (entry && typeof entry === 'object') { + const record = entry as Record; + const content = + typeof record.content === 'string' + ? record.content + : typeof record.text === 'string' + ? record.text + : typeof record.title === 'string' + ? record.title + : null; + if (!content) return null; + const status = + record.status === TODO_STATUS_COMPLETED || + record.status === TODO_STATUS_IN_PROGRESS || + record.status === TODO_STATUS_PENDING + ? (record.status as CodexTodoItem['status']) + : TODO_STATUS_PENDING; + const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined; + return { content, status, activeForm }; + } + return null; + }) + .filter((item): item is CodexTodoItem => Boolean(item)); +} + +export function extractCodexTodoItems(item: Record): CodexTodoItem[] | null { + const todosValue = item.todos; + if (Array.isArray(todosValue)) { + const todos = extractTodoFromArray(todosValue); + return todos.length > 0 ? todos : null; + } + + const itemsValue = item.items; + if (Array.isArray(itemsValue)) { + const todos = extractTodoFromArray(itemsValue); + return todos.length > 0 ? todos : null; + } + + const textValue = + typeof item.text === 'string' + ? item.text + : typeof item.content === 'string' + ? item.content + : null; + if (!textValue) return null; + + const lines = textValue + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const todos = parseTodoLines(lines); + return todos.length > 0 ? todos : null; +} + +export function getCodexTodoToolName(): string { + return TOOL_NAME_TODO; +} diff --git a/jules_branch/apps/server/src/providers/copilot-provider.ts b/jules_branch/apps/server/src/providers/copilot-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5cc3a7e23e10aa19f60cd8cf977c455ab411d18 --- /dev/null +++ b/jules_branch/apps/server/src/providers/copilot-provider.ts @@ -0,0 +1,1006 @@ +/** + * Copilot Provider - Executes queries using the GitHub Copilot SDK + * + * Uses the official @github/copilot-sdk for: + * - Session management and streaming responses + * - GitHub OAuth authentication (via gh CLI) + * - Tool call handling and permission management + * - Runtime model discovery + * + * Based on https://github.com/github/copilot-sdk + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; +// Note: validateBareModelId is not used because Copilot's bare model IDs +// legitimately contain prefixes like claude-, gemini-, gpt- +import { + COPILOT_MODEL_MAP, + type CopilotAuthStatus, + type CopilotRuntimeModel, +} from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { resolveModelString } from '@automaker/model-resolver'; +import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; +import { + normalizeTodos, + normalizeFilePathInput, + normalizeCommandInput, + normalizePatternInput, +} from './tool-normalization.js'; + +// Create logger for this module +const logger = createLogger('CopilotProvider'); + +// Default bare model (without copilot- prefix) for SDK calls +const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6'; + +// ============================================================================= +// SDK Event Types (from @github/copilot-sdk) +// ============================================================================= + +/** + * SDK session event data types + */ +interface SdkEvent { + type: string; + data?: unknown; +} + +interface SdkMessageEvent extends SdkEvent { + type: 'assistant.message'; + data: { + content: string; + }; +} + +// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise +// The final assistant.message event contains the complete content + +interface SdkToolExecutionStartEvent extends SdkEvent { + type: 'tool.execution_start'; + data: { + toolName: string; + toolCallId: string; + input?: Record; + }; +} + +interface SdkToolExecutionCompleteEvent extends SdkEvent { + type: 'tool.execution_complete'; + data: { + toolCallId: string; + success: boolean; + result?: { + content: string; + }; + error?: { + message: string; + code?: string; + }; + }; +} + +interface SdkSessionErrorEvent extends SdkEvent { + type: 'session.error'; + data: { + message: string; + code?: string; + }; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Prefix for error messages in tool results + * Consistent with GeminiProvider's error formatting + */ +const TOOL_ERROR_PREFIX = '[ERROR]' as const; + +// ============================================================================= +// Error Codes +// ============================================================================= + +export enum CopilotErrorCode { + NOT_INSTALLED = 'COPILOT_NOT_INSTALLED', + NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED', + RATE_LIMITED = 'COPILOT_RATE_LIMITED', + MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'COPILOT_NETWORK_ERROR', + PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED', + TIMEOUT = 'COPILOT_TIMEOUT', + CLI_ERROR = 'COPILOT_CLI_ERROR', + SDK_ERROR = 'COPILOT_SDK_ERROR', + UNKNOWN = 'COPILOT_UNKNOWN_ERROR', +} + +export interface CopilotError extends Error { + code: CopilotErrorCode; + recoverable: boolean; + suggestion?: string; +} + +type CopilotSession = Awaited>; +type CopilotSessionOptions = Parameters[0]; +type ResumableCopilotClient = CopilotClient & { + resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise; +}; + +// ============================================================================= +// Tool Name Normalization +// ============================================================================= + +/** + * Copilot SDK tool name to standard tool name mapping + * + * Maps Copilot CLI tool names to our standard tool names for consistent UI display. + * Tool names are case-insensitive (normalized to lowercase before lookup). + */ +const COPILOT_TOOL_NAME_MAP: Record = { + // File operations + read_file: 'Read', + read: 'Read', + view: 'Read', // Copilot uses 'view' for reading files + read_many_files: 'Read', + write_file: 'Write', + write: 'Write', + create_file: 'Write', + edit_file: 'Edit', + edit: 'Edit', + replace: 'Edit', + patch: 'Edit', + // Shell operations + run_shell: 'Bash', + run_shell_command: 'Bash', + shell: 'Bash', + bash: 'Bash', + execute: 'Bash', + terminal: 'Bash', + // Search operations + search: 'Grep', + grep: 'Grep', + search_file_content: 'Grep', + find_files: 'Glob', + glob: 'Glob', + list_dir: 'Ls', + list_directory: 'Ls', + ls: 'Ls', + // Web operations + web_fetch: 'WebFetch', + fetch: 'WebFetch', + web_search: 'WebSearch', + search_web: 'WebSearch', + google_web_search: 'WebSearch', + // Todo operations + todo_write: 'TodoWrite', + write_todos: 'TodoWrite', + update_todos: 'TodoWrite', + // Planning/intent operations (Copilot-specific) + report_intent: 'ReportIntent', // Keep as-is, it's a planning tool + think: 'Think', + plan: 'Plan', +}; + +/** + * Normalize Copilot tool names to standard tool names + */ +function normalizeCopilotToolName(copilotToolName: string): string { + const lowerName = copilotToolName.toLowerCase(); + return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName; +} + +/** + * Normalize Copilot tool input parameters to standard format + * + * Maps Copilot's parameter names to our standard parameter names. + * Uses shared utilities from tool-normalization.ts for common normalizations. + */ +function normalizeCopilotToolInput( + toolName: string, + input: Record +): Record { + const normalizedName = normalizeCopilotToolName(toolName); + + // Normalize todo_write / write_todos: ensure proper format + if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) { + return { todos: normalizeTodos(input.todos) }; + } + + // Normalize file path parameters for Read/Write/Edit tools + if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') { + return normalizeFilePathInput(input); + } + + // Normalize shell command parameters for Bash tool + if (normalizedName === 'Bash') { + return normalizeCommandInput(input); + } + + // Normalize search parameters for Grep tool + if (normalizedName === 'Grep') { + return normalizePatternInput(input); + } + + return input; +} + +/** + * CopilotProvider - Integrates GitHub Copilot SDK as an AI provider + * + * Features: + * - GitHub OAuth authentication + * - SDK-based session management + * - Runtime model discovery + * - Tool call normalization + * - Per-execution working directory support + */ +export class CopilotProvider extends CliProvider { + private runtimeModels: CopilotRuntimeModel[] | null = null; + + constructor(config: ProviderConfig = {}) { + super(config); + // Trigger CLI detection on construction + this.ensureCliDetected(); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'copilot'; + } + + getCliName(): string { + return 'copilot'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', // Copilot CLI can be run via npx + npxPackage: '@github/copilot', // Official GitHub Copilot CLI package + commonPaths: { + linux: [ + path.join(os.homedir(), '.local/bin/copilot'), + '/usr/local/bin/copilot', + path.join(os.homedir(), '.npm-global/bin/copilot'), + ], + darwin: [ + path.join(os.homedir(), '.local/bin/copilot'), + '/usr/local/bin/copilot', + '/opt/homebrew/bin/copilot', + path.join(os.homedir(), '.npm-global/bin/copilot'), + ], + win32: [ + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'), + path.join(os.homedir(), '.npm-global', 'copilot.cmd'), + ], + }, + }; + } + + /** + * Extract prompt text from ExecuteOptions + * + * Note: CopilotProvider does not yet support vision/image inputs. + * If non-text content is provided, an error is thrown. + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } else if (Array.isArray(options.prompt)) { + // Check for non-text content (images, etc.) which we don't support yet + const hasNonText = options.prompt.some((p) => p.type !== 'text'); + if (hasNonText) { + throw new Error( + 'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' + + 'Please use text-only prompts or switch to a provider that supports vision.' + ); + } + return options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + } + + /** + * Not used with SDK approach - kept for interface compatibility + */ + buildCliArgs(_options: ExecuteOptions): string[] { + return []; + } + + /** + * Convert SDK event to AutoMaker ProviderMessage format + */ + normalizeEvent(event: unknown): ProviderMessage | null { + const sdkEvent = event as SdkEvent; + + switch (sdkEvent.type) { + case 'assistant.message': { + const messageEvent = sdkEvent as SdkMessageEvent; + return { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: messageEvent.data.content }], + }, + }; + } + + case 'assistant.message_delta': { + // Skip delta events - they create too much noise + // The final assistant.message event has the complete content + return null; + } + + case 'tool.execution_start': { + const toolEvent = sdkEvent as SdkToolExecutionStartEvent; + const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName); + const normalizedInput = toolEvent.data.input + ? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input) + : {}; + + return { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: normalizedName, + tool_use_id: toolEvent.data.toolCallId, + input: normalizedInput, + }, + ], + }, + }; + } + + /** + * Tool execution completed event + * Handles both successful results and errors from tool executions + * Error messages optionally include error codes for better debugging + */ + case 'tool.execution_complete': { + const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent; + const error = toolResultEvent.data.error; + + // Format error message with optional code for better debugging + const content = error + ? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}` + : toolResultEvent.data.result?.content || ''; + + return { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolResultEvent.data.toolCallId, + content, + }, + ], + }, + }; + } + + case 'session.idle': { + logger.debug('Copilot session idle'); + return { + type: 'result', + subtype: 'success', + }; + } + + case 'session.error': { + const errorEvent = sdkEvent as SdkSessionErrorEvent; + const enrichedError = + errorEvent.data.message || + (errorEvent.data.code + ? `Copilot agent error (code: ${errorEvent.data.code})` + : 'Copilot agent error'); + return { + type: 'error', + error: enrichedError, + }; + } + + default: + logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`); + return null; + } + } + + // ========================================================================== + // CliProvider Overrides + // ========================================================================== + + /** + * Override error mapping for Copilot-specific error codes + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') || + lower.includes('login required') || + lower.includes('authentication required') || + lower.includes('github login') + ) { + return { + code: CopilotErrorCode.NOT_AUTHENTICATED, + message: 'GitHub Copilot is not authenticated', + recoverable: true, + suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') || + lower.includes('quota exceeded') + ) { + return { + code: CopilotErrorCode.RATE_LIMITED, + message: 'Copilot API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again', + }; + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') || + lower.includes('model not found') || + (lower.includes('not found') && lower.includes('404')) + ) { + return { + code: CopilotErrorCode.MODEL_UNAVAILABLE, + message: 'Requested model is not available', + recoverable: true, + suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`, + }; + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: CopilotErrorCode.NETWORK_ERROR, + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: CopilotErrorCode.PROCESS_CRASHED, + message: 'Copilot CLI process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + return { + code: CopilotErrorCode.UNKNOWN, + message: stderr || `Copilot CLI exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Override install instructions for Copilot-specific guidance + */ + protected getInstallInstructions(): string { + return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)'; + } + + /** + * Execute a prompt using Copilot SDK with real-time streaming + * + * Creates a new CopilotClient for each execution with the correct working directory. + * Streams tool execution events in real-time for UI display. + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + // Note: We don't use validateBareModelId here because Copilot's model IDs + // legitimately contain prefixes like claude-, gemini-, gpt- which are the + // actual model names from the Copilot CLI. We only need to ensure the + // copilot- prefix has been stripped by the ProviderFactory. + if (options.model?.startsWith('copilot-')) { + throw new Error( + `[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` + + `The ProviderFactory should strip this prefix before passing to the provider.` + ); + } + + if (!this.cliPath) { + throw this.createError( + CopilotErrorCode.NOT_INSTALLED, + 'Copilot CLI is not installed', + true, + this.getInstallInstructions() + ); + } + + const promptText = this.extractPromptText(options); + // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"), + // but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6"). + // Normalize by converting the last dash-separated numeric pair to dot notation. + const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL); + const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2'); + const workingDirectory = options.cwd || process.cwd(); + + logger.debug( + `CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"` + ); + logger.debug(`Prompt length: ${promptText.length} characters`); + + // Create a client for this execution with the correct working directory + const client = new CopilotClient({ + logLevel: 'warning', + autoRestart: false, + cwd: workingDirectory, + }); + + // Use an async queue to bridge callback-based SDK events to async generator + const eventQueue: SdkEvent[] = []; + let resolveWaiting: (() => void) | null = null; + let sessionComplete = false; + let sessionError: Error | null = null; + + const pushEvent = (event: SdkEvent) => { + eventQueue.push(event); + if (resolveWaiting) { + resolveWaiting(); + resolveWaiting = null; + } + }; + + const waitForEvent = (): Promise => { + if (eventQueue.length > 0 || sessionComplete) { + return Promise.resolve(); + } + return new Promise((resolve) => { + resolveWaiting = resolve; + }); + }; + + // Declare session outside try so it's accessible in the catch block for cleanup. + let session: CopilotSession | undefined; + + try { + await client.start(); + logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); + + const sessionOptions: CopilotSessionOptions = { + model: bareModel, + streaming: true, + // AUTONOMOUS MODE: Auto-approve all permission requests. + // AutoMaker is designed for fully autonomous AI agent operation. + // Security boundary is provided by Docker containerization (see CLAUDE.md). + // User is warned about this at app startup. + onPermissionRequest: async ( + request: PermissionRequest + ): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => { + logger.debug(`Permission request: ${request.kind}`); + return { kind: 'approved' }; + }, + }; + + // Resume the previous Copilot session when possible; otherwise create a fresh one. + const resumableClient = client as ResumableCopilotClient; + let sessionResumed = false; + if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') { + try { + session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions); + sessionResumed = true; + logger.debug(`Resumed Copilot session: ${session.sessionId}`); + } catch (resumeError) { + logger.warn( + `Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}` + ); + session = await client.createSession(sessionOptions); + } + } else { + session = await client.createSession(sessionOptions); + } + + // session is always assigned by this point (both branches above assign it) + const activeSession = session!; + const sessionId = activeSession.sessionId; + logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`); + + // Set up event handler to push events to queue + activeSession.on((event: SdkEvent) => { + logger.debug(`SDK event: ${event.type}`); + + if (event.type === 'session.idle') { + sessionComplete = true; + pushEvent(event); + } else if (event.type === 'session.error') { + const errorEvent = event as SdkSessionErrorEvent; + sessionError = new Error(errorEvent.data.message); + sessionComplete = true; + pushEvent(event); + } else { + // Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.) + pushEvent(event); + } + }); + + // Send the prompt (non-blocking) + await activeSession.send({ prompt: promptText }); + + // Process events as they arrive + while (!sessionComplete || eventQueue.length > 0) { + await waitForEvent(); + + // Check for errors first (before processing events to avoid race condition) + if (sessionError) { + await activeSession.destroy(); + await client.stop(); + throw sessionError; + } + + // Process all queued events + while (eventQueue.length > 0) { + const event = eventQueue.shift()!; + const normalized = this.normalizeEvent(event); + if (normalized) { + // Add session_id if not present + if (!normalized.session_id) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } + + // Cleanup + await activeSession.destroy(); + await client.stop(); + logger.debug('CopilotClient stopped successfully'); + } catch (error) { + // Ensure session is destroyed and client is stopped on error to prevent leaks. + // The session may have been created/resumed before the error occurred. + if (session) { + try { + await session.destroy(); + } catch (sessionCleanupError) { + logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`); + } + } + try { + await client.stop(); + } catch (cleanupError) { + // Log but don't throw cleanup errors - the original error is more important + logger.debug(`Failed to stop client during cleanup: ${cleanupError}`); + } + + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; + } + + // Map errors to CopilotError + if (error instanceof Error) { + logger.error(`Copilot SDK error: ${error.message}`); + const errorInfo = this.mapError(error.message, null); + throw this.createError( + errorInfo.code as CopilotErrorCode, + errorInfo.message, + errorInfo.recoverable, + errorInfo.suggestion + ); + } + throw error; + } + } + + // ========================================================================== + // Copilot-Specific Methods + // ========================================================================== + + /** + * Create a CopilotError with details + */ + private createError( + code: CopilotErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): CopilotError { + const error = new Error(message) as CopilotError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'CopilotError'; + return error; + } + + /** + * Get Copilot CLI version + */ + async getVersion(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + * + * Uses GitHub CLI (gh) to check Copilot authentication status. + * The Copilot CLI relies on gh auth for authentication. + */ + async checkAuth(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + logger.debug('checkAuth: CLI not found'); + return { authenticated: false, method: 'none' }; + } + + logger.debug('checkAuth: Starting credential check'); + + // Try to check GitHub CLI authentication status first + // The Copilot CLI uses gh auth for authentication + try { + const ghStatus = execSync('gh auth status --hostname github.com', { + encoding: 'utf8', + timeout: 10000, + stdio: 'pipe', + }); + + logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`); + + // Parse gh auth status output + const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/); + if (loggedInMatch) { + return { + authenticated: true, + method: 'oauth', + login: loggedInMatch[1], + host: 'github.com', + }; + } + + // Check for token auth + if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) { + return { + authenticated: true, + method: 'oauth', + host: 'github.com', + }; + } + } catch (ghError) { + logger.debug(`checkAuth: gh auth status failed: ${ghError}`); + } + + // Try Copilot-specific auth check if gh is not available + try { + const result = execSync(`"${this.cliPath}" auth status`, { + encoding: 'utf8', + timeout: 10000, + stdio: 'pipe', + }); + + logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`); + + if (result.includes('authenticated') || result.includes('logged in')) { + return { + authenticated: true, + method: 'cli', + }; + } + } catch (copilotError) { + logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`); + } + + // Check for GITHUB_TOKEN environment variable + if (process.env.GITHUB_TOKEN) { + logger.debug('checkAuth: Found GITHUB_TOKEN environment variable'); + return { + authenticated: true, + method: 'oauth', + statusMessage: 'Using GITHUB_TOKEN environment variable', + }; + } + + // Check for gh config file + const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml'); + try { + await fs.access(ghConfigPath); + const content = await fs.readFile(ghConfigPath, 'utf8'); + if (content.includes('github.com') && content.includes('oauth_token')) { + logger.debug('checkAuth: Found gh config with oauth_token'); + return { + authenticated: true, + method: 'oauth', + host: 'github.com', + }; + } + } catch { + logger.debug('checkAuth: No gh config found'); + } + + // No credentials found + logger.debug('checkAuth: No valid credentials found'); + return { + authenticated: false, + method: 'none', + error: + 'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.', + }; + } + + /** + * Fetch available models from the CLI at runtime + */ + async fetchRuntimeModels(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + return []; + } + + try { + // Try to list models using the CLI + const result = execSync(`"${this.cliPath}" models list --format json`, { + encoding: 'utf8', + timeout: 15000, + stdio: 'pipe', + }); + + const models = JSON.parse(result) as CopilotRuntimeModel[]; + this.runtimeModels = models; + logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`); + return models; + } catch (error) { + // Clear cache on failure to avoid returning stale data + this.runtimeModels = null; + logger.debug(`Failed to fetch runtime models: ${error}`); + return []; + } + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + authenticated: auth.authenticated, + }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + getCliPath(): string | null { + this.ensureCliDetected(); + return this.cliPath; + } + + /** + * Get available Copilot models + * + * Returns both static model definitions and runtime-discovered models + */ + getAvailableModels(): ModelDefinition[] { + // Start with static model definitions - explicitly typed to allow runtime models + const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map( + ([id, config]) => ({ + id, // Full model ID with copilot- prefix + name: config.label, + modelString: id.replace('copilot-', ''), // Bare model for CLI + provider: 'copilot', + description: config.description, + supportsTools: config.supportsTools, + supportsVision: config.supportsVision, + contextWindow: config.contextWindow, + }) + ); + + // Add runtime models if available (discovered via CLI) + if (this.runtimeModels) { + for (const runtimeModel of this.runtimeModels) { + // Skip if already in static list + const staticId = `copilot-${runtimeModel.id}`; + if (staticModels.some((m) => m.id === staticId)) { + continue; + } + + staticModels.push({ + id: staticId, + name: runtimeModel.name || runtimeModel.id, + modelString: runtimeModel.id, + provider: 'copilot', + description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`, + supportsTools: true, + supportsVision: runtimeModel.capabilities?.supportsVision ?? false, + contextWindow: runtimeModel.capabilities?.maxInputTokens, + }); + } + } + + return staticModels; + } + + /** + * Check if a feature is supported + * + * Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet. + * This may change in future versions of the Copilot SDK. + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming']; + return supported.includes(feature); + } + + /** + * Check if runtime models have been cached + */ + hasCachedModels(): boolean { + return this.runtimeModels !== null && this.runtimeModels.length > 0; + } + + /** + * Clear the runtime model cache + */ + clearModelCache(): void { + this.runtimeModels = null; + logger.debug('Cleared Copilot model cache'); + } + + /** + * Refresh models from CLI and return all available models + */ + async refreshModels(): Promise { + logger.debug('Refreshing Copilot models from CLI'); + await this.fetchRuntimeModels(); + return this.getAvailableModels(); + } +} diff --git a/jules_branch/apps/server/src/providers/cursor-config-manager.ts b/jules_branch/apps/server/src/providers/cursor-config-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b32ceb95b69e435de56a7b31431776d94e0c991 --- /dev/null +++ b/jules_branch/apps/server/src/providers/cursor-config-manager.ts @@ -0,0 +1,197 @@ +/** + * Cursor CLI Configuration Manager + * + * Manages Cursor CLI configuration stored in .automaker/cursor-config.json + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getAllCursorModelIds, type CursorCliConfig, type CursorModelId } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import { getAutomakerDir } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorConfigManager'); + +/** + * Manages Cursor CLI configuration + * Config location: .automaker/cursor-config.json + */ +export class CursorConfigManager { + private configPath: string; + private config: CursorCliConfig; + + constructor(projectPath: string) { + // Use getAutomakerDir for consistent path resolution + this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json'); + this.config = this.loadConfig(); + } + + /** + * Load configuration from disk + */ + private loadConfig(): CursorCliConfig { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf8'); + const parsed = JSON.parse(content) as CursorCliConfig; + logger.debug(`Loaded config from ${this.configPath}`); + return parsed; + } + } catch (error) { + logger.warn('Failed to load config:', error); + } + + // Return default config with all available models + return { + defaultModel: 'cursor-auto', + models: getAllCursorModelIds(), + }; + } + + /** + * Save configuration to disk + */ + private saveConfig(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); + logger.debug('Config saved'); + } catch (error) { + logger.error('Failed to save config:', error); + throw error; + } + } + + /** + * Get the full configuration + */ + getConfig(): CursorCliConfig { + return { ...this.config }; + } + + /** + * Get the default model + */ + getDefaultModel(): CursorModelId { + return this.config.defaultModel || 'cursor-auto'; + } + + /** + * Set the default model + */ + setDefaultModel(model: CursorModelId): void { + this.config.defaultModel = model; + this.saveConfig(); + logger.info(`Default model set to: ${model}`); + } + + /** + * Get enabled models + */ + getEnabledModels(): CursorModelId[] { + return this.config.models || ['cursor-auto']; + } + + /** + * Set enabled models + */ + setEnabledModels(models: CursorModelId[]): void { + this.config.models = models; + this.saveConfig(); + logger.info(`Enabled models updated: ${models.join(', ')}`); + } + + /** + * Add a model to enabled list + */ + addModel(model: CursorModelId): void { + if (!this.config.models) { + this.config.models = []; + } + if (!this.config.models.includes(model)) { + this.config.models.push(model); + this.saveConfig(); + logger.info(`Model added: ${model}`); + } + } + + /** + * Remove a model from enabled list + */ + removeModel(model: CursorModelId): void { + if (this.config.models) { + this.config.models = this.config.models.filter((m) => m !== model); + this.saveConfig(); + logger.info(`Model removed: ${model}`); + } + } + + /** + * Check if a model is enabled + */ + isModelEnabled(model: CursorModelId): boolean { + return this.config.models?.includes(model) ?? false; + } + + /** + * Get MCP server configurations + */ + getMcpServers(): string[] { + return this.config.mcpServers || []; + } + + /** + * Set MCP server configurations + */ + setMcpServers(servers: string[]): void { + this.config.mcpServers = servers; + this.saveConfig(); + logger.info(`MCP servers updated: ${servers.join(', ')}`); + } + + /** + * Get Cursor rules paths + */ + getRules(): string[] { + return this.config.rules || []; + } + + /** + * Set Cursor rules paths + */ + setRules(rules: string[]): void { + this.config.rules = rules; + this.saveConfig(); + logger.info(`Rules updated: ${rules.join(', ')}`); + } + + /** + * Reset configuration to defaults + */ + reset(): void { + this.config = { + defaultModel: 'cursor-auto', + models: getAllCursorModelIds(), + }; + this.saveConfig(); + logger.info('Config reset to defaults'); + } + + /** + * Check if config file exists + */ + exists(): boolean { + return fs.existsSync(this.configPath); + } + + /** + * Get the config file path + */ + getConfigPath(): string { + return this.configPath; + } +} diff --git a/jules_branch/apps/server/src/providers/cursor-provider.ts b/jules_branch/apps/server/src/providers/cursor-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..3903ea648c10625c82d425eb644a58baf1902bb0 --- /dev/null +++ b/jules_branch/apps/server/src/providers/cursor-provider.ts @@ -0,0 +1,1258 @@ +/** + * Cursor Provider - Executes queries using cursor-agent CLI + * + * Extends CliProvider with Cursor-specific: + * - Event normalization for Cursor's JSONL format + * - Text block deduplication (Cursor sends duplicates) + * - Session ID tracking + * - Versions directory detection + * + * Spawns the cursor-agent CLI with --output-format stream-json for streaming responses. + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { findCliInWsl, isWslAvailable } from '@automaker/platform'; +import { + CliProvider, + type CliSpawnConfig, + type CliDetectionResult, + type CliErrorInfo, +} from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, +} from './types.js'; +import { validateBareModelId } from '@automaker/types'; +import { validateApiKey } from '../lib/auth-utils.js'; +import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js'; +import { + type CursorStreamEvent, + type CursorSystemEvent, + type CursorAssistantEvent, + type CursorToolCallEvent, + type CursorResultEvent, + type CursorAuthStatus, + CURSOR_MODEL_MAP, +} from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { spawnJSONLProcess, execInWsl } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorProvider'); + +// ============================================================================= +// Cursor Tool Handler Registry +// ============================================================================= + +/** + * Tool handler definition for mapping Cursor tool calls to normalized format + */ +interface CursorToolHandler { + /** The normalized tool name (e.g., 'Read', 'Write') */ + name: string; + /** Extract and normalize input from Cursor's args format */ + mapInput: (args: TArgs) => unknown; + /** Format the result content for display (optional) */ + formatResult?: (result: TResult, args?: TArgs) => string; + /** Format rejected result (optional) */ + formatRejected?: (reason: string) => string; +} + +/** + * Registry of Cursor tool handlers + * Each handler knows how to normalize its specific tool call type + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters +const CURSOR_TOOL_HANDLERS: Record> = { + readToolCall: { + name: 'Read', + mapInput: (args: { path: string }) => ({ file_path: args.path }), + formatResult: (result: { content: string }) => result.content, + }, + + writeToolCall: { + name: 'Write', + mapInput: (args: { path: string; fileText: string }) => ({ + file_path: args.path, + content: args.fileText, + }), + formatResult: (result: { linesCreated: number; path: string }) => + `Wrote ${result.linesCreated} lines to ${result.path}`, + }, + + editToolCall: { + name: 'Edit', + mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({ + file_path: args.path, + old_string: args.oldText, + new_string: args.newText, + }), + formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`, + }, + + shellToolCall: { + name: 'Bash', + mapInput: (args: { command: string }) => ({ command: args.command }), + formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => { + let content = `Exit code: ${result.exitCode}`; + if (result.stdout) content += `\n${result.stdout}`; + if (result.stderr) content += `\nStderr: ${result.stderr}`; + return content; + }, + formatRejected: (reason: string) => `Rejected: ${reason}`, + }, + + deleteToolCall: { + name: 'Delete', + mapInput: (args: { path: string }) => ({ file_path: args.path }), + formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`, + formatRejected: (reason: string) => `Delete rejected: ${reason}`, + }, + + grepToolCall: { + name: 'Grep', + mapInput: (args: { pattern: string; path?: string }) => ({ + pattern: args.pattern, + path: args.path, + }), + formatResult: (result: { matchedLines: number }) => + `Found ${result.matchedLines} matching lines`, + }, + + lsToolCall: { + name: 'Ls', + mapInput: (args: { path: string }) => ({ path: args.path }), + formatResult: (result: { childrenFiles: number; childrenDirs: number }) => + `Found ${result.childrenFiles} files, ${result.childrenDirs} directories`, + }, + + globToolCall: { + name: 'Glob', + mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({ + pattern: args.globPattern, + path: args.targetDirectory, + }), + formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`, + }, + + semSearchToolCall: { + name: 'SemanticSearch', + mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({ + query: args.query, + targetDirectories: args.targetDirectories, + explanation: args.explanation, + }), + formatResult: (result: { results: string; codeResults?: unknown[] }) => { + const resultCount = result.codeResults?.length || 0; + return resultCount > 0 + ? `Found ${resultCount} semantic search result(s)` + : result.results || 'No results found'; + }, + }, + + readLintsToolCall: { + name: 'ReadLints', + mapInput: (args: { paths: string[] }) => ({ paths: args.paths }), + formatResult: (result: { totalDiagnostics: number; totalFiles: number }) => + `Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`, + }, +}; + +/** + * Process a Cursor tool call using the handler registry + * Returns { toolName, toolInput } or null if tool type is unknown + */ +function processCursorToolCall( + toolCall: CursorToolCallEvent['tool_call'] +): { toolName: string; toolInput: unknown } | null { + // Check each registered handler + for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { + const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined; + if (toolData) { + // Skip if args not yet populated (partial streaming event) + if (!toolData.args) return null; + return { + toolName: handler.name, + toolInput: handler.mapInput(toolData.args), + }; + } + } + + // Handle generic function call (fallback) + if (toolCall.function) { + let toolInput: unknown; + try { + toolInput = JSON.parse(toolCall.function.arguments || '{}'); + } catch { + toolInput = { raw: toolCall.function.arguments }; + } + return { + toolName: toolCall.function.name, + toolInput, + }; + } + + return null; +} + +/** + * Format the result content for a completed Cursor tool call + */ +function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string { + for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { + const toolData = toolCall[key as keyof typeof toolCall] as + | { + args?: unknown; + result?: { success?: unknown; rejected?: { reason: string } }; + } + | undefined; + + if (toolData?.result) { + if (toolData.result.success && handler.formatResult) { + return handler.formatResult(toolData.result.success, toolData.args); + } + if (toolData.result.rejected && handler.formatRejected) { + return handler.formatRejected(toolData.result.rejected.reason); + } + } + } + + return ''; +} + +// ============================================================================= +// Error Codes +// ============================================================================= + +/** + * Cursor-specific error codes for detailed error handling + */ +export enum CursorErrorCode { + NOT_INSTALLED = 'CURSOR_NOT_INSTALLED', + NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED', + RATE_LIMITED = 'CURSOR_RATE_LIMITED', + MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'CURSOR_NETWORK_ERROR', + PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED', + TIMEOUT = 'CURSOR_TIMEOUT', + UNKNOWN = 'CURSOR_UNKNOWN_ERROR', +} + +export interface CursorError extends Error { + code: CursorErrorCode; + recoverable: boolean; + suggestion?: string; +} + +/** + * CursorProvider - Integrates cursor-agent CLI as an AI provider + * + * Extends CliProvider with Cursor-specific behavior: + * - WSL required on Windows (cursor-agent has no native Windows build) + * - Versions directory detection for cursor-agent installations + * - Session ID tracking for conversation continuity + * - Text block deduplication (Cursor sends duplicate chunks) + */ +export class CursorProvider extends CliProvider { + /** + * Version data directory where cursor-agent stores versions + * The install script creates versioned folders like: + * ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent + */ + private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions'); + + constructor(config: ProviderConfig = {}) { + super(config); + // Trigger CLI detection on construction (eager for Cursor) + this.ensureCliDetected(); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'cursor'; + } + + getCliName(): string { + return 'cursor-agent'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'direct', + commonPaths: { + linux: [ + path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location + '/usr/local/bin/cursor-agent', + ], + darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'], + win32: [ + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'Cursor', + 'resources', + 'app', + 'bin', + 'cursor-agent.exe' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'Cursor', + 'resources', + 'app', + 'bin', + 'cursor-agent.cmd' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'Cursor', + 'resources', + 'app', + 'bin', + 'cursor.exe' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'Cursor', + 'cursor.exe' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'cursor', + 'resources', + 'app', + 'bin', + 'cursor-agent.exe' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'cursor', + 'resources', + 'app', + 'bin', + 'cursor-agent.cmd' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'cursor', + 'resources', + 'app', + 'bin', + 'cursor.exe' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'Programs', + 'cursor', + 'cursor.exe' + ), + path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + 'npm', + 'cursor-agent.cmd' + ), + path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + 'npm', + 'cursor.cmd' + ), + path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + '.npm-global', + 'bin', + 'cursor-agent.cmd' + ), + path.join( + process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), + '.npm-global', + 'bin', + 'cursor.cmd' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'pnpm', + 'cursor-agent.cmd' + ), + path.join( + process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), + 'pnpm', + 'cursor.cmd' + ), + ], + }, + }; + } + + /** + * Extract prompt text from ExecuteOptions + * Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } else if (Array.isArray(options.prompt)) { + return options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + } + + buildCliArgs(options: ExecuteOptions): string[] { + // Model is already bare (no prefix) - validated by executeQuery + const model = options.model || 'auto'; + + // Build CLI arguments for cursor-agent + // NOTE: Prompt is NOT included here - it's passed via stdin to avoid + // shell escaping issues when content contains $(), backticks, etc. + const cliArgs: string[] = []; + + // If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand + if (this.cliPath && !this.cliPath.includes('cursor-agent')) { + cliArgs.push('agent'); + } + + cliArgs.push( + '-p', // Print mode (non-interactive) + '--output-format', + 'stream-json', + '--stream-partial-output' // Real-time streaming + ); + + // In read-only mode, use --mode ask for Q&A style (no tools) + // Otherwise, add --force to allow file edits + if (options.readOnly) { + cliArgs.push('--mode', 'ask'); + } else { + cliArgs.push('--force'); + } + + // Add model if not auto + if (model !== 'auto') { + cliArgs.push('--model', model); + } + + // Resume an existing chat when a provider session ID is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + + // Use '-' to indicate reading prompt from stdin + cliArgs.push('-'); + + return cliArgs; + } + + /** + * Convert Cursor event to AutoMaker ProviderMessage format + * Made public as required by CliProvider abstract method + */ + normalizeEvent(event: unknown): ProviderMessage | null { + const cursorEvent = event as CursorStreamEvent; + + switch (cursorEvent.type) { + case 'system': + // System init - we capture session_id but don't yield a message + return null; + + case 'user': + // User message - already handled by caller + return null; + + case 'assistant': { + const assistantEvent = cursorEvent as CursorAssistantEvent; + return { + type: 'assistant', + session_id: assistantEvent.session_id, + message: { + role: 'assistant', + content: assistantEvent.message.content.map((c) => ({ + type: 'text' as const, + text: c.text, + })), + }, + }; + } + + case 'tool_call': { + const toolEvent = cursorEvent as CursorToolCallEvent; + const toolCall = toolEvent.tool_call; + + // Use the tool handler registry to process the tool call + const processed = processCursorToolCall(toolCall); + if (!processed) { + // Log unrecognized tool call structure for debugging + const toolCallKeys = Object.keys(toolCall); + logger.warn( + `[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` + + `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}` + ); + return null; + } + + const { toolName, toolInput } = processed; + + // For started events, emit tool_use + if (toolEvent.subtype === 'started') { + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: toolName, + tool_use_id: toolEvent.call_id, + input: toolInput, + }, + ], + }, + }; + } + + // For completed events, emit both tool_use and tool_result + if (toolEvent.subtype === 'completed') { + const resultContent = formatCursorToolResult(toolCall); + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: toolName, + tool_use_id: toolEvent.call_id, + input: toolInput, + }, + { + type: 'tool_result', + tool_use_id: toolEvent.call_id, + content: resultContent, + }, + ], + }, + }; + } + + return null; + } + + case 'result': { + const resultEvent = cursorEvent as CursorResultEvent; + + if (resultEvent.is_error) { + const errorText = resultEvent.error || resultEvent.result || ''; + const enrichedError = + errorText || + `Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`; + return { + type: 'error', + session_id: resultEvent.session_id, + error: enrichedError, + }; + } + + return { + type: 'result', + subtype: 'success', + session_id: resultEvent.session_id, + result: resultEvent.result, + }; + } + + default: + return null; + } + } + + // ========================================================================== + // CliProvider Overrides + // ========================================================================== + + /** + * Override CLI detection to add Cursor-specific checks: + * 1. Versions directory for cursor-agent installations + * 2. Cursor IDE with 'cursor agent' subcommand support + */ + protected detectCli(): CliDetectionResult { + if (process.platform === 'win32') { + const findInPath = (command: string): string | null => { + try { + const result = execSync(`where ${command}`, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }) + .trim() + .split(/\r?\n/)[0]; + + if (result && fs.existsSync(result)) { + return result; + } + } catch { + // Not in PATH + } + + return null; + }; + + const isCursorAgentBinary = (cliPath: string) => + cliPath.toLowerCase().includes('cursor-agent'); + + const supportsCursorAgentSubcommand = (cliPath: string) => { + try { + execSync(`"${cliPath}" agent --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + windowsHide: true, + }); + return true; + } catch { + return false; + } + }; + + const pathResult = findInPath('cursor-agent') || findInPath('cursor'); + if (pathResult) { + if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) { + return { + cliPath: pathResult, + useWsl: false, + strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct', + }; + } + } + + const config = this.getSpawnConfig(); + for (const candidate of config.commonPaths.win32 || []) { + const resolved = candidate; + if (!fs.existsSync(resolved)) { + continue; + } + if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) { + return { + cliPath: resolved, + useWsl: false, + strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct', + }; + } + } + + const wslLogger = (msg: string) => logger.debug(msg); + if (isWslAvailable({ logger: wslLogger })) { + const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger }); + if (wslResult) { + logger.debug( + `Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}` + ); + return { + cliPath: 'wsl.exe', + useWsl: true, + wslCliPath: wslResult.wslPath, + wslDistribution: wslResult.distribution, + strategy: 'wsl', + }; + } + } + + logger.debug('cursor-agent not found on Windows'); + return { cliPath: null, useWsl: false, strategy: 'direct' }; + } + + // First try standard detection (PATH, common paths, WSL) + const result = super.detectCli(); + if (result.cliPath) { + return result; + } + + // Cursor-specific: Check versions directory for any installed version + // This handles cases where cursor-agent is installed but not in PATH + if (fs.existsSync(CursorProvider.VERSIONS_DIR)) { + try { + const versions = fs + .readdirSync(CursorProvider.VERSIONS_DIR) + .filter((v) => !v.startsWith('.')) + .sort() + .reverse(); // Most recent first + + for (const version of versions) { + const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent'); + if (fs.existsSync(versionPath)) { + logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`); + return { + cliPath: versionPath, + useWsl: false, + strategy: 'native', + }; + } + } + } catch { + // Ignore directory read errors + } + } + + // If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand + // The Cursor IDE includes the agent as a subcommand: cursor agent + const cursorPaths = [ + '/usr/bin/cursor', + '/usr/local/bin/cursor', + path.join(os.homedir(), '.local/bin/cursor'), + '/opt/cursor/cursor', + ]; + + for (const cursorPath of cursorPaths) { + if (fs.existsSync(cursorPath)) { + // Verify cursor agent subcommand works + try { + execSync(`"${cursorPath}" agent --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }); + logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`); + // Return cursor path but we'll use 'cursor agent' subcommand + return { + cliPath: cursorPath, + useWsl: false, + strategy: 'native', + }; + } catch { + // cursor agent subcommand doesn't work, try next path + } + } + } + + return result; + } + + /** + * Override error mapping for Cursor-specific error codes + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') + ) { + return { + code: CursorErrorCode.NOT_AUTHENTICATED, + message: 'Cursor CLI is not authenticated', + recoverable: true, + suggestion: 'Run "cursor-agent login" to authenticate with your browser', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') + ) { + return { + code: CursorErrorCode.RATE_LIMITED, + message: 'Cursor API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again, or upgrade to Cursor Pro', + }; + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') + ) { + return { + code: CursorErrorCode.MODEL_UNAVAILABLE, + message: 'Requested model is not available', + recoverable: true, + suggestion: 'Try using "auto" mode or select a different model', + }; + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: CursorErrorCode.NETWORK_ERROR, + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: CursorErrorCode.PROCESS_CRASHED, + message: 'Cursor agent process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + return { + code: CursorErrorCode.UNKNOWN, + message: stderr || `Cursor agent exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Override install instructions for Cursor-specific guidance + */ + protected getInstallInstructions(): string { + if (process.platform === 'win32') { + return 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash'; + } + return 'Install with: curl https://cursor.com/install -fsS | bash'; + } + + /** + * Execute a prompt using Cursor CLI with streaming + * + * Overrides base class to add: + * - Session ID tracking from system init events + * - Text block deduplication (Cursor sends duplicate chunks) + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + // Validate that model doesn't have a provider prefix (except cursor- which should already be stripped) + // AgentService should strip prefixes before passing to providers + // Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-" + validateBareModelId(options.model, 'CursorProvider', 'cursor'); + + if (!this.cliPath) { + throw this.createError( + CursorErrorCode.NOT_INSTALLED, + 'Cursor CLI is not installed', + true, + this.getInstallInstructions() + ); + } + + // MCP servers are not yet supported by Cursor CLI - log warning but continue + if (options.mcpServers && Object.keys(options.mcpServers).length > 0) { + const serverCount = Object.keys(options.mcpServers).length; + logger.warn( + `MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` + + `MCP support for Cursor will be added in a future release. ` + + `The configured MCP servers will be ignored for this execution.` + ); + } + + // Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages) + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + // Extract prompt text to pass via stdin (avoids shell escaping issues) + const promptText = this.extractPromptText(effectiveOptions); + + const cliArgs = this.buildCliArgs(effectiveOptions); + const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); + + // Pass prompt via stdin to avoid shell interpretation of special characters + // like $(), backticks, etc. that may appear in file content + subprocessOptions.stdinData = promptText; + + let sessionId: string | undefined; + + // Dedup state for Cursor-specific text block handling + let lastTextBlock = ''; + let accumulatedText = ''; + + logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`); + + // Get effective permissions for this project and detect the active profile + const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd()); + const activeProfile = detectProfile(effectivePermissions); + logger.debug( + `Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}` + ); + + // Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled + const debugRawEvents = + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; + + try { + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const event = rawEvent as CursorStreamEvent; + + // Log raw event for debugging + if (debugRawEvents) { + const subtype = 'subtype' in event ? (event.subtype as string) : 'none'; + logger.info(`[RAW EVENT] type=${event.type} subtype=${subtype}`); + if (event.type === 'tool_call') { + const toolEvent = event as CursorToolCallEvent; + const tc = toolEvent.tool_call; + const toolTypes = + [ + tc.readToolCall && 'read', + tc.writeToolCall && 'write', + tc.editToolCall && 'edit', + tc.shellToolCall && 'shell', + tc.deleteToolCall && 'delete', + tc.grepToolCall && 'grep', + tc.lsToolCall && 'ls', + tc.globToolCall && 'glob', + tc.function && `function:${tc.function.name}`, + ] + .filter(Boolean) + .join(',') || 'unknown'; + logger.info( + `[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` + + (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') + + (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '') + ); + } + } + + // Capture session ID from system init + if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') { + sessionId = event.session_id; + logger.debug(`Session started: ${sessionId}`); + } + + // Normalize and yield the event + const normalized = this.normalizeEvent(event); + if (!normalized && debugRawEvents) { + logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`); + } + if (normalized) { + // Ensure session_id is always set + if (!normalized.session_id && sessionId) { + normalized.session_id = sessionId; + } + + // Apply Cursor-specific dedup for assistant text messages + if (normalized.type === 'assistant' && normalized.message?.content) { + const dedupedContent = this.deduplicateTextBlocks( + normalized.message.content, + lastTextBlock, + accumulatedText + ); + + if (dedupedContent.content.length === 0) { + // All blocks were duplicates, skip this message + continue; + } + + // Update state + lastTextBlock = dedupedContent.lastBlock; + accumulatedText = dedupedContent.accumulated; + + // Update the message with deduped content + normalized.message.content = dedupedContent.content; + } + + yield normalized; + } + } + } catch (error) { + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; + } + + // Map CLI errors to CursorError + if (error instanceof Error && 'stderr' in error) { + const errorInfo = this.mapError( + (error as { stderr?: string }).stderr || error.message, + (error as { exitCode?: number | null }).exitCode ?? null + ); + throw this.createError( + errorInfo.code as CursorErrorCode, + errorInfo.message, + errorInfo.recoverable, + errorInfo.suggestion + ); + } + throw error; + } + } + + // ========================================================================== + // Cursor-Specific Methods + // ========================================================================== + + /** + * Create a CursorError with details + */ + private createError( + code: CursorErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): CursorError { + const error = new Error(message) as CursorError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'CursorError'; + return error; + } + + /** + * Deduplicate text blocks in Cursor assistant messages + * + * Cursor often sends: + * 1. Duplicate consecutive text blocks (same text twice in a row) + * 2. A final accumulated block containing ALL previous text + * + * This method filters out these duplicates to prevent UI stuttering. + */ + private deduplicateTextBlocks( + content: ContentBlock[], + lastTextBlock: string, + accumulatedText: string + ): { content: ContentBlock[]; lastBlock: string; accumulated: string } { + const filtered: ContentBlock[] = []; + let newLastBlock = lastTextBlock; + let newAccumulated = accumulatedText; + + for (const block of content) { + if (block.type !== 'text' || !block.text) { + filtered.push(block); + continue; + } + + const text = block.text; + + // Skip empty text + if (!text.trim()) continue; + + // Skip duplicate consecutive text blocks + if (text === newLastBlock) { + continue; + } + + // Skip final accumulated text block + // Cursor sends one large block containing ALL previous text at the end + if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) { + const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim(); + const normalizedNew = text.replace(/\s+/g, ' ').trim(); + if (normalizedNew.includes(normalizedAccum.slice(0, 100))) { + // This is the final accumulated block, skip it + continue; + } + } + + // This is a valid new text block + newLastBlock = text; + newAccumulated += text; + filtered.push(block); + } + + return { + content: filtered, + lastBlock: newLastBlock, + accumulated: newAccumulated, + }; + } + + /** + * Get Cursor CLI version + */ + async getVersion(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) return null; + + try { + if (this.useWsl && this.wslCliPath) { + const result = execInWsl(`${this.wslCliPath} --version`, { + timeout: 5000, + distribution: this.wslDistribution, + }); + return result; + } + + // If using Cursor IDE, use 'cursor agent --version' + const versionCmd = this.cliPath.includes('cursor-agent') + ? `"${this.cliPath}" --version` + : `"${this.cliPath}" agent --version`; + + const result = execSync(versionCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + */ + async checkAuth(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + return { authenticated: false, method: 'none' }; + } + + // Check for API key in environment with validation + if (process.env.CURSOR_API_KEY) { + const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor'); + if (!validation.isValid) { + logger.warn('Cursor API key validation failed:', validation.error); + return { authenticated: false, method: 'api_key', error: validation.error }; + } + return { authenticated: true, method: 'api_key' }; + } + + // For WSL mode, check credentials inside WSL + if (this.useWsl && this.wslCliPath) { + const wslOpts = { timeout: 5000, distribution: this.wslDistribution }; + + // Check for credentials file inside WSL + const wslCredPaths = [ + '$HOME/.cursor/credentials.json', + '$HOME/.config/cursor/credentials.json', + ]; + + for (const credPath of wslCredPaths) { + const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts); + if (content && content.trim()) { + try { + const creds = JSON.parse(content); + if (creds.accessToken || creds.token) { + return { authenticated: true, method: 'login', hasCredentialsFile: true }; + } + } catch { + // Invalid credentials file + } + } + } + + // Try running --version to check if CLI works + const versionResult = execInWsl(`${this.wslCliPath} --version`, { + timeout: 10000, + distribution: this.wslDistribution, + }); + if (versionResult) { + return { authenticated: true, method: 'login' }; + } + + return { authenticated: false, method: 'none' }; + } + + // Native mode (Linux/macOS) - check local credentials + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + ]; + + for (const credPath of credentialPaths) { + if (fs.existsSync(credPath)) { + try { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken || creds.token) { + return { authenticated: true, method: 'login', hasCredentialsFile: true }; + } + } catch { + // Invalid credentials file + } + } + } + + // Try running a simple command to check auth + try { + execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env }, + }); + return { authenticated: true, method: 'login' }; + } catch (error: unknown) { + const execError = error as { stderr?: string }; + if (execError.stderr?.includes('not authenticated') || execError.stderr?.includes('log in')) { + return { authenticated: false, method: 'none' }; + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + // Determine the display path - for WSL, show the WSL path with distribution + const displayPath = + this.useWsl && this.wslCliPath + ? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}` + : this.cliPath || undefined; + + return { + installed, + version: version || undefined, + path: displayPath, + method: this.useWsl ? 'wsl' : 'cli', + hasApiKey: !!process.env.CURSOR_API_KEY, + authenticated: auth.authenticated, + }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + getCliPath(): string | null { + this.ensureCliDetected(); + return this.cliPath; + } + + /** + * Get available Cursor models + */ + getAvailableModels(): ModelDefinition[] { + return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({ + id: `cursor-${id}`, + name: config.label, + modelString: id, + provider: 'cursor', + description: config.description, + supportsTools: true, + supportsVision: config.supportsVision, + })); + } + + /** + * Check if a feature is supported + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming']; + return supported.includes(feature); + } +} diff --git a/jules_branch/apps/server/src/providers/gemini-provider.ts b/jules_branch/apps/server/src/providers/gemini-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..9723de45a00ba5037163cd0315f9f31335b3a518 --- /dev/null +++ b/jules_branch/apps/server/src/providers/gemini-provider.ts @@ -0,0 +1,900 @@ +/** + * Gemini Provider - Executes queries using the Gemini CLI + * + * Extends CliProvider with Gemini-specific: + * - Event normalization for Gemini's JSONL streaming format + * - Google account and API key authentication support + * - Thinking level configuration + * + * Based on https://github.com/google-gemini/gemini-cli + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; +import { validateBareModelId } from '@automaker/types'; +import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform'; +import { normalizeTodos } from './tool-normalization.js'; + +// Create logger for this module +const logger = createLogger('GeminiProvider'); + +// ============================================================================= +// Gemini Stream Event Types +// ============================================================================= + +/** + * Base event structure from Gemini CLI --output-format stream-json + * + * Actual CLI output format: + * {"type":"init","timestamp":"...","session_id":"...","model":"..."} + * {"type":"message","timestamp":"...","role":"user","content":"..."} + * {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true} + * {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}} + * {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."} + * {"type":"result","timestamp":"...","status":"success","stats":{...}} + */ +interface GeminiStreamEvent { + type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error'; + timestamp?: string; + session_id?: string; +} + +interface GeminiInitEvent extends GeminiStreamEvent { + type: 'init'; + session_id: string; + model: string; +} + +interface GeminiMessageEvent extends GeminiStreamEvent { + type: 'message'; + role: 'user' | 'assistant'; + content: string; + delta?: boolean; + session_id?: string; +} + +interface GeminiToolUseEvent extends GeminiStreamEvent { + type: 'tool_use'; + tool_id: string; + tool_name: string; + parameters: Record; + session_id?: string; +} + +interface GeminiToolResultEvent extends GeminiStreamEvent { + type: 'tool_result'; + tool_id: string; + status: 'success' | 'error'; + output: string; + session_id?: string; +} + +interface GeminiResultEvent extends GeminiStreamEvent { + type: 'result'; + status: 'success' | 'error'; + stats?: { + total_tokens?: number; + input_tokens?: number; + output_tokens?: number; + cached?: number; + input?: number; + duration_ms?: number; + tool_calls?: number; + }; + error?: string; + session_id?: string; +} + +// ============================================================================= +// Error Codes +// ============================================================================= + +export enum GeminiErrorCode { + NOT_INSTALLED = 'GEMINI_NOT_INSTALLED', + NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED', + RATE_LIMITED = 'GEMINI_RATE_LIMITED', + MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'GEMINI_NETWORK_ERROR', + PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED', + TIMEOUT = 'GEMINI_TIMEOUT', + UNKNOWN = 'GEMINI_UNKNOWN_ERROR', +} + +export interface GeminiError extends Error { + code: GeminiErrorCode; + recoverable: boolean; + suggestion?: string; +} + +// ============================================================================= +// Tool Name Normalization +// ============================================================================= + +/** + * Gemini CLI tool name to standard tool name mapping + * This allows the UI to properly categorize and display Gemini tool calls + */ +const GEMINI_TOOL_NAME_MAP: Record = { + write_todos: 'TodoWrite', + read_file: 'Read', + read_many_files: 'Read', + replace: 'Edit', + write_file: 'Write', + run_shell_command: 'Bash', + search_file_content: 'Grep', + glob: 'Glob', + list_directory: 'Ls', + web_fetch: 'WebFetch', + google_web_search: 'WebSearch', +}; + +/** + * Normalize Gemini tool names to standard tool names + */ +function normalizeGeminiToolName(geminiToolName: string): string { + return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName; +} + +/** + * Normalize Gemini tool input parameters to standard format + * + * Uses shared normalizeTodos utility for consistent todo normalization. + * + * Gemini `write_todos` format: + * {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]} + * + * Claude `TodoWrite` format: + * {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]} + */ +function normalizeGeminiToolInput( + toolName: string, + input: Record +): Record { + // Normalize write_todos using shared utility + if (toolName === 'write_todos' && Array.isArray(input.todos)) { + return { todos: normalizeTodos(input.todos) }; + } + return input; +} + +/** + * GeminiProvider - Integrates Gemini CLI as an AI provider + * + * Features: + * - Google account OAuth login support + * - API key authentication (GEMINI_API_KEY) + * - Vertex AI support + * - Thinking level configuration + * - Streaming JSON output + */ +export class GeminiProvider extends CliProvider { + constructor(config: ProviderConfig = {}) { + super(config); + // Trigger CLI detection on construction + this.ensureCliDetected(); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'gemini'; + } + + getCliName(): string { + return 'gemini'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', // Gemini CLI can be run via npx + npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package + commonPaths: { + linux: [ + path.join(os.homedir(), '.local/bin/gemini'), + '/usr/local/bin/gemini', + path.join(os.homedir(), '.npm-global/bin/gemini'), + ], + darwin: [ + path.join(os.homedir(), '.local/bin/gemini'), + '/usr/local/bin/gemini', + '/opt/homebrew/bin/gemini', + path.join(os.homedir(), '.npm-global/bin/gemini'), + ], + win32: [ + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), '.npm-global', 'gemini.cmd'), + ], + }, + }; + } + + /** + * Extract prompt text from ExecuteOptions + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } else if (Array.isArray(options.prompt)) { + return options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + } + + buildCliArgs(options: ExecuteOptions): string[] { + // Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash') + // We need to add 'gemini-' back since it's part of the actual CLI model name + const bareModel = options.model || '2.5-flash'; + const cliArgs: string[] = []; + + // Streaming JSON output format for real-time updates + cliArgs.push('--output-format', 'stream-json'); + + // Model selection - Gemini CLI expects full model names like "gemini-2.5-flash" + // Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI + // the 'gemini-' is part of the actual model name Google expects + if (bareModel && bareModel !== 'auto') { + // Add gemini- prefix if not already present (handles edge cases) + const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`; + cliArgs.push('--model', cliModel); + } + + // Disable sandbox mode for faster execution (sandbox adds overhead) + cliArgs.push('--sandbox', 'false'); + + // YOLO mode for automatic approval (required for non-interactive use) + // Use explicit approval-mode for clearer semantics + cliArgs.push('--approval-mode', 'yolo'); + + // Force headless (non-interactive) mode with --prompt flag. + // The actual prompt content is passed via stdin (see buildSubprocessOptions()), + // but we MUST include -p to trigger headless mode. Without it, Gemini CLI + // starts in interactive mode which adds significant startup overhead + // (interactive REPL setup, extra context loading, etc.). + // Per Gemini CLI docs: stdin content is "appended to" the -p value. + cliArgs.push('--prompt', ''); + + // Explicitly include the working directory in allowed workspace directories + // This ensures Gemini CLI allows file operations in the project directory, + // even if it has a different workspace cached from a previous session + if (options.cwd) { + cliArgs.push('--include-directories', options.cwd); + } + + // Resume an existing Gemini session when one is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + + // Note: Gemini CLI doesn't have a --thinking-level flag. + // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). + // The model handles thinking internally based on the task complexity. + + return cliArgs; + } + + /** + * Convert Gemini event to AutoMaker ProviderMessage format + */ + normalizeEvent(event: unknown): ProviderMessage | null { + const geminiEvent = event as GeminiStreamEvent; + + switch (geminiEvent.type) { + case 'init': { + // Init event - capture session but don't yield a message + const initEvent = geminiEvent as GeminiInitEvent; + logger.debug( + `Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}` + ); + return null; + } + + case 'message': { + const messageEvent = geminiEvent as GeminiMessageEvent; + + // Skip user messages - already handled by caller + if (messageEvent.role === 'user') { + return null; + } + + // Handle assistant messages + if (messageEvent.role === 'assistant') { + return { + type: 'assistant', + session_id: messageEvent.session_id, + message: { + role: 'assistant', + content: [{ type: 'text', text: messageEvent.content }], + }, + }; + } + + return null; + } + + case 'tool_use': { + const toolEvent = geminiEvent as GeminiToolUseEvent; + const normalizedName = normalizeGeminiToolName(toolEvent.tool_name); + const normalizedInput = normalizeGeminiToolInput( + toolEvent.tool_name, + toolEvent.parameters as Record + ); + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: normalizedName, + tool_use_id: toolEvent.tool_id, + input: normalizedInput, + }, + ], + }, + }; + } + + case 'tool_result': { + const toolResultEvent = geminiEvent as GeminiToolResultEvent; + // If tool result is an error, prefix with error indicator + const content = + toolResultEvent.status === 'error' + ? `[ERROR] ${toolResultEvent.output}` + : toolResultEvent.output; + return { + type: 'assistant', + session_id: toolResultEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolResultEvent.tool_id, + content, + }, + ], + }, + }; + } + + case 'result': { + const resultEvent = geminiEvent as GeminiResultEvent; + + if (resultEvent.status === 'error') { + const enrichedError = + resultEvent.error || + `Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`; + return { + type: 'error', + session_id: resultEvent.session_id, + error: enrichedError, + }; + } + + // Success result - include stats for logging + logger.debug( + `Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}` + ); + return { + type: 'result', + subtype: 'success', + session_id: resultEvent.session_id, + }; + } + + case 'error': { + const errorEvent = geminiEvent as GeminiResultEvent; + const enrichedError = + errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`; + return { + type: 'error', + session_id: errorEvent.session_id, + error: enrichedError, + }; + } + + default: + logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`); + return null; + } + } + + // ========================================================================== + // CliProvider Overrides + // ========================================================================== + + /** + * Build subprocess options with stdin data for prompt and speed-optimized env vars. + * + * Passes the prompt via stdin instead of --prompt CLI arg to: + * - Avoid shell argument size limits with large prompts (system prompt + context) + * - Avoid shell escaping issues with special characters in prompts + * - Match the pattern used by Cursor, OpenCode, and Codex providers + * + * Also injects environment variables to reduce Gemini CLI startup overhead: + * - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection + */ + protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { + const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); + + // Pass prompt via stdin to avoid shell interpretation of special characters + // and shell argument size limits with large system prompts + context files + subprocessOptions.stdinData = this.extractPromptText(options); + + // Disable telemetry to reduce startup overhead + if (subprocessOptions.env) { + subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false'; + } + + return subprocessOptions; + } + + /** + * Override error mapping for Gemini-specific error codes + */ + protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') || + lower.includes('login required') || + lower.includes('error authenticating') || + lower.includes('loadcodeassist') || + (lower.includes('econnrefused') && lower.includes('8888')) + ) { + return { + code: GeminiErrorCode.NOT_AUTHENTICATED, + message: 'Gemini CLI is not authenticated', + recoverable: true, + suggestion: + 'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable', + }; + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') || + lower.includes('quota exceeded') + ) { + return { + code: GeminiErrorCode.RATE_LIMITED, + message: 'Gemini API rate limit exceeded', + recoverable: true, + suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day', + }; + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') || + lower.includes('modelnotfounderror') || + lower.includes('model not found') || + (lower.includes('not found') && lower.includes('404')) + ) { + return { + code: GeminiErrorCode.MODEL_UNAVAILABLE, + message: 'Requested model is not available', + recoverable: true, + suggestion: 'Try using "gemini-2.5-flash" or select a different model', + }; + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return { + code: GeminiErrorCode.NETWORK_ERROR, + message: 'Network connection error', + recoverable: true, + suggestion: 'Check your internet connection and try again', + }; + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return { + code: GeminiErrorCode.PROCESS_CRASHED, + message: 'Gemini CLI process was terminated', + recoverable: true, + suggestion: 'The process may have run out of memory. Try a simpler task.', + }; + } + + return { + code: GeminiErrorCode.UNKNOWN, + message: stderr || `Gemini CLI exited with code ${exitCode}`, + recoverable: false, + }; + } + + /** + * Override install instructions for Gemini-specific guidance + */ + protected getInstallInstructions(): string { + return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)'; + } + + /** + * Execute a prompt using Gemini CLI with streaming + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + this.ensureCliDetected(); + + // Validate that model doesn't have a provider prefix (except gemini- which should already be stripped) + validateBareModelId(options.model, 'GeminiProvider', 'gemini'); + + if (!this.cliPath) { + throw this.createError( + GeminiErrorCode.NOT_INSTALLED, + 'Gemini CLI is not installed', + true, + this.getInstallInstructions() + ); + } + + // Ensure .geminiignore exists in the working directory to prevent Gemini CLI + // from scanning .git and node_modules directories during startup. This reduces + // startup time significantly (reported: 35s → 11s) by skipping large directories + // that Gemini CLI would otherwise traverse for context discovery. + await this.ensureGeminiIgnore(options.cwd || process.cwd()); + + // Embed system prompt into the user prompt so Gemini CLI receives + // project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would + // otherwise be silently dropped since Gemini CLI has no --system-prompt flag. + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + + // Build CLI args for headless execution. + const cliArgs = this.buildCliArgs(effectiveOptions); + + const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs); + + let sessionId: string | undefined; + + logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`); + + try { + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const event = rawEvent as GeminiStreamEvent; + + // Capture session ID from init event + if (event.type === 'init') { + const initEvent = event as GeminiInitEvent; + sessionId = initEvent.session_id; + logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`); + } + + // Normalize and yield the event + const normalized = this.normalizeEvent(event); + if (normalized) { + if (!normalized.session_id && sessionId) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } catch (error) { + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; + } + + // Map CLI errors to GeminiError + if (error instanceof Error && 'stderr' in error) { + const errorInfo = this.mapError( + (error as { stderr?: string }).stderr || error.message, + (error as { exitCode?: number | null }).exitCode ?? null + ); + throw this.createError( + errorInfo.code as GeminiErrorCode, + errorInfo.message, + errorInfo.recoverable, + errorInfo.suggestion + ); + } + throw error; + } + } + + // ========================================================================== + // Gemini-Specific Methods + // ========================================================================== + + /** + * Ensure a .geminiignore file exists in the working directory. + * + * Gemini CLI scans the working directory for context discovery during startup. + * Excluding .git and node_modules dramatically reduces startup time by preventing + * traversal of large directories (reported improvement: 35s → 11s). + * + * Only creates the file if it doesn't already exist to avoid overwriting user config. + */ + private async ensureGeminiIgnore(cwd: string): Promise { + const ignorePath = path.join(cwd, '.geminiignore'); + const content = [ + '# Auto-generated by Automaker to speed up Gemini CLI startup', + '# Prevents Gemini CLI from scanning large directories during context discovery', + '.git', + 'node_modules', + 'dist', + 'build', + '.next', + '.nuxt', + 'coverage', + '.automaker', + '.worktrees', + '.vscode', + '.idea', + '*.lock', + '', + ].join('\n'); + try { + // Use 'wx' flag for atomic creation - fails if file exists (EEXIST) + await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' }); + logger.debug(`Created .geminiignore at ${ignorePath}`); + } catch (writeError) { + // EEXIST means file already exists - that's fine, preserve user's file + if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') { + logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`); + return; + } + // Non-fatal: startup will just be slower without the ignore file + logger.debug(`Failed to create .geminiignore: ${writeError}`); + } + } + + /** + * Create a GeminiError with details + */ + private createError( + code: GeminiErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): GeminiError { + const error = new Error(message) as GeminiError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'GeminiError'; + return error; + } + + /** + * Get Gemini CLI version + */ + async getVersion(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + * + * Uses a fast credential check approach: + * 1. Check for GEMINI_API_KEY environment variable + * 2. Check for Google Cloud credentials + * 3. Check for Gemini settings file with stored credentials + * 4. Quick CLI auth test with --help (fast, doesn't make API calls) + */ + async checkAuth(): Promise { + this.ensureCliDetected(); + if (!this.cliPath) { + logger.debug('checkAuth: CLI not found'); + return { authenticated: false, method: 'none' }; + } + + logger.debug('checkAuth: Starting credential check'); + + // Determine the likely auth method based on environment + const hasApiKey = !!process.env.GEMINI_API_KEY; + const hasEnvApiKey = hasApiKey; + const hasVertexAi = !!( + process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT + ); + + logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`); + + // Check for Gemini credentials file (~/.gemini/settings.json) + const geminiConfigDir = path.join(os.homedir(), '.gemini'); + const settingsPath = path.join(geminiConfigDir, 'settings.json'); + let hasCredentialsFile = false; + let authType: string | null = null; + + try { + await fs.access(settingsPath); + logger.debug(`checkAuth: Found settings file at ${settingsPath}`); + try { + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + // Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key") + const selectedType = settings?.security?.auth?.selectedType; + if (selectedType) { + hasCredentialsFile = true; + authType = selectedType; + logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`); + } else { + logger.debug(`checkAuth: Settings file found but no auth type configured`); + } + } catch (e) { + logger.debug(`checkAuth: Failed to parse settings file: ${e}`); + } + } catch { + logger.debug('checkAuth: No settings file found'); + } + + // If we have an API key, we're authenticated + if (hasApiKey) { + logger.debug('checkAuth: Using API key authentication'); + return { + authenticated: true, + method: 'api_key', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // If we have Vertex AI credentials, we're authenticated + if (hasVertexAi) { + logger.debug('checkAuth: Using Vertex AI authentication'); + return { + authenticated: true, + method: 'vertex_ai', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // Check if settings file indicates configured authentication + if (hasCredentialsFile && authType) { + // OAuth types: "oauth-personal", "oauth-adc" + // API key type: "api-key" + // Code assist: "code-assist" (requires IDE integration) + if (authType.startsWith('oauth')) { + logger.debug(`checkAuth: OAuth authentication configured (${authType})`); + return { + authenticated: true, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + if (authType === 'api-key') { + logger.debug('checkAuth: API key authentication configured in settings'); + return { + authenticated: true, + method: 'api_key', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + if (authType === 'code-assist' || authType === 'codeassist') { + logger.debug('checkAuth: Code Assist auth configured but requires local server'); + return { + authenticated: false, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + error: + 'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.', + }; + } + + // Unknown auth type but something is configured + logger.debug(`checkAuth: Unknown auth type configured: ${authType}`); + return { + authenticated: true, + method: 'google_login', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + }; + } + + // No credentials found + logger.debug('checkAuth: No valid credentials found'); + return { + authenticated: false, + method: 'none', + hasApiKey, + hasEnvApiKey, + hasCredentialsFile, + error: + 'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.', + }; + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + hasApiKey: !!process.env.GEMINI_API_KEY, + authenticated: auth.authenticated, + }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + getCliPath(): string | null { + this.ensureCliDetected(); + return this.cliPath; + } + + /** + * Get available Gemini models + */ + getAvailableModels(): ModelDefinition[] { + return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({ + id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash') + name: config.label, + modelString: id, // Same as id - CLI uses the full model name + provider: 'gemini', + description: config.description, + supportsTools: true, + supportsVision: config.supportsVision, + contextWindow: config.contextWindow, + })); + } + + /** + * Check if a feature is supported + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming', 'vision', 'thinking']; + return supported.includes(feature); + } +} diff --git a/jules_branch/apps/server/src/providers/index.ts b/jules_branch/apps/server/src/providers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cb79470157bb42403896edc7f346f6194479d12 --- /dev/null +++ b/jules_branch/apps/server/src/providers/index.ts @@ -0,0 +1,56 @@ +/** + * Provider exports + */ + +// Base providers +export { BaseProvider } from './base-provider.js'; +export { + CliProvider, + type SpawnStrategy, + type CliSpawnConfig, + type CliErrorInfo, +} from './cli-provider.js'; +export type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + AgentDefinition, + ReasoningEffort, + SystemPromptPreset, + ConversationMessage, + ContentBlock, + ValidationResult, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from './types.js'; + +// Claude provider +export { ClaudeProvider } from './claude-provider.js'; + +// Cursor provider +export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js'; +export { CursorConfigManager } from './cursor-config-manager.js'; + +// OpenCode provider +export { OpencodeProvider } from './opencode-provider.js'; + +// Gemini provider +export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js'; + +// Copilot provider (GitHub Copilot SDK) +export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js'; + +// Provider factory +export { ProviderFactory } from './provider-factory.js'; + +// Simple query service - unified interface for basic AI queries +export { simpleQuery, streamingQuery } from './simple-query-service.js'; +export type { + SimpleQueryOptions, + SimpleQueryResult, + StreamingQueryOptions, +} from './simple-query-service.js'; diff --git a/jules_branch/apps/server/src/providers/mock-provider.ts b/jules_branch/apps/server/src/providers/mock-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2880fecef9cb538f1bd2c34f28cf182cb62dbcbc --- /dev/null +++ b/jules_branch/apps/server/src/providers/mock-provider.ts @@ -0,0 +1,53 @@ +/** + * Mock Provider - No-op AI provider for E2E and CI testing + * + * When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of + * real backends (Claude, Codex, etc.) so tests never call external APIs. + */ + +import type { ExecuteOptions } from '@automaker/types'; +import { BaseProvider } from './base-provider.js'; +import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js'; + +const MOCK_TEXT = 'Mock agent output for testing.'; + +export class MockProvider extends BaseProvider { + getName(): string { + return 'mock'; + } + + async *executeQuery(_options: ExecuteOptions): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: MOCK_TEXT }], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + } + + async detectInstallation(): Promise { + return { + installed: true, + method: 'sdk', + hasApiKey: true, + authenticated: true, + }; + } + + getAvailableModels(): ModelDefinition[] { + return [ + { + id: 'mock-model', + name: 'Mock Model', + modelString: 'mock-model', + provider: 'mock', + description: 'Mock model for testing', + }, + ]; + } +} diff --git a/jules_branch/apps/server/src/providers/opencode-provider.ts b/jules_branch/apps/server/src/providers/opencode-provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c38e339968408c8ce578e220ab490bda59102c5e --- /dev/null +++ b/jules_branch/apps/server/src/providers/opencode-provider.ts @@ -0,0 +1,1537 @@ +/** + * OpenCode Provider - Executes queries using opencode CLI + * + * Extends CliProvider with OpenCode-specific configuration: + * - Event normalization for OpenCode's stream-json format + * - Dynamic model discovery via `opencode models` CLI command + * - NPX-based Windows execution strategy + * - Platform-specific npm global installation paths + * + * Spawns the opencode CLI with --output-format stream-json for streaming responses. + */ + +import * as path from 'path'; +import * as os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { CliProvider, type CliSpawnConfig } from './cli-provider.js'; + +const execFileAsync = promisify(execFile); +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + ModelDefinition, + InstallationStatus, + ContentBlock, +} from '@automaker/types'; +import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +// Create logger for OpenCode operations +const opencodeLogger = createLogger('OpencodeProvider'); + +// ============================================================================= +// OpenCode Auth Types +// ============================================================================= + +export interface OpenCodeAuthStatus { + authenticated: boolean; + method: 'api_key' | 'oauth' | 'none'; + hasOAuthToken?: boolean; + hasApiKey?: boolean; +} + +// ============================================================================= +// OpenCode Dynamic Model Types +// ============================================================================= + +/** + * Model information from `opencode models` CLI output + */ +export interface OpenCodeModelInfo { + /** Full model ID (e.g., "copilot/claude-sonnet-4-5") */ + id: string; + /** Provider name (e.g., "copilot", "anthropic", "openai") */ + provider: string; + /** Model name without provider prefix */ + name: string; + /** Display name for UI */ + displayName?: string; +} + +/** + * Provider information from `opencode auth list` CLI output + */ +export interface OpenCodeProviderInfo { + /** Provider ID (e.g., "copilot", "anthropic") */ + id: string; + /** Human-readable name */ + name: string; + /** Whether the provider is authenticated */ + authenticated: boolean; + /** Authentication method if authenticated */ + authMethod?: 'oauth' | 'api_key'; +} + +/** Cache duration for dynamic model fetching (5 minutes) */ +const MODEL_CACHE_DURATION_MS = 5 * 60 * 1000; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_MODEL_ID_PATTERN = /^[a-z0-9.-]+\/\S+$/; +const OPENCODE_PROVIDER_PATTERN = /^[a-z0-9.-]+$/; +const OPENCODE_MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/; + +// ============================================================================= +// OpenCode Stream Event Types +// ============================================================================= + +/** + * Part object within OpenCode events + */ +interface OpenCodePart { + id?: string; + sessionID?: string; + messageID?: string; + type: string; + text?: string; + reason?: string; + error?: string; + name?: string; + args?: unknown; + call_id?: string; + output?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + }; +} + +/** + * Base interface for all OpenCode stream events + * Format: {"type":"event_type","timestamp":...,"sessionID":"...","part":{...}} + */ +interface OpenCodeBaseEvent { + /** Event type identifier (step_start, text, step_finish, tool_call, etc.) */ + type: string; + /** Unix timestamp */ + timestamp?: number; + /** Session identifier */ + sessionID?: string; + /** Event details */ + part?: OpenCodePart; +} + +/** + * Text event - Text output from the model + */ +export interface OpenCodeTextEvent extends OpenCodeBaseEvent { + type: 'text'; + part: OpenCodePart & { type: 'text'; text: string }; +} + +/** + * Step start event - Begins an agentic loop iteration + */ +export interface OpenCodeStepStartEvent extends OpenCodeBaseEvent { + type: 'step_start'; + part: OpenCodePart & { type: 'step-start' }; +} + +/** + * Step finish event - Completes an agentic loop iteration + */ +export interface OpenCodeStepFinishEvent extends OpenCodeBaseEvent { + type: 'step_finish'; + part: OpenCodePart & { type: 'step-finish'; reason?: string }; +} + +/** + * Tool call event - Request to execute a tool + */ +export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { + type: 'tool_call'; + part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown }; +} + +/** + * Tool result event - Output from a tool execution + */ +export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { + type: 'tool_result'; + part: OpenCodePart & { type: 'tool-result'; output: string }; +} + +/** + * Error details object in error events + */ +interface OpenCodeErrorDetails { + name?: string; + message?: string; + data?: { + message?: string; + statusCode?: number; + isRetryable?: boolean; + }; +} + +/** + * Error event - An error occurred + */ +export interface OpenCodeErrorEvent extends OpenCodeBaseEvent { + type: 'error'; + part?: OpenCodePart & { error: string }; + error?: string | OpenCodeErrorDetails; +} + +/** + * Tool error event - A tool execution failed + */ +export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { + type: 'tool_error'; + part?: OpenCodePart & { error: string }; +} + +/** + * Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked. + * Contains the tool name, call ID, and the complete state (input, output, status). + * Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type. + */ +export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent { + type: 'tool_use'; + part: OpenCodePart & { + type: 'tool'; + callID?: string; + tool?: string; + state?: { + status?: string; + input?: unknown; + output?: string; + title?: string; + metadata?: unknown; + time?: { start: number; end: number }; + }; + }; +} + +/** + * Union type of all OpenCode stream events + */ +export type OpenCodeStreamEvent = + | OpenCodeTextEvent + | OpenCodeStepStartEvent + | OpenCodeStepFinishEvent + | OpenCodeToolCallEvent + | OpenCodeToolUseEvent + | OpenCodeToolResultEvent + | OpenCodeErrorEvent + | OpenCodeToolErrorEvent; + +// ============================================================================= +// Tool Use ID Generation +// ============================================================================= + +/** Counter for generating unique tool use IDs when call_id is not provided */ +let toolUseIdCounter = 0; + +/** + * Generate a unique tool use ID for tool calls without explicit IDs + */ +function generateToolUseId(): string { + toolUseIdCounter += 1; + return `opencode-tool-${toolUseIdCounter}`; +} + +/** + * Reset the tool use ID counter (useful for testing) + */ +export function resetToolUseIdCounter(): void { + toolUseIdCounter = 0; +} + +// ============================================================================= +// Provider Implementation +// ============================================================================= + +/** + * OpencodeProvider - Integrates opencode CLI as an AI provider + * + * OpenCode is an npm-distributed CLI tool that provides access to + * multiple AI model providers through a unified interface. + * + * Supports dynamic model discovery via `opencode models` CLI command, + * enabling access to 75+ providers including GitHub Copilot, Google, + * Anthropic, OpenAI, and more based on user authentication. + */ +export class OpencodeProvider extends CliProvider { + // ========================================================================== + // Dynamic Model Cache + // ========================================================================== + + /** Cached model definitions */ + private cachedModels: ModelDefinition[] | null = null; + + /** Timestamp when cache expires */ + private modelsCacheExpiry: number = 0; + + /** Cached authenticated providers */ + private cachedProviders: OpenCodeProviderInfo[] | null = null; + + /** Whether model refresh is in progress */ + private isRefreshing: boolean = false; + + /** Promise that resolves when current refresh completes */ + private refreshPromise: Promise | null = null; + + constructor(config: ProviderConfig = {}) { + super(config); + } + + // ========================================================================== + // CliProvider Abstract Method Implementations + // ========================================================================== + + getName(): string { + return 'opencode'; + } + + getCliName(): string { + return 'opencode'; + } + + getSpawnConfig(): CliSpawnConfig { + return { + windowsStrategy: 'npx', + npxPackage: 'opencode-ai@latest', + commonPaths: { + linux: [ + path.join(os.homedir(), '.opencode/bin/opencode'), + path.join(os.homedir(), '.npm-global/bin/opencode'), + '/usr/local/bin/opencode', + '/usr/bin/opencode', + path.join(os.homedir(), '.local/bin/opencode'), + ], + darwin: [ + path.join(os.homedir(), '.opencode/bin/opencode'), + path.join(os.homedir(), '.npm-global/bin/opencode'), + '/usr/local/bin/opencode', + '/opt/homebrew/bin/opencode', + path.join(os.homedir(), '.local/bin/opencode'), + ], + win32: [ + path.join(os.homedir(), '.opencode', 'bin', 'opencode.exe'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'), + path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), + ], + }, + }; + } + + /** + * Build CLI arguments for the `opencode run` command + * + * Arguments built: + * - 'run' subcommand for executing queries + * - '--format', 'json' for JSONL streaming output + * - '--model', '' for model selection (if specified) + * - '--session', '' for continuing an existing session (if sdkSessionId is set) + * + * The prompt is passed via stdin (piped) to avoid shell escaping issues. + * OpenCode CLI automatically reads from stdin when input is piped. + * + * @param options - Execution options containing model, cwd, etc. + * @returns Array of CLI arguments for opencode run + */ + buildCliArgs(options: ExecuteOptions): string[] { + const args: string[] = ['run']; + + // Add JSON output format for JSONL parsing (not 'stream-json') + args.push('--format', 'json'); + + // Handle session resumption for conversation continuity. + // The opencode CLI supports `--session ` to continue an existing session. + // The sdkSessionId is captured from the sessionID field in previous stream events + // and persisted by AgentService for use in follow-up messages. + if (options.sdkSessionId) { + args.push('--session', options.sdkSessionId); + } + + // Handle model selection + // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx) + // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model') + if (options.model) { + // Strip opencode- prefix if present, then ensure slash format + const model = options.model.startsWith('opencode-') + ? options.model.slice('opencode-'.length) + : options.model; + + // If model has slash, it's already provider/model format; otherwise prepend opencode/ + const cliModel = model.includes('/') ? model : `opencode/${model}`; + + args.push('--model', cliModel); + } + + // Note: OpenCode reads from stdin automatically when input is piped + // No '-' argument needed + + return args; + } + + // ========================================================================== + // Prompt Handling + // ========================================================================== + + /** + * Extract prompt text from ExecuteOptions for passing via stdin + * + * Handles both string prompts and array-based prompts with content blocks. + * For array prompts with images, extracts only text content (images would + * need separate handling via file paths if OpenCode supports them). + * + * @param options - Execution options containing the prompt + * @returns Plain text prompt string + */ + private extractPromptText(options: ExecuteOptions): string { + if (typeof options.prompt === 'string') { + return options.prompt; + } + + // Array-based prompt - extract text content + if (Array.isArray(options.prompt)) { + return options.prompt + .filter((block) => block.type === 'text' && block.text) + .map((block) => block.text) + .join('\n'); + } + + throw new Error('Invalid prompt format: expected string or content block array'); + } + + /** + * Build subprocess options with stdin data for prompt + * + * Extends the base class method to add stdinData containing the prompt. + * This allows passing prompts via stdin instead of CLI arguments, + * avoiding shell escaping issues with special characters. + * + * @param options - Execution options + * @param cliArgs - CLI arguments from buildCliArgs + * @returns SubprocessOptions with stdinData set + */ + protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { + const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); + + // Pass prompt via stdin to avoid shell interpretation of special characters + // like $(), backticks, quotes, etc. that may appear in prompts or file content + subprocessOptions.stdinData = this.extractPromptText(options); + + return subprocessOptions; + } + + /** + * Check if an error message indicates a session-not-found condition. + * + * Centralizes the pattern matching for session errors to avoid duplication. + * Strips ANSI escape codes first since opencode CLI uses colored stderr output + * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found"). + * + * IMPORTANT: Patterns must be specific enough to avoid false positives. + * Generic patterns like "notfounderror" or "resource not found" match + * non-session errors (e.g. "ProviderModelNotFoundError") which would + * trigger unnecessary retries that fail identically, producing confusing + * error messages like "OpenCode session could not be created". + * + * @param errorText - Raw error text (may contain ANSI codes) + * @returns true if the error indicates the session was not found + */ + private static isSessionNotFoundError(errorText: string): boolean { + const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase(); + + // Explicit session-related phrases — high confidence + if ( + cleaned.includes('session not found') || + cleaned.includes('session does not exist') || + cleaned.includes('invalid session') || + cleaned.includes('session expired') || + cleaned.includes('no such session') + ) { + return true; + } + + // Generic "NotFoundError" / "resource not found" are only session errors + // when the message also references a session path or session ID. + // Without this guard, errors like "ProviderModelNotFoundError" or + // "Resource not found: /path/to/config.json" would false-positive. + if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) { + return cleaned.includes('/session/') || /\bsession\b/.test(cleaned); + } + + return false; + } + + /** + * Strip ANSI escape codes from a string. + * + * The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m"). + * These escape codes render as garbled text like "[91m[1mError: [0m" in the UI + * when passed through as-is. This utility removes them so error messages are + * clean and human-readable. + */ + private static stripAnsiCodes(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ''); + } + + /** + * Clean a CLI error message for display. + * + * Strips ANSI escape codes AND removes the redundant "Error: " prefix that + * the OpenCode CLI prepends to error messages in its colored stderr output + * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found"). + * + * Without this, consumers that wrap the message in their own "Error: " prefix + * (like AgentService or AgentExecutor) produce garbled double-prefixed output: + * "Error: Error: Session not found". + */ + private static cleanErrorMessage(text: string): string { + let cleaned = OpencodeProvider.stripAnsiCodes(text).trim(); + // Remove leading "Error: " prefix (case-insensitive) if present. + // The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m + // After ANSI stripping this becomes: "Error: " + cleaned = cleaned.replace(/^Error:\s*/i, '').trim(); + return cleaned || text; + } + + /** + * Execute a query with automatic session resumption fallback. + * + * When a sdkSessionId is provided, the CLI receives `--session `. + * If the session no longer exists on disk the CLI will fail with a + * "NotFoundError" / "Resource not found" / "Session not found" error. + * + * The opencode CLI writes this to **stderr** and exits non-zero. + * `spawnJSONLProcess` collects stderr and **yields** it as + * `{ type: 'error', error: }` — it is NOT thrown. + * After `normalizeEvent`, the error becomes a yielded `ProviderMessage` + * with `type: 'error'`. A simple try/catch therefore cannot intercept it. + * + * This override iterates the parent stream, intercepts yielded error + * messages that match the session-not-found pattern, and retries the + * entire query WITHOUT the `--session` flag so a fresh session is started. + * + * Session-not-found retry is ONLY attempted when `sdkSessionId` is set. + * Without the `--session` flag the CLI always creates a fresh session, so + * retrying without it would be identical to the first attempt and would + * fail the same way — producing a confusing "session could not be created" + * message for what is actually a different error (model not found, auth + * failure, etc.). + * + * All error messages (session or not) are cleaned of ANSI codes and the + * CLI's redundant "Error: " prefix before being yielded to consumers. + * + * After a successful retry, the consumer (AgentService) will receive a new + * session_id from the fresh stream events, which it persists to metadata — + * replacing the stale sdkSessionId and preventing repeated failures. + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + // When no sdkSessionId is set, there is nothing to "retry without" — just + // stream normally and clean error messages as they pass through. + if (!options.sdkSessionId) { + for await (const msg of super.executeQuery(options)) { + // Clean error messages so consumers don't get ANSI or double "Error:" prefix + if (msg.type === 'error' && msg.error && typeof msg.error === 'string') { + msg.error = OpencodeProvider.cleanErrorMessage(msg.error); + } + yield msg; + } + return; + } + + // sdkSessionId IS set — the CLI will receive `--session `. + // If that session no longer exists, intercept the error and retry fresh. + // + // To avoid buffering the entire stream in memory for long-lived sessions, + // we only buffer an initial window of messages until we observe a healthy + // (non-error) message. Once a healthy message is seen, we flush the buffer + // and switch to direct passthrough, while still watching for session errors + // via isSessionNotFoundError on any subsequent error messages. + const buffered: ProviderMessage[] = []; + let sessionError = false; + let seenHealthyMessage = false; + + try { + for await (const msg of super.executeQuery(options)) { + if (msg.type === 'error') { + const errorText = msg.error || ''; + if (OpencodeProvider.isSessionNotFoundError(errorText)) { + sessionError = true; + opencodeLogger.info( + `OpenCode session error detected (session "${options.sdkSessionId}") ` + + `— retrying without --session to start fresh` + ); + break; // stop consuming the failed stream + } + + // Non-session error — clean it + if (msg.error && typeof msg.error === 'string') { + msg.error = OpencodeProvider.cleanErrorMessage(msg.error); + } + } else { + // A non-error message is a healthy signal — stop buffering after this + seenHealthyMessage = true; + } + + if (seenHealthyMessage && buffered.length > 0) { + // Flush the pre-healthy buffer first, then switch to passthrough + for (const bufferedMsg of buffered) { + yield bufferedMsg; + } + buffered.length = 0; + } + + if (seenHealthyMessage) { + // Passthrough mode — yield directly without buffering + yield msg; + } else { + // Still in initial window — buffer until we see a healthy message + buffered.push(msg); + } + } + } catch (error) { + // Also handle thrown exceptions (e.g. from mapError in cli-provider) + const errMsg = error instanceof Error ? error.message : String(error); + if (OpencodeProvider.isSessionNotFoundError(errMsg)) { + sessionError = true; + opencodeLogger.info( + `OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` + + `— retrying without --session to start fresh` + ); + } else { + throw error; + } + } + + if (sessionError) { + // Retry the entire query without the stale session ID. + const retryOptions = { ...options, sdkSessionId: undefined }; + opencodeLogger.info('Retrying OpenCode query without --session flag...'); + + // Stream the retry directly to the consumer. + // If the retry also fails, it's a genuine error (not session-related) + // and should be surfaced as-is rather than masked with a misleading + // "session could not be created" message. + for await (const retryMsg of super.executeQuery(retryOptions)) { + if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') { + retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error); + } + yield retryMsg; + } + } else if (buffered.length > 0) { + // No session error and still have buffered messages (stream ended before + // any healthy message was observed) — flush them to the consumer + for (const msg of buffered) { + yield msg; + } + } + // If seenHealthyMessage is true, all messages have already been yielded + // directly in passthrough mode — nothing left to flush. + } + + /** + * Normalize a raw CLI event to ProviderMessage format + * + * Maps OpenCode event types to the standard ProviderMessage structure: + * - text -> type: 'assistant', content with type: 'text' + * - step_start -> null (informational, no message needed) + * - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success' + * - step_finish with reason 'tool-calls' -> null (intermediate step, not final) + * - step_finish with error -> type: 'error' + * - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format) + * - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format) + * - tool_result -> type: 'assistant', content with type: 'tool_result' + * - error -> type: 'error' + * + * @param event - Raw event from OpenCode CLI JSONL output + * @returns Normalized ProviderMessage or null to skip the event + */ + normalizeEvent(event: unknown): ProviderMessage | null { + if (!event || typeof event !== 'object') { + return null; + } + + const openCodeEvent = event as OpenCodeStreamEvent; + + switch (openCodeEvent.type) { + case 'text': { + const textEvent = openCodeEvent as OpenCodeTextEvent; + + // Skip empty text + if (!textEvent.part?.text) { + return null; + } + + const content: ContentBlock[] = [ + { + type: 'text', + text: textEvent.part.text, + }, + ]; + + return { + type: 'assistant', + session_id: textEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'step_start': { + // Step start is informational - no message needed + return null; + } + + case 'step_finish': { + const finishEvent = openCodeEvent as OpenCodeStepFinishEvent; + + // Check if the step failed - either by error property or reason='error' + if (finishEvent.part?.error) { + return { + type: 'error', + session_id: finishEvent.sessionID, + error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error), + }; + } + + // Check if reason indicates error (even without explicit error text) + if (finishEvent.part?.reason === 'error') { + return { + type: 'error', + session_id: finishEvent.sessionID, + error: OpencodeProvider.cleanErrorMessage('Step execution failed'), + }; + } + + // Intermediate step completion (reason: 'tool-calls') — the agent loop + // is continuing because the model requested tool calls. Skip these so + // consumers don't mistake them for final results. + if (finishEvent.part?.reason === 'tool-calls') { + return null; + } + + // Only treat an explicit allowlist of reasons as true success. + // Reasons like 'length' (context-window truncation) or 'content-filter' + // indicate the model stopped abnormally and must not be surfaced as + // successful completions. + const SUCCESS_REASONS = new Set(['stop', 'end_turn']); + const reason = finishEvent.part?.reason; + + if (reason === undefined || SUCCESS_REASONS.has(reason)) { + // Final completion (reason: 'stop', 'end_turn', or unset) + return { + type: 'result', + subtype: 'success', + session_id: finishEvent.sessionID, + result: (finishEvent.part as OpenCodePart & { result?: string })?.result, + }; + } + + // Non-success, non-tool-calls reason (e.g. 'length', 'content-filter') + return { + type: 'result', + subtype: 'error', + session_id: finishEvent.sessionID, + error: `Step finished with non-success reason: ${reason}`, + result: (finishEvent.part as OpenCodePart & { result?: string })?.result, + }; + } + + case 'tool_error': { + const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent; + + // Extract error message from part.error and clean ANSI codes + const errorMessage = OpencodeProvider.cleanErrorMessage( + toolErrorEvent.part?.error || 'Tool execution failed' + ); + + return { + type: 'error', + session_id: toolErrorEvent.sessionID, + error: errorMessage, + }; + } + + // OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool. + // The event format includes the tool name, call ID, and state with input/output. + // Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness. + case 'tool_use': { + const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent; + const part = toolUseEvent.part; + + // Generate a tool use ID if not provided + const toolUseId = part?.callID || part?.call_id || generateToolUseId(); + const toolName = part?.tool || part?.name || 'unknown'; + + const content: ContentBlock[] = [ + { + type: 'tool_use', + name: toolName, + tool_use_id: toolUseId, + input: part?.state?.input || part?.args, + }, + ]; + + // If the tool has already completed (state.status === 'completed'), also emit the result + if (part?.state?.status === 'completed' && part?.state?.output) { + content.push({ + type: 'tool_result', + tool_use_id: toolUseId, + content: part.state.output, + }); + } + + return { + type: 'assistant', + session_id: toolUseEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool_call': { + const toolEvent = openCodeEvent as OpenCodeToolCallEvent; + + // Generate a tool use ID if not provided + const toolUseId = toolEvent.part?.call_id || generateToolUseId(); + + const content: ContentBlock[] = [ + { + type: 'tool_use', + name: toolEvent.part?.name || 'unknown', + tool_use_id: toolUseId, + input: toolEvent.part?.args, + }, + ]; + + return { + type: 'assistant', + session_id: toolEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'tool_result': { + const resultEvent = openCodeEvent as OpenCodeToolResultEvent; + + const content: ContentBlock[] = [ + { + type: 'tool_result', + tool_use_id: resultEvent.part?.call_id, + content: resultEvent.part?.output || '', + }, + ]; + + return { + type: 'assistant', + session_id: resultEvent.sessionID, + message: { + role: 'assistant', + content, + }, + }; + } + + case 'error': { + const errorEvent = openCodeEvent as OpenCodeErrorEvent; + + // Extract error message from various formats + let errorMessage = 'Unknown error'; + if (errorEvent.error) { + if (typeof errorEvent.error === 'string') { + errorMessage = errorEvent.error; + } else { + // Error is an object with name/data structure + errorMessage = + errorEvent.error.data?.message || + errorEvent.error.message || + errorEvent.error.name || + 'Unknown error'; + } + } else if (errorEvent.part?.error) { + errorMessage = errorEvent.part.error; + } + + // Clean error messages: strip ANSI escape codes AND the redundant "Error: " + // prefix the CLI adds. The OpenCode CLI outputs colored stderr like: + // \x1b[91m\x1b[1mError: \x1b[0mSession not found + // Without cleaning, consumers that wrap in their own "Error: " prefix + // produce "Error: Error: Session not found". + errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage); + + return { + type: 'error', + session_id: errorEvent.sessionID, + error: errorMessage, + }; + } + + default: { + // Unknown event type - skip it + return null; + } + } + } + + // ========================================================================== + // Model Configuration + // ========================================================================== + + /** + * Get available models for OpenCode + * + * Returns cached models if available and not expired. + * Falls back to default models if cache is empty or CLI is unavailable. + * + * Use `refreshModels()` to force a fresh fetch from the CLI. + */ + getAvailableModels(): ModelDefinition[] { + // Return cached models if available and not expired + if (this.cachedModels && Date.now() < this.modelsCacheExpiry) { + return this.cachedModels; + } + + // Return cached models even if expired (better than nothing) + if (this.cachedModels) { + // Trigger background refresh + this.refreshModels().catch((err) => { + opencodeLogger.debug(`Background model refresh failed: ${err}`); + }); + return this.cachedModels; + } + + // Return default models while cache is empty + return this.getDefaultModels(); + } + + /** + * Get default hardcoded models (fallback when CLI is unavailable) + */ + private getDefaultModels(): ModelDefinition[] { + return [ + // OpenCode Free Tier Models + { + id: 'opencode/big-pickle', + name: 'Big Pickle (Free)', + modelString: 'opencode/big-pickle', + provider: 'opencode', + description: 'OpenCode free tier model - great for general coding', + supportsTools: true, + supportsVision: false, + tier: 'basic', + default: true, + }, + { + id: 'opencode/glm-5-free', + name: 'GLM 5 Free', + modelString: 'opencode/glm-5-free', + provider: 'opencode', + description: 'OpenCode free tier GLM model', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + { + id: 'opencode/gpt-5-nano', + name: 'GPT-5 Nano (Free)', + modelString: 'opencode/gpt-5-nano', + provider: 'opencode', + description: 'Fast and lightweight free tier model', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + { + id: 'opencode/kimi-k2.5-free', + name: 'Kimi K2.5 Free', + modelString: 'opencode/kimi-k2.5-free', + provider: 'opencode', + description: 'OpenCode free tier Kimi model for coding', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + { + id: 'opencode/minimax-m2.5-free', + name: 'MiniMax M2.5 Free', + modelString: 'opencode/minimax-m2.5-free', + provider: 'opencode', + description: 'OpenCode free tier MiniMax model', + supportsTools: true, + supportsVision: false, + tier: 'basic', + }, + ]; + } + + // ========================================================================== + // Dynamic Model Discovery + // ========================================================================== + + /** + * Refresh models from OpenCode CLI + * + * Fetches available models using `opencode models` command and updates cache. + * Returns the updated model definitions. + */ + async refreshModels(): Promise { + // If refresh is in progress, wait for existing promise instead of busy-waiting + if (this.isRefreshing && this.refreshPromise) { + opencodeLogger.debug('Model refresh already in progress, waiting for completion...'); + return this.refreshPromise; + } + + this.isRefreshing = true; + opencodeLogger.debug('Starting model refresh from OpenCode CLI'); + + this.refreshPromise = this.doRefreshModels(); + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + this.isRefreshing = false; + } + } + + /** + * Internal method that performs the actual model refresh + */ + private async doRefreshModels(): Promise { + try { + const models = await this.fetchModelsFromCli(); + + if (models.length > 0) { + this.cachedModels = models; + this.modelsCacheExpiry = Date.now() + MODEL_CACHE_DURATION_MS; + opencodeLogger.debug(`Cached ${models.length} models from OpenCode CLI`); + } else { + // Keep existing cache if fetch returned nothing + opencodeLogger.debug('No models returned from CLI, keeping existing cache'); + } + + return this.cachedModels || this.getDefaultModels(); + } catch (error) { + opencodeLogger.debug(`Model refresh failed: ${error}`); + // Return existing cache or defaults on error + return this.cachedModels || this.getDefaultModels(); + } + } + + /** + * Fetch models from OpenCode CLI using `opencode models` command + * + * Uses async execFile to avoid blocking the event loop. + */ + private async fetchModelsFromCli(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for model fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy: execute npx with opencode-ai package + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + args = ['opencode-ai@latest', 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy: execute via wsl.exe + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'models'] + : [this.wslCliPath, 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 30000, + windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), + }); + + opencodeLogger.debug( + `Models output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + return this.parseModelsOutput(stdout); + } catch (error) { + opencodeLogger.error(`Failed to fetch models from CLI: ${error}`); + return []; + } + } + + /** + * Parse the output of `opencode models` command + * + * OpenCode CLI output format (one model per line): + * opencode/big-pickle + * opencode/glm-5-free + * anthropic/claude-3-5-haiku-20241022 + * github-copilot/claude-3.5-sonnet + * ... + */ + private parseModelsOutput(output: string): ModelDefinition[] { + // Parse line-based format (one model ID per line) + const lines = output.split('\n'); + const models: ModelDefinition[] = []; + + // Regex to validate "provider/model-name" format + // Provider: lowercase letters, numbers, dots, hyphens + // Model name: non-whitespace (supports nested paths like openrouter/anthropic/claude) + const modelIdRegex = OPENCODE_MODEL_ID_PATTERN; + + for (const line of lines) { + // Remove ANSI escape codes if any + const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + + // Skip empty lines + if (!cleanLine) continue; + + // Validate format using regex for robustness + if (modelIdRegex.test(cleanLine)) { + const separatorIndex = cleanLine.indexOf(OPENCODE_MODEL_ID_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === cleanLine.length - 1) { + continue; + } + + const provider = cleanLine.slice(0, separatorIndex); + const name = cleanLine.slice(separatorIndex + 1); + + if (!OPENCODE_PROVIDER_PATTERN.test(provider) || !OPENCODE_MODEL_NAME_PATTERN.test(name)) { + continue; + } + + models.push( + this.modelInfoToDefinition({ + id: cleanLine, + provider, + name, + }) + ); + } + } + + opencodeLogger.debug(`Parsed ${models.length} models from CLI output`); + return models; + } + + /** + * Convert OpenCodeModelInfo to ModelDefinition + */ + private modelInfoToDefinition(model: OpenCodeModelInfo): ModelDefinition { + const displayName = model.displayName || this.formatModelDisplayName(model); + const tier = this.inferModelTier(model.id); + + return { + id: model.id, + name: displayName, + modelString: model.id, + provider: model.provider, // Use the actual provider (github-copilot, google, etc.) + description: `${model.name} via ${this.formatProviderName(model.provider)}`, + supportsTools: true, + supportsVision: this.modelSupportsVision(model.id), + tier, + // Mark Claude Sonnet as default if available + default: model.id.includes('claude-sonnet-4'), + }; + } + + /** + * Format provider name for display + */ + private formatProviderName(provider: string): string { + const providerNames: Record = { + 'github-copilot': 'GitHub Copilot', + google: 'Google AI', + openai: 'OpenAI', + anthropic: 'Anthropic', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + ollama: 'Ollama', + lmstudio: 'LM Studio', + azure: 'Azure OpenAI', + xai: 'xAI', + deepseek: 'DeepSeek', + }; + return ( + providerNames[provider] || + provider.charAt(0).toUpperCase() + provider.slice(1).replace(/-/g, ' ') + ); + } + + /** + * Format a display name for a model + */ + private formatModelDisplayName(model: OpenCodeModelInfo): string { + // Extract the last path segment for nested model IDs + // e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free" + let rawName = model.name; + if (rawName.includes('/')) { + rawName = rawName.split('/').pop()!; + } + + // Strip tier/pricing suffixes like ":free", ":extended" + const colonIdx = rawName.indexOf(':'); + let suffix = ''; + if (colonIdx !== -1) { + const tierPart = rawName.slice(colonIdx + 1); + if (/^(free|extended|beta|preview)$/i.test(tierPart)) { + suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`; + } + rawName = rawName.slice(0, colonIdx); + } + + // Capitalize and format the model name + const formattedName = rawName + .split('-') + .map((part) => { + // Handle version numbers like "4-5" -> "4.5" + if (/^\d+$/.test(part)) { + return part; + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(' ') + .replace(/(\d)\s+(\d)/g, '$1.$2'); // "4 5" -> "4.5" + + // Format provider name + const providerNames: Record = { + copilot: 'GitHub Copilot', + anthropic: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + 'amazon-bedrock': 'AWS Bedrock', + bedrock: 'AWS Bedrock', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + azure: 'Azure', + ollama: 'Ollama', + lmstudio: 'LM Studio', + }; + + const providerDisplay = providerNames[model.provider] || model.provider; + return `${formattedName}${suffix} (${providerDisplay})`; + } + + /** + * Infer model tier based on model ID + */ + private inferModelTier(modelId: string): 'basic' | 'standard' | 'premium' { + const lowerModelId = modelId.toLowerCase(); + + // Premium tier: flagship models + if ( + lowerModelId.includes('opus') || + lowerModelId.includes('gpt-5') || + lowerModelId.includes('o3') || + lowerModelId.includes('o4') || + lowerModelId.includes('gemini-2') || + lowerModelId.includes('deepseek-r1') + ) { + return 'premium'; + } + + // Basic tier: free or lightweight models + if ( + lowerModelId.includes('free') || + lowerModelId.includes('nano') || + lowerModelId.includes('mini') || + lowerModelId.includes('haiku') || + lowerModelId.includes('flash') + ) { + return 'basic'; + } + + // Standard tier: everything else + return 'standard'; + } + + /** + * Check if a model supports vision based on model ID + */ + private modelSupportsVision(modelId: string): boolean { + const lowerModelId = modelId.toLowerCase(); + + // Models known to support vision + const visionModels = ['claude', 'gpt-4', 'gpt-5', 'gemini', 'nova', 'llama-3', 'llama-4']; + + return visionModels.some((vm) => lowerModelId.includes(vm)); + } + + /** + * Fetch authenticated providers from OpenCode CLI + * + * Runs `opencode auth list` to get the list of authenticated providers. + * Uses async execFile to avoid blocking the event loop. + */ + async fetchAuthenticatedProviders(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for provider fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy + command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + args = ['opencode-ai@latest', 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'auth', 'list'] + : [this.wslCliPath, 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 15000, + windowsHide: true, + // Use shell on Windows for .cmd files + shell: process.platform === 'win32' && command.endsWith('.cmd'), + }); + + opencodeLogger.debug( + `Auth list output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + const providers = this.parseProvidersOutput(stdout); + this.cachedProviders = providers; + return providers; + } catch (error) { + opencodeLogger.error(`Failed to fetch providers from CLI: ${error}`); + return this.cachedProviders || []; + } + } + + /** + * Parse the output of `opencode auth list` command + * + * OpenCode CLI output format: + * ┌ Credentials ~/.local/share/opencode/auth.json + * │ + * ● Anthropic oauth + * │ + * ● GitHub Copilot oauth + * │ + * └ 4 credentials + * + * Each line with ● contains: provider name and auth method (oauth/api) + */ + private parseProvidersOutput(output: string): OpenCodeProviderInfo[] { + const lines = output.split('\n'); + const providers: OpenCodeProviderInfo[] = []; + + // Provider name to ID mapping + const providerIdMap: Record = { + anthropic: 'anthropic', + 'github copilot': 'github-copilot', + copilot: 'github-copilot', + google: 'google', + openai: 'openai', + openrouter: 'openrouter', + azure: 'azure', + bedrock: 'amazon-bedrock', + 'amazon bedrock': 'amazon-bedrock', + ollama: 'ollama', + 'lm studio': 'lmstudio', + lmstudio: 'lmstudio', + opencode: 'opencode', + 'z.ai coding plan': 'zai-coding-plan', + 'z.ai': 'z-ai', + }; + + for (const line of lines) { + // Look for lines with ● which indicate authenticated providers + // Format: "● Provider Name auth_method" + if (line.includes('●')) { + // Remove ANSI escape codes and the ● symbol + const cleanLine = line + .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI codes + .replace(/●/g, '') // Remove ● symbol + .trim(); + + if (!cleanLine) continue; + + // Parse "Provider Name auth_method" format + // Auth method is the last word (oauth, api, etc.) + const parts = cleanLine.split(/\s+/); + if (parts.length >= 2) { + const authMethod = parts[parts.length - 1].toLowerCase(); + const providerName = parts.slice(0, -1).join(' '); + + // Determine auth method type + let authMethodType: 'oauth' | 'api_key' | undefined; + if (authMethod === 'oauth') { + authMethodType = 'oauth'; + } else if (authMethod === 'api' || authMethod === 'api_key') { + authMethodType = 'api_key'; + } + + // Get provider ID from name + const providerNameLower = providerName.toLowerCase(); + const providerId = + providerIdMap[providerNameLower] || providerNameLower.replace(/\s+/g, '-'); + + providers.push({ + id: providerId, + name: providerName, + authenticated: true, // If it's listed with ●, it's authenticated + authMethod: authMethodType, + }); + } + } + } + + opencodeLogger.debug(`Parsed ${providers.length} providers from auth list`); + return providers; + } + + /** + * Get cached authenticated providers + */ + getCachedProviders(): OpenCodeProviderInfo[] | null { + return this.cachedProviders; + } + + /** + * Clear the model cache, forcing a refresh on next access + */ + clearModelCache(): void { + this.cachedModels = null; + this.modelsCacheExpiry = 0; + this.cachedProviders = null; + opencodeLogger.debug('Model cache cleared'); + } + + /** + * Check if we have cached models (not just defaults) + */ + hasCachedModels(): boolean { + return this.cachedModels !== null && this.cachedModels.length > 0; + } + + // ========================================================================== + // Feature Support + // ========================================================================== + + /** + * Check if a feature is supported by OpenCode + * + * Supported features: + * - tools: Function calling / tool use + * - text: Text generation + * - vision: Image understanding + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ['tools', 'text', 'vision']; + return supportedFeatures.includes(feature); + } + + // ========================================================================== + // Authentication + // ========================================================================== + + /** + * Check authentication status for OpenCode CLI + * + * Checks for authentication via: + * - OAuth token in auth file + * - API key in auth file + */ + async checkAuth(): Promise { + const authIndicators = await getOpenCodeAuthIndicators(); + + // Check for OAuth token + if (authIndicators.hasOAuthToken) { + return { + authenticated: true, + method: 'oauth', + hasOAuthToken: true, + hasApiKey: authIndicators.hasApiKey, + }; + } + + // Check for API key + if (authIndicators.hasApiKey) { + return { + authenticated: true, + method: 'api_key', + hasOAuthToken: false, + hasApiKey: true, + }; + } + + return { + authenticated: false, + method: 'none', + hasOAuthToken: false, + hasApiKey: false, + }; + } + + // ========================================================================== + // Installation Detection + // ========================================================================== + + /** + * Detect OpenCode installation status + * + * Checks if the opencode CLI is available either through: + * - Direct installation (npm global) + * - NPX (fallback on Windows) + * Also checks authentication status. + */ + async detectInstallation(): Promise { + this.ensureCliDetected(); + + const installed = await this.isInstalled(); + const auth = await this.checkAuth(); + + return { + installed, + path: this.cliPath || undefined, + method: this.detectedStrategy === 'npx' ? 'npm' : 'cli', + authenticated: auth.authenticated, + hasApiKey: auth.hasApiKey, + hasOAuthToken: auth.hasOAuthToken, + }; + } +} diff --git a/jules_branch/apps/server/src/providers/provider-factory.ts b/jules_branch/apps/server/src/providers/provider-factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b5535084a26100c35a7ceeb5eb6daa6a6d8c8a6 --- /dev/null +++ b/jules_branch/apps/server/src/providers/provider-factory.ts @@ -0,0 +1,350 @@ +/** + * Provider Factory - Routes model IDs to the appropriate provider + * + * Uses a registry pattern for dynamic provider registration. + * Providers register themselves on import, making it easy to add new providers. + */ + +import { BaseProvider } from './base-provider.js'; +import type { InstallationStatus, ModelDefinition } from './types.js'; +import { + isCursorModel, + isCodexModel, + isOpencodeModel, + isGeminiModel, + isCopilotModel, + type ModelProvider, +} from '@automaker/types'; +import * as fs from 'fs'; +import * as path from 'path'; + +const DISCONNECTED_MARKERS: Record = { + claude: '.claude-disconnected', + codex: '.codex-disconnected', + cursor: '.cursor-disconnected', + opencode: '.opencode-disconnected', + gemini: '.gemini-disconnected', + copilot: '.copilot-disconnected', +}; + +/** + * Check if a provider CLI is disconnected from the app + */ +export function isProviderDisconnected(providerName: string): boolean { + const markerFile = DISCONNECTED_MARKERS[providerName.toLowerCase()]; + if (!markerFile) return false; + + const markerPath = path.join(process.cwd(), '.automaker', markerFile); + return fs.existsSync(markerPath); +} + +/** + * Provider registration entry + */ +interface ProviderRegistration { + /** Factory function to create provider instance */ + factory: () => BaseProvider; + /** Aliases for this provider (e.g., 'anthropic' for 'claude') */ + aliases?: string[]; + /** Function to check if this provider can handle a model ID */ + canHandleModel?: (modelId: string) => boolean; + /** Priority for model matching (higher = checked first) */ + priority?: number; +} + +/** + * Provider registry - stores registered providers + */ +const providerRegistry = new Map(); + +/** + * Register a provider with the factory + * + * @param name Provider name (e.g., 'claude', 'cursor') + * @param registration Provider registration config + */ +export function registerProvider(name: string, registration: ProviderRegistration): void { + providerRegistry.set(name.toLowerCase(), registration); +} + +/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */ +let mockProviderInstance: BaseProvider | null = null; + +function getMockProvider(): BaseProvider { + if (!mockProviderInstance) { + mockProviderInstance = new MockProvider(); + } + return mockProviderInstance; +} + +export class ProviderFactory { + /** + * Determine which provider to use for a given model + * + * @param model Model identifier + * @returns Provider name (ModelProvider type) + */ + static getProviderNameForModel(model: string): ModelProvider { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider + } + const lowerModel = model.toLowerCase(); + + // Get all registered providers sorted by priority (descending) + const registrations = Array.from(providerRegistry.entries()).sort( + ([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0) + ); + + // Check each provider's canHandleModel function + for (const [name, reg] of registrations) { + if (reg.canHandleModel?.(lowerModel)) { + return name as ModelProvider; + } + } + + // Fallback: Check for explicit prefixes + for (const [name] of registrations) { + if (lowerModel.startsWith(`${name}-`)) { + return name as ModelProvider; + } + } + + // Default to claude (first registered provider or claude) + return 'claude'; + } + + /** + * Get the appropriate provider for a given model ID + * + * @param modelId Model identifier (e.g., "claude-opus-4-6", "cursor-gpt-4o", "cursor-auto") + * @param options Optional settings + * @param options.throwOnDisconnected Throw error if provider is disconnected (default: true) + * @returns Provider instance for the model + * @throws Error if provider is disconnected and throwOnDisconnected is true + */ + static getProviderForModel( + modelId: string, + options: { throwOnDisconnected?: boolean } = {} + ): BaseProvider { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return getMockProvider(); + } + const { throwOnDisconnected = true } = options; + const providerName = this.getProviderForModelName(modelId); + + // Check if provider is disconnected + if (throwOnDisconnected && isProviderDisconnected(providerName)) { + throw new Error( + `${providerName.charAt(0).toUpperCase() + providerName.slice(1)} CLI is disconnected from the app. ` + + `Please go to Settings > Providers and click "Sign In" to reconnect.` + ); + } + + const provider = this.getProviderByName(providerName); + + if (!provider) { + // Fallback to claude if provider not found + const claudeReg = providerRegistry.get('claude'); + if (claudeReg) { + return claudeReg.factory(); + } + throw new Error(`No provider found for model: ${modelId}`); + } + + return provider; + } + + /** + * Get the provider name for a given model ID (without creating provider instance) + */ + static getProviderForModelName(modelId: string): string { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return 'claude'; + } + const lowerModel = modelId.toLowerCase(); + + // Get all registered providers sorted by priority (descending) + const registrations = Array.from(providerRegistry.entries()).sort( + ([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0) + ); + + // Check each provider's canHandleModel function + for (const [name, reg] of registrations) { + if (reg.canHandleModel?.(lowerModel)) { + return name; + } + } + + // Fallback: Check for explicit prefixes + for (const [name] of registrations) { + if (lowerModel.startsWith(`${name}-`)) { + return name; + } + } + + // Default to claude (first registered provider or claude) + return 'claude'; + } + + /** + * Get all available providers + */ + static getAllProviders(): BaseProvider[] { + return Array.from(providerRegistry.values()).map((reg) => reg.factory()); + } + + /** + * Check installation status for all providers + * + * @returns Map of provider name to installation status + */ + static async checkAllProviders(): Promise> { + const statuses: Record = {}; + + for (const [name, reg] of providerRegistry.entries()) { + const provider = reg.factory(); + const status = await provider.detectInstallation(); + statuses[name] = status; + } + + return statuses; + } + + /** + * Get provider by name (for direct access if needed) + * + * @param name Provider name (e.g., "claude", "cursor") or alias (e.g., "anthropic") + * @returns Provider instance or null if not found + */ + static getProviderByName(name: string): BaseProvider | null { + const lowerName = name.toLowerCase(); + + // Direct lookup + const directReg = providerRegistry.get(lowerName); + if (directReg) { + return directReg.factory(); + } + + // Check aliases + for (const [, reg] of providerRegistry.entries()) { + if (reg.aliases?.includes(lowerName)) { + return reg.factory(); + } + } + + return null; + } + + /** + * Get all available models from all providers + */ + static getAllAvailableModels(): ModelDefinition[] { + const providers = this.getAllProviders(); + return providers.flatMap((p) => p.getAvailableModels()); + } + + /** + * Get list of registered provider names + */ + static getRegisteredProviderNames(): string[] { + return Array.from(providerRegistry.keys()); + } + + /** + * Check if a specific model supports vision/image input + * + * @param modelId Model identifier + * @returns Whether the model supports vision (defaults to true if model not found) + */ + static modelSupportsVision(modelId: string): boolean { + const provider = this.getProviderForModel(modelId); + const models = provider.getAvailableModels(); + + // Find the model in the available models list + for (const model of models) { + if ( + model.id === modelId || + model.modelString === modelId || + model.id.endsWith(`-${modelId}`) || + model.modelString.endsWith(`-${modelId}`) || + model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') || + model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '') + ) { + return model.supportsVision ?? true; + } + } + + // Also try exact match with model string from provider's model map + for (const model of models) { + if (model.modelString === modelId || model.id === modelId) { + return model.supportsVision ?? true; + } + } + + // Default to true (Claude SDK supports vision by default) + return true; + } +} + +// ============================================================================= +// Provider Registrations +// ============================================================================= + +// Import providers for registration side-effects +import { MockProvider } from './mock-provider.js'; +import { ClaudeProvider } from './claude-provider.js'; +import { CursorProvider } from './cursor-provider.js'; +import { CodexProvider } from './codex-provider.js'; +import { OpencodeProvider } from './opencode-provider.js'; +import { GeminiProvider } from './gemini-provider.js'; +import { CopilotProvider } from './copilot-provider.js'; + +// Register Claude provider +registerProvider('claude', { + factory: () => new ClaudeProvider(), + aliases: ['anthropic'], + canHandleModel: (model: string) => { + return ( + model.startsWith('claude-') || ['opus', 'sonnet', 'haiku'].some((n) => model.includes(n)) + ); + }, + priority: 0, // Default priority +}); + +// Register Cursor provider +registerProvider('cursor', { + factory: () => new CursorProvider(), + canHandleModel: (model: string) => isCursorModel(model), + priority: 10, // Higher priority - check Cursor models first +}); + +// Register Codex provider +registerProvider('codex', { + factory: () => new CodexProvider(), + aliases: ['openai'], + canHandleModel: (model: string) => isCodexModel(model), + priority: 5, // Medium priority - check after Cursor but before Claude +}); + +// Register OpenCode provider +registerProvider('opencode', { + factory: () => new OpencodeProvider(), + canHandleModel: (model: string) => isOpencodeModel(model), + priority: 3, // Between codex (5) and claude (0) +}); + +// Register Gemini provider +registerProvider('gemini', { + factory: () => new GeminiProvider(), + aliases: ['google'], + canHandleModel: (model: string) => isGeminiModel(model), + priority: 4, // Between opencode (3) and codex (5) +}); + +// Register Copilot provider (GitHub Copilot SDK) +registerProvider('copilot', { + factory: () => new CopilotProvider(), + aliases: ['github-copilot', 'github'], + canHandleModel: (model: string) => isCopilotModel(model), + priority: 6, // High priority - check before Codex since both can handle GPT models +}); diff --git a/jules_branch/apps/server/src/providers/simple-query-service.ts b/jules_branch/apps/server/src/providers/simple-query-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ebe4db974f91423d875d107e161bb3c20fb381c --- /dev/null +++ b/jules_branch/apps/server/src/providers/simple-query-service.ts @@ -0,0 +1,273 @@ +/** + * Simple Query Service - Simplified interface for basic AI queries + * + * Use this for routes that need simple text responses without + * complex event handling. This service abstracts away the provider + * selection and streaming details, providing a clean interface + * for common query patterns. + * + * Benefits: + * - No direct SDK imports needed in route files + * - Consistent provider routing based on model + * - Automatic text extraction from streaming responses + * - Structured output support for JSON schema responses + * - Eliminates duplicate extractTextFromStream() functions + */ + +import { ProviderFactory } from './provider-factory.js'; +import type { + ThinkingLevel, + ReasoningEffort, + ClaudeApiProfile, + ClaudeCompatibleProvider, + Credentials, +} from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; + +/** + * Options for simple query execution + */ +export interface SimpleQueryOptions { + /** The prompt to send to the AI (can be text or multi-part content) */ + prompt: string | Array<{ type: string; text?: string; source?: object }>; + /** Model to use (with or without provider prefix) */ + model?: string; + /** Working directory for the query */ + cwd: string; + /** System prompt (combined with user prompt for some providers) */ + systemPrompt?: string; + /** Maximum turns for agentic operations (default: 1) */ + maxTurns?: number; + /** Tools to allow (default: [] for simple queries) */ + allowedTools?: string[]; + /** Abort controller for cancellation */ + abortController?: AbortController; + /** Structured output format for JSON responses */ + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; + /** Thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex/OpenAI models */ + reasoningEffort?: ReasoningEffort; + /** If true, runs in read-only mode (no file writes) */ + readOnly?: boolean; + /** Setting sources for CLAUDE.md loading */ + settingSources?: Array<'user' | 'project' | 'local'>; + /** + * Active Claude API profile for alternative endpoint configuration + * @deprecated Use claudeCompatibleProvider instead + */ + claudeApiProfile?: ClaudeApiProfile; + /** + * Claude-compatible provider for alternative endpoint configuration. + * Takes precedence over claudeApiProfile if both are set. + */ + claudeCompatibleProvider?: ClaudeCompatibleProvider; + /** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */ + credentials?: Credentials; +} + +/** + * Result from a simple query + */ +export interface SimpleQueryResult { + /** The accumulated text response */ + text: string; + /** Structured output if outputFormat was specified and provider supports it */ + structured_output?: Record; +} + +/** + * Options for streaming query execution + */ +export interface StreamingQueryOptions extends SimpleQueryOptions { + /** Callback for each text chunk received */ + onText?: (text: string) => void; + /** Callback for tool use events */ + onToolUse?: (tool: string, input: unknown) => void; + /** Callback for thinking blocks (if available) */ + onThinking?: (thinking: string) => void; +} + +/** + * Default model to use when none specified + */ +const DEFAULT_MODEL = 'claude-sonnet-4-6'; + +/** + * Execute a simple query and return the text result + * + * Use this for simple, non-streaming queries where you just need + * the final text response. For more complex use cases with progress + * callbacks, use streamingQuery() instead. + * + * @example + * ```typescript + * const result = await simpleQuery({ + * prompt: 'Generate a title for: user authentication', + * cwd: process.cwd(), + * systemPrompt: 'You are a title generator...', + * maxTurns: 1, + * allowedTools: [], + * }); + * console.log(result.text); // "Add user authentication" + * ``` + */ +export async function simpleQuery(options: SimpleQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: bareModel, + originalModel: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 1, + allowedTools: options.allowedTools ?? [], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + readOnly: options.readOnly, + settingSources: options.settingSources, + claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence) + credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract text from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} + +/** + * Execute a streaming query with event callbacks + * + * Use this for queries where you need real-time progress updates, + * such as when displaying streaming output to a user. + * + * @example + * ```typescript + * const result = await streamingQuery({ + * prompt: 'Analyze this project and suggest improvements', + * cwd: '/path/to/project', + * maxTurns: 250, + * allowedTools: ['Read', 'Glob', 'Grep'], + * onText: (text) => emitProgress(text), + * onToolUse: (tool, input) => emitToolUse(tool, input), + * }); + * ``` + */ +export async function streamingQuery(options: StreamingQueryOptions): Promise { + const model = options.model || DEFAULT_MODEL; + const provider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + let responseText = ''; + let structuredOutput: Record | undefined; + + // Build provider options + const providerOptions = { + prompt: options.prompt, + model: bareModel, + originalModel: model, + cwd: options.cwd, + systemPrompt: options.systemPrompt, + maxTurns: options.maxTurns ?? 250, + allowedTools: options.allowedTools ?? ['Read', 'Glob', 'Grep'], + abortController: options.abortController, + outputFormat: options.outputFormat, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + readOnly: options.readOnly, + settingSources: options.settingSources, + claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration + claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence) + credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource + }; + + for await (const msg of provider.executeQuery(providerOptions)) { + // Handle error messages + if (msg.type === 'error') { + const errorMessage = msg.error || 'Provider returned an error'; + throw new Error(errorMessage); + } + + // Extract content from assistant messages + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + options.onText?.(block.text); + } else if (block.type === 'tool_use' && block.name) { + options.onToolUse?.(block.name, block.input); + } else if (block.type === 'thinking' && block.thinking) { + options.onThinking?.(block.thinking); + } + } + } + + // Handle result messages + if (msg.type === 'result') { + if (msg.subtype === 'success') { + // Use result text if longer than accumulated text + if (msg.result && msg.result.length > responseText.length) { + responseText = msg.result; + } + // Capture structured output if present + if (msg.structured_output) { + structuredOutput = msg.structured_output; + } + } else if (msg.subtype === 'error_max_turns') { + // Max turns reached - return what we have + break; + } else if (msg.subtype === 'error_max_structured_output_retries') { + throw new Error('Could not produce valid structured output after retries'); + } + } + } + + return { text: responseText, structured_output: structuredOutput }; +} diff --git a/jules_branch/apps/server/src/providers/tool-normalization.ts b/jules_branch/apps/server/src/providers/tool-normalization.ts new file mode 100644 index 0000000000000000000000000000000000000000..27442a7a22cd919d601b31167173a37e7793e4f7 --- /dev/null +++ b/jules_branch/apps/server/src/providers/tool-normalization.ts @@ -0,0 +1,112 @@ +/** + * Shared tool normalization utilities for AI providers + * + * These utilities help normalize tool inputs from various AI providers + * to the standard format expected by the application. + */ + +/** + * Valid todo status values in the standard format + */ +type TodoStatus = 'pending' | 'in_progress' | 'completed'; + +/** + * Set of valid status values for validation + */ +const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed']); + +/** + * Todo item from various AI providers (Gemini, Copilot, etc.) + */ +interface ProviderTodo { + description?: string; + content?: string; + status?: string; +} + +/** + * Standard todo format used by the application + */ +interface NormalizedTodo { + content: string; + status: TodoStatus; + activeForm: string; +} + +/** + * Normalize a provider status value to a valid TodoStatus + */ +function normalizeStatus(status: string | undefined): TodoStatus { + if (!status) return 'pending'; + if (status === 'cancelled' || status === 'canceled') return 'completed'; + if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus; + return 'pending'; +} + +/** + * Normalize todos array from provider format to standard format + * + * Handles different formats from providers: + * - Gemini: { description, status } with 'cancelled' as possible status + * - Copilot: { content/description, status } with 'cancelled' as possible status + * + * Output format (Claude/Standard): + * - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed' + */ +export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] { + if (!todos) return []; + return todos.map((todo) => ({ + content: todo.content || todo.description || '', + status: normalizeStatus(todo.status), + // Use content/description as activeForm since providers may not have it + activeForm: todo.content || todo.description || '', + })); +} + +/** + * Normalize file path parameters from various provider formats + * + * Different providers use different parameter names for file paths: + * - path, file, filename, filePath -> file_path + */ +export function normalizeFilePathInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.file_path) { + if (input.path) normalized.file_path = input.path; + else if (input.file) normalized.file_path = input.file; + else if (input.filename) normalized.file_path = input.filename; + else if (input.filePath) normalized.file_path = input.filePath; + } + return normalized; +} + +/** + * Normalize shell command parameters from various provider formats + * + * Different providers use different parameter names for commands: + * - cmd, script -> command + */ +export function normalizeCommandInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.command) { + if (input.cmd) normalized.command = input.cmd; + else if (input.script) normalized.command = input.script; + } + return normalized; +} + +/** + * Normalize search pattern parameters from various provider formats + * + * Different providers use different parameter names for search patterns: + * - query, search, regex -> pattern + */ +export function normalizePatternInput(input: Record): Record { + const normalized = { ...input }; + if (!normalized.pattern) { + if (input.query) normalized.pattern = input.query; + else if (input.search) normalized.pattern = input.search; + else if (input.regex) normalized.pattern = input.regex; + } + return normalized; +} diff --git a/jules_branch/apps/server/src/providers/types.ts b/jules_branch/apps/server/src/providers/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d439091bbeb7130ae8d74814cc3feec97dec082 --- /dev/null +++ b/jules_branch/apps/server/src/providers/types.ts @@ -0,0 +1,25 @@ +/** + * Shared types for AI model providers + * + * Re-exports types from @automaker/types for consistency across the codebase. + * All provider types are defined in @automaker/types to avoid duplication. + */ + +// Re-export all provider types from @automaker/types +export type { + ProviderConfig, + ConversationMessage, + ExecuteOptions, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, + ContentBlock, + ProviderMessage, + InstallationStatus, + ValidationResult, + ModelDefinition, + AgentDefinition, + ReasoningEffort, + SystemPromptPreset, +} from '@automaker/types'; diff --git a/jules_branch/apps/server/src/routes/agent/common.ts b/jules_branch/apps/server/src/routes/agent/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b24a76a7cd0861b4f94c8e1666dfe8ff4bb9513 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for agent routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Agent'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/agent/index.ts b/jules_branch/apps/server/src/routes/agent/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e27c2ecc0aefc057c3a7ed72977e7a68aeab24d --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/index.ts @@ -0,0 +1,45 @@ +/** + * Agent routes - HTTP API for Claude agent interactions + */ + +import { Router } from 'express'; +import { AgentService } from '../../services/agent-service.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createStartHandler } from './routes/start.js'; +import { createSendHandler } from './routes/send.js'; +import { createHistoryHandler } from './routes/history.js'; +import { createStopHandler } from './routes/stop.js'; +import { createClearHandler } from './routes/clear.js'; +import { createModelHandler } from './routes/model.js'; +import { createQueueAddHandler } from './routes/queue-add.js'; +import { createQueueListHandler } from './routes/queue-list.js'; +import { createQueueRemoveHandler } from './routes/queue-remove.js'; +import { createQueueClearHandler } from './routes/queue-clear.js'; + +export function createAgentRoutes(agentService: AgentService, _events: EventEmitter): Router { + const router = Router(); + + router.post('/start', validatePathParams('workingDirectory?'), createStartHandler(agentService)); + router.post( + '/send', + validatePathParams('workingDirectory?', 'imagePaths[]'), + createSendHandler(agentService) + ); + router.post('/history', createHistoryHandler(agentService)); + router.post('/stop', createStopHandler(agentService)); + router.post('/clear', createClearHandler(agentService)); + router.post('/model', createModelHandler(agentService)); + + // Queue routes + router.post( + '/queue/add', + validatePathParams('imagePaths[]'), + createQueueAddHandler(agentService) + ); + router.post('/queue/list', createQueueListHandler(agentService)); + router.post('/queue/remove', createQueueRemoveHandler(agentService)); + router.post('/queue/clear', createQueueClearHandler(agentService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/clear.ts b/jules_branch/apps/server/src/routes/agent/routes/clear.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ee605b60e16b47b183dbb95db46e63fa2cd2b76 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/clear.ts @@ -0,0 +1,26 @@ +/** + * POST /clear endpoint - Clear conversation + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createClearHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const result = await agentService.clearSession(sessionId); + res.json(result); + } catch (error) { + logError(error, 'Clear session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/history.ts b/jules_branch/apps/server/src/routes/agent/routes/history.ts new file mode 100644 index 0000000000000000000000000000000000000000..e11578d71deb3d872a94967ba402f4527b52483b --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/history.ts @@ -0,0 +1,26 @@ +/** + * POST /history endpoint - Get conversation history + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createHistoryHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const result = await agentService.getHistory(sessionId); + res.json(result); + } catch (error) { + logError(error, 'Get history failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/model.ts b/jules_branch/apps/server/src/routes/agent/routes/model.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e1a1dddfba882adae4e29c9f180332568ead6f0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/model.ts @@ -0,0 +1,29 @@ +/** + * POST /model endpoint - Set session model + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createModelHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, model } = req.body as { + sessionId: string; + model: string; + }; + + if (!sessionId || !model) { + res.status(400).json({ success: false, error: 'sessionId and model are required' }); + return; + } + + const result = await agentService.setSessionModel(sessionId, model); + res.json({ success: result }); + } catch (error) { + logError(error, 'Set session model failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/queue-add.ts b/jules_branch/apps/server/src/routes/agent/routes/queue-add.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5b8a875ad8a993f41a5154b04a3eb396fd8723a --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/queue-add.ts @@ -0,0 +1,41 @@ +/** + * POST /queue/add endpoint - Add a prompt to the queue + */ + +import type { Request, Response } from 'express'; +import type { ThinkingLevel } from '@automaker/types'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueAddHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, imagePaths, model, thinkingLevel } = req.body as { + sessionId: string; + message: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + }; + + if (!sessionId || !message) { + res.status(400).json({ + success: false, + error: 'sessionId and message are required', + }); + return; + } + + const result = await agentService.addToQueue(sessionId, { + message, + imagePaths, + model, + thinkingLevel, + }); + res.json(result); + } catch (error) { + logError(error, 'Add to queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/queue-clear.ts b/jules_branch/apps/server/src/routes/agent/routes/queue-clear.ts new file mode 100644 index 0000000000000000000000000000000000000000..34969eab660acc0b6f6f472c9af58861c609dd14 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/queue-clear.ts @@ -0,0 +1,29 @@ +/** + * POST /queue/clear endpoint - Clear all prompts from the queue + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueClearHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required', + }); + return; + } + + const result = await agentService.clearQueue(sessionId); + res.json(result); + } catch (error) { + logError(error, 'Clear queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/queue-list.ts b/jules_branch/apps/server/src/routes/agent/routes/queue-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..7299e871693e73ce42d0e8a853392c152d80be5e --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/queue-list.ts @@ -0,0 +1,29 @@ +/** + * POST /queue/list endpoint - List queued prompts + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueListHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required', + }); + return; + } + + const result = await agentService.getQueue(sessionId); + res.json(result); + } catch (error) { + logError(error, 'List queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/queue-remove.ts b/jules_branch/apps/server/src/routes/agent/routes/queue-remove.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2ed43d832fa31481d17d8455a367579d57ef45b --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/queue-remove.ts @@ -0,0 +1,32 @@ +/** + * POST /queue/remove endpoint - Remove a prompt from the queue + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createQueueRemoveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, promptId } = req.body as { + sessionId: string; + promptId: string; + }; + + if (!sessionId || !promptId) { + res.status(400).json({ + success: false, + error: 'sessionId and promptId are required', + }); + return; + } + + const result = await agentService.removeFromQueue(sessionId, promptId); + res.json(result); + } catch (error) { + logError(error, 'Remove from queue failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/send.ts b/jules_branch/apps/server/src/routes/agent/routes/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f6e527cc32c2b9cb5c754009cdad15445fd31a2 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/send.ts @@ -0,0 +1,78 @@ +/** + * POST /send endpoint - Send a message + */ + +import type { Request, Response } from 'express'; +import type { ThinkingLevel } from '@automaker/types'; +import { AgentService } from '../../../services/agent-service.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; +const logger = createLogger('Agent'); + +export function createSendHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, workingDirectory, imagePaths, model, thinkingLevel } = + req.body as { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + }; + + logger.debug('Received request:', { + sessionId, + messageLength: message?.length, + workingDirectory, + imageCount: imagePaths?.length || 0, + model, + thinkingLevel, + }); + + if (!sessionId || !message) { + logger.warn('Validation failed - missing sessionId or message'); + res.status(400).json({ + success: false, + error: 'sessionId and message are required', + }); + return; + } + + logger.debug('Validation passed, calling agentService.sendMessage()'); + + // Start the message processing (don't await - it streams via WebSocket) + agentService + .sendMessage({ + sessionId, + message, + workingDirectory, + imagePaths, + model, + thinkingLevel, + }) + .catch((error) => { + const errorMsg = (error as Error).message || 'Unknown error'; + logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg); + + // Emit error via WebSocket so the UI is notified even though + // the HTTP response already returned 200. This is critical for + // session-not-found errors where sendMessage() throws before it + // can emit its own error event (no in-memory session to emit from). + agentService.emitSessionError(sessionId, errorMsg); + + logError(error, 'Send message failed (background)'); + }); + + logger.debug('Returning immediate response to client'); + + // Return immediately - responses come via WebSocket + res.json({ success: true, message: 'Message sent' }); + } catch (error) { + logger.error('Synchronous error:', error); + logError(error, 'Send message failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/start.ts b/jules_branch/apps/server/src/routes/agent/routes/start.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd9b7e4194134aba14dab100d5627e84f71e0fb8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/start.ts @@ -0,0 +1,35 @@ +/** + * POST /start endpoint - Start a conversation + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; +const _logger = createLogger('Agent'); + +export function createStartHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, workingDirectory } = req.body as { + sessionId: string; + workingDirectory?: string; + }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const result = await agentService.startConversation({ + sessionId, + workingDirectory, + }); + + res.json(result); + } catch (error) { + logError(error, 'Start conversation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/agent/routes/stop.ts b/jules_branch/apps/server/src/routes/agent/routes/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5a5fe014b1e043b7d09d9f23d8c70c2fac13a17 --- /dev/null +++ b/jules_branch/apps/server/src/routes/agent/routes/stop.ts @@ -0,0 +1,26 @@ +/** + * POST /stop endpoint - Stop execution + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStopHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.body as { sessionId: string }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const result = await agentService.stopExecution(sessionId); + res.json(result); + } catch (error) { + logError(error, 'Stop execution failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/common.ts b/jules_branch/apps/server/src/routes/app-spec/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..0731a7ddd71b5fd87d0a0500a8c463fb4bc132cc --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/common.ts @@ -0,0 +1,140 @@ +/** + * Common utilities and state management for spec regeneration + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('SpecRegeneration'); + +// Types for running generation +export type GenerationType = 'spec_regeneration' | 'feature_generation' | 'sync'; + +interface RunningGeneration { + isRunning: boolean; + type: GenerationType; + startedAt: string; +} + +// Shared state for tracking generation status - scoped by project path +const runningProjects = new Map(); +const abortControllers = new Map(); + +/** + * Get the running state for a specific project + */ +export function getSpecRegenerationStatus(projectPath?: string): { + isRunning: boolean; + currentAbortController: AbortController | null; + projectPath?: string; + type?: GenerationType; + startedAt?: string; +} { + if (projectPath) { + const generation = runningProjects.get(projectPath); + return { + isRunning: generation?.isRunning || false, + currentAbortController: abortControllers.get(projectPath) || null, + projectPath, + type: generation?.type, + startedAt: generation?.startedAt, + }; + } + // Fallback: check if any project is running (for backward compatibility) + const isAnyRunning = Array.from(runningProjects.values()).some((g) => g.isRunning); + return { isRunning: isAnyRunning, currentAbortController: null }; +} + +/** + * Get the project path that is currently running (if any) + */ +export function getRunningProjectPath(): string | null { + for (const [path, running] of runningProjects.entries()) { + if (running) return path; + } + return null; +} + +/** + * Set the running state and abort controller for a specific project + */ +export function setRunningState( + projectPath: string, + running: boolean, + controller: AbortController | null = null, + type: GenerationType = 'spec_regeneration' +): void { + if (running) { + runningProjects.set(projectPath, { + isRunning: true, + type, + startedAt: new Date().toISOString(), + }); + if (controller) { + abortControllers.set(projectPath, controller); + } + } else { + runningProjects.delete(projectPath); + abortControllers.delete(projectPath); + } +} + +/** + * Get all running spec/feature generations for the running agents view + */ +export function getAllRunningGenerations(): Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; +}> { + const results: Array<{ + projectPath: string; + type: GenerationType; + startedAt: string; + }> = []; + + for (const [projectPath, generation] of runningProjects.entries()) { + if (generation.isRunning) { + results.push({ + projectPath, + type: generation.type, + startedAt: generation.startedAt, + }); + } + } + + return results; +} + +/** + * Helper to log authentication status + */ +export function logAuthStatus(context: string): void { + const hasApiKey = !!process.env.ANTHROPIC_API_KEY; + + logger.info(`${context} - Auth Status:`); + logger.info( + ` ANTHROPIC_API_KEY: ${ + hasApiKey ? 'SET (' + process.env.ANTHROPIC_API_KEY?.substring(0, 20) + '...)' : 'NOT SET' + }` + ); + + if (!hasApiKey) { + logger.warn('⚠️ WARNING: No authentication configured! SDK will fail.'); + } +} + +/** + * Log error details consistently + */ +export function logError(error: unknown, context: string): void { + logger.error(`❌ ${context}:`); + logger.error('Error name:', (error as Error)?.name); + logger.error('Error message:', (error as Error)?.message); + logger.error('Error stack:', (error as Error)?.stack); + logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); +} + +import { getErrorMessage as getErrorMessageShared } from '../common.js'; + +// Re-export shared utility +export { getErrorMessageShared as getErrorMessage }; diff --git a/jules_branch/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/jules_branch/apps/server/src/routes/app-spec/generate-features-from-spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..562484714cc775be0401d13a12fdf1d1c2d16dcb --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -0,0 +1,329 @@ +/** + * Generate features from existing app_spec.txt + * + * Model is configurable via phaseModels.featureGenerationModel in settings + * (defaults to Sonnet for balanced speed and quality). + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { streamingQuery } from '../../providers/simple-query-service.js'; +import { parseAndCreateFeatures } from './parse-and-create-features.js'; +import { extractJsonWithArray } from '../../lib/json-extractor.js'; +import { getAppSpecPath } from '@automaker/platform'; +import type { SettingsService } from '../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getPhaseModelWithOverrides, +} from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; + +const logger = createLogger('SpecRegeneration'); + +const DEFAULT_MAX_FEATURES = 50; + +/** + * Timeout for Codex models when generating features (5 minutes). + * Codex models are slower and need more time to generate 50+ features. + */ +const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes + +/** + * Type for extracted features JSON response + */ +interface FeaturesExtractionResult { + features: Array<{ + id: string; + category?: string; + title: string; + description: string; + priority?: number; + complexity?: 'simple' | 'moderate' | 'complex'; + dependencies?: string[]; + }>; +} + +/** + * JSON schema for features output format (Claude/Codex structured output) + */ +const featuresOutputSchema = { + type: 'object', + properties: { + features: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Unique feature identifier (kebab-case)' }, + category: { type: 'string', description: 'Feature category' }, + title: { type: 'string', description: 'Short, descriptive title' }, + description: { type: 'string', description: 'Detailed feature description' }, + priority: { + type: 'number', + description: 'Priority level: 1 (highest) to 5 (lowest)', + }, + complexity: { + type: 'string', + enum: ['simple', 'moderate', 'complex'], + description: 'Implementation complexity', + }, + dependencies: { + type: 'array', + items: { type: 'string' }, + description: 'IDs of features this depends on', + }, + }, + required: ['id', 'title', 'description'], + }, + }, + }, + required: ['features'], +} as const; + +export async function generateFeaturesFromSpec( + projectPath: string, + events: EventEmitter, + abortController: AbortController, + maxFeatures?: number, + settingsService?: SettingsService +): Promise { + const featureCount = maxFeatures ?? DEFAULT_MAX_FEATURES; + logger.debug('========== generateFeaturesFromSpec() started =========='); + logger.debug('projectPath:', projectPath); + logger.debug('maxFeatures:', featureCount); + + // Read existing spec from .automaker directory + const specPath = getAppSpecPath(projectPath); + let spec: string; + + logger.debug('Reading spec from:', specPath); + + try { + spec = (await secureFs.readFile(specPath, 'utf-8')) as string; + logger.info(`Spec loaded successfully (${spec.length} chars)`); + logger.info(`Spec preview (first 500 chars): ${spec.substring(0, 500)}`); + logger.info(`Spec preview (last 500 chars): ${spec.substring(spec.length - 500)}`); + } catch (readError) { + logger.error('❌ Failed to read spec file:', readError); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: 'No project spec found. Generate spec first.', + projectPath: projectPath, + }); + return; + } + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[FeatureGeneration]'); + + // Load existing features to prevent duplicates + const featureLoader = new FeatureLoader(); + const existingFeatures = await featureLoader.getAll(projectPath); + + logger.info(`Found ${existingFeatures.length} existing features to exclude from generation`); + + // Build existing features context for the prompt + let existingFeaturesContext = ''; + if (existingFeatures.length > 0) { + const featuresList = existingFeatures + .map( + (f) => + `- "${f.title}" (ID: ${f.id}): ${f.description?.substring(0, 100) || 'No description'}` + ) + .join('\n'); + existingFeaturesContext = ` + +## EXISTING FEATURES (DO NOT REGENERATE THESE) + +The following ${existingFeatures.length} features already exist in the project. You MUST NOT generate features that duplicate or overlap with these: + +${featuresList} + +CRITICAL INSTRUCTIONS: +- DO NOT generate any features with the same or similar titles as the existing features listed above +- DO NOT generate features that cover the same functionality as existing features +- ONLY generate NEW features that are not yet in the system +- If a feature from the roadmap already exists, skip it entirely +- Generate unique feature IDs that do not conflict with existing IDs: ${existingFeatures.map((f) => f.id).join(', ')} +`; + } + + const prompt = `Based on this project specification: + +${spec} +${existingFeaturesContext} +${prompts.appSpec.generateFeaturesFromSpecPrompt} + +Generate ${featureCount} NEW features that build on each other logically. Remember: ONLY generate features that DO NOT already exist.`; + + logger.info('========== PROMPT BEING SENT =========='); + logger.info(`Prompt length: ${prompt.length} chars`); + logger.info(`Prompt preview (first 1000 chars):\n${prompt.substring(0, 1000)}`); + logger.info('========== END PROMPT PREVIEW =========='); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Analyzing spec and generating features...\n', + projectPath: projectPath, + }); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[FeatureGeneration]' + ); + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'featureGenerationModel', + settingsService, + projectPath, + '[FeatureGeneration]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel, + provider: undefined, + credentials: undefined, + }; + const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry); + + logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API'); + + // Codex models need extended timeout for generating many features. + // Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s). + // The Codex provider has a special 5-minute base timeout for feature generation. + const isCodex = isCodexModel(model); + const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort; + + if (isCodex) { + logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)'); + } + if (effectiveReasoningEffort) { + logger.info('Reasoning effort:', effectiveReasoningEffort); + } + + // Determine if we should use structured output based on model type + const useStructuredOutput = supportsStructuredOutput(model); + logger.info( + `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}` + ); + + // Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} + +CRITICAL INSTRUCTIONS: +1. DO NOT write any files. Return the JSON in your response only. +2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. +3. The JSON must have this exact structure: +{ + "features": [ + { + "id": "unique-feature-id", + "category": "Category Name", + "title": "Short Feature Title", + "description": "Detailed description of the feature", + "priority": 1, + "complexity": "simple|moderate|complex", + "dependencies": ["other-feature-id"] + } + ] +} + +4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export") +5. Priority ranges from 1 (highest) to 5 (lowest) +6. Complexity must be one of: "simple", "moderate", "complex" +7. Dependencies is an array of feature IDs that must be completed first (can be empty) + +Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; + } + + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models + readOnly: true, // Feature generation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: featuresOutputSchema, + } + : undefined, + onText: (text) => { + logger.debug(`Feature text block received (${text.length} chars)`); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + }); + + // Get response content - prefer structured output if available + let contentForParsing: string; + + if (result.structured_output) { + // Use structured output from Claude/Codex models + logger.info('✅ Received structured output from model'); + contentForParsing = JSON.stringify(result.structured_output); + logger.debug('Structured output:', contentForParsing); + } else { + // Use text response (for non-Claude/Codex models or fallback) + // Pre-extract JSON to handle conversational text that may surround the JSON response + // This follows the same pattern used in generate-spec.ts and validate-issue.ts + const rawText = result.text; + logger.info(`Feature stream complete.`); + logger.info(`Feature response length: ${rawText.length} chars`); + logger.info('========== FULL RESPONSE TEXT =========='); + logger.info(rawText); + logger.info('========== END RESPONSE TEXT =========='); + + // Pre-extract JSON from response - handles conversational text around the JSON + const extracted = extractJsonWithArray(rawText, 'features', { + logger, + }); + if (extracted) { + contentForParsing = JSON.stringify(extracted); + logger.info('✅ Pre-extracted JSON from text response'); + } else { + // If pre-extraction fails, we know the next step will also fail. + // Throw an error here to avoid redundant parsing and make the failure point clearer. + logger.error( + '❌ Could not extract features JSON from model response. Full response text was:\n' + + rawText + ); + const errorMessage = + 'Failed to parse features from model response: No valid JSON with a "features" array found.'; + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: errorMessage, + projectPath: projectPath, + }); + throw new Error(errorMessage); + } + } + + await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService); + + logger.debug('========== generateFeaturesFromSpec() completed =========='); +} diff --git a/jules_branch/apps/server/src/routes/app-spec/generate-spec.ts b/jules_branch/apps/server/src/routes/app-spec/generate-spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd47e9ea4ead3641e7faa505ad684fd97f92971a --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/generate-spec.ts @@ -0,0 +1,317 @@ +/** + * Generate app_spec.txt from project overview + * + * Model is configurable via phaseModels.specGenerationModel in settings + * (defaults to Opus for high-quality specification generation). + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { extractJson } from '../../lib/json-extractor.js'; +import { streamingQuery } from '../../providers/simple-query-service.js'; +import { generateFeaturesFromSpec } from './generate-features-from-spec.js'; +import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform'; +import type { SettingsService } from '../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getPhaseModelWithOverrides, +} from '../../lib/settings-helpers.js'; + +const logger = createLogger('SpecRegeneration'); + +export async function generateSpec( + projectPath: string, + projectOverview: string, + events: EventEmitter, + abortController: AbortController, + generateFeatures?: boolean, + analyzeProject?: boolean, + maxFeatures?: number, + settingsService?: SettingsService +): Promise { + logger.info('========== generateSpec() started =========='); + logger.info('projectPath:', projectPath); + logger.info('projectOverview length:', `${projectOverview.length} chars`); + logger.info('projectOverview preview:', projectOverview.substring(0, 300)); + logger.info('generateFeatures:', generateFeatures); + logger.info('analyzeProject:', analyzeProject); + logger.info('maxFeatures:', maxFeatures); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[SpecRegeneration]'); + + // Build the prompt based on whether we should analyze the project + let analysisInstructions = ''; + let techStackDefaults = ''; + + if (analyzeProject !== false) { + // Default to true - analyze the project + analysisInstructions = `Based on this overview, analyze the project directory (if it exists) using the Read, Glob, and Grep tools to understand: +- Existing technologies and frameworks +- Project structure and architecture +- Current features and capabilities +- Code patterns and conventions`; + } else { + // Use default tech stack + techStackDefaults = `Default Technology Stack: +- Framework: TanStack Start (React-based full-stack framework) +- Database: PostgreSQL with Drizzle ORM +- UI Components: shadcn/ui +- Styling: Tailwind CSS +- Frontend: React + +Use these technologies as the foundation for the specification.`; + } + + const prompt = `${prompts.appSpec.generateSpecSystemPrompt} + +Project Overview: +${projectOverview} + +${techStackDefaults} + +${analysisInstructions} + +${prompts.appSpec.structuredSpecInstructions}`; + + logger.info('========== PROMPT BEING SENT =========='); + logger.info(`Prompt length: ${prompt.length} chars`); + logger.info(`Prompt preview (first 500 chars):\n${prompt.substring(0, 500)}`); + logger.info('========== END PROMPT PREVIEW =========='); + + events.emit('spec-regeneration:event', { + type: 'spec_progress', + content: 'Starting spec generation...\n', + }); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[SpecRegeneration]' + ); + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'specGenerationModel', + settingsService, + projectPath, + '[SpecRegeneration]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel, + provider: undefined, + credentials: undefined, + }; + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API'); + + let responseText = ''; + let structuredOutput: SpecOutput | null = null; + + // Determine if we should use structured output based on model type + const useStructuredOutput = supportsStructuredOutput(model); + logger.info( + `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}` + ); + + // Build the final prompt - for non-Claude/Codex models, include JSON schema instructions + let finalPrompt = prompt; + if (!useStructuredOutput) { + finalPrompt = `${prompt} + +CRITICAL INSTRUCTIONS: +1. DO NOT write any files. DO NOT create any files like "project_specification.json". +2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. +3. The JSON must match this exact schema: + +${JSON.stringify(specOutputSchema, null, 2)} + +Your entire response should be valid JSON starting with { and ending with }. No text before or after.`; + } + + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model, + cwd: projectPath, + maxTurns: 250, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, // Spec generation only reads code, we write the spec ourselves + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: specOutputSchema, + } + : undefined, + onText: (text) => { + responseText += text; + logger.info( + `Text block received (${text.length} chars), total now: ${responseText.length} chars` + ); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: text, + projectPath: projectPath, + }); + }, + onToolUse: (tool, input) => { + logger.info('Tool use:', tool); + events.emit('spec-regeneration:event', { + type: 'spec_tool', + tool, + input, + }); + }, + }); + + // Get structured output if available + if (result.structured_output) { + structuredOutput = result.structured_output as unknown as SpecOutput; + logger.info('✅ Received structured output'); + logger.debug('Structured output:', JSON.stringify(structuredOutput, null, 2)); + } else if (!useStructuredOutput && responseText) { + // For non-Claude providers, parse JSON from response text + structuredOutput = extractJson(responseText, { logger }); + } + + logger.info(`Stream iteration complete.`); + logger.info(`Response text length: ${responseText.length} chars`); + + // Determine XML content to save + let xmlContent: string; + + if (structuredOutput) { + // Use structured output - convert JSON to XML + logger.info('✅ Using structured output for XML generation'); + xmlContent = specToXml(structuredOutput); + logger.info(`Generated XML from structured output: ${xmlContent.length} chars`); + } else { + // Fallback: Extract XML content from response text + // Claude might include conversational text before/after + // See: https://github.com/AutoMaker-Org/automaker/issues/149 + logger.warn('⚠️ No structured output, falling back to text parsing'); + logger.info('========== FINAL RESPONSE TEXT =========='); + logger.info(responseText || '(empty)'); + logger.info('========== END RESPONSE TEXT =========='); + + if (!responseText || responseText.trim().length === 0) { + throw new Error('No response text and no structured output - cannot generate spec'); + } + + const xmlStart = responseText.indexOf(''); + const xmlEnd = responseText.lastIndexOf(''); + + if (xmlStart !== -1 && xmlEnd !== -1) { + // Extract just the XML content, discarding any conversational text before/after + xmlContent = responseText.substring(xmlStart, xmlEnd + ''.length); + logger.info(`Extracted XML content: ${xmlContent.length} chars (from position ${xmlStart})`); + } else { + // No XML found, try JSON extraction + logger.warn('⚠️ No XML tags found, attempting JSON extraction...'); + const extractedJson = extractJson(responseText, { logger }); + + if ( + extractedJson && + typeof extractedJson.project_name === 'string' && + typeof extractedJson.overview === 'string' && + Array.isArray(extractedJson.technology_stack) && + Array.isArray(extractedJson.core_capabilities) && + Array.isArray(extractedJson.implemented_features) + ) { + logger.info('✅ Successfully extracted JSON from response text'); + xmlContent = specToXml(extractedJson); + logger.info(`✅ Converted extracted JSON to XML: ${xmlContent.length} chars`); + } else { + // Neither XML nor valid JSON found + logger.error('❌ Response does not contain valid XML or JSON structure'); + logger.error( + 'This typically happens when structured output failed and the agent produced conversational text instead of structured output' + ); + throw new Error( + 'Failed to generate spec: No valid XML or JSON structure found in response. ' + + 'The response contained conversational text but no tags or valid JSON. ' + + 'Please try again.' + ); + } + } + } + + // Save spec to .automaker directory + await ensureAutomakerDir(projectPath); + const specPath = getAppSpecPath(projectPath); + + logger.info('Saving spec to:', specPath); + logger.info(`Content to save (${xmlContent.length} chars)`); + + await secureFs.writeFile(specPath, xmlContent); + + // Verify the file was written + const savedContent = await secureFs.readFile(specPath, 'utf-8'); + logger.info(`Verified saved file: ${savedContent.length} chars`); + if (savedContent.length === 0) { + logger.error('❌ File was saved but is empty!'); + } + + logger.info('Spec saved successfully'); + + // Emit spec completion event + if (generateFeatures) { + // If features will be generated, emit intermediate completion + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: '[Phase: spec_complete] Spec created! Generating features...\n', + projectPath: projectPath, + }); + } else { + // If no features, emit final completion + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: 'Spec regeneration complete!', + projectPath: projectPath, + }); + } + + // If generate features was requested, generate them from the spec + if (generateFeatures) { + logger.info('Starting feature generation from spec...'); + // Create a new abort controller for feature generation + const featureAbortController = new AbortController(); + try { + await generateFeaturesFromSpec( + projectPath, + events, + featureAbortController, + maxFeatures, + settingsService + ); + // Final completion will be emitted by generateFeaturesFromSpec -> parseAndCreateFeatures + } catch (featureError) { + logger.error('Feature generation failed:', featureError); + // Don't throw - spec generation succeeded, feature generation is optional + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: (featureError as Error).message || 'Feature generation failed', + projectPath: projectPath, + }); + } + } + + logger.debug('========== generateSpec() completed =========='); +} diff --git a/jules_branch/apps/server/src/routes/app-spec/index.ts b/jules_branch/apps/server/src/routes/app-spec/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..79b8e44de8193c747fe59b73a0bbb4b93390598b --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/index.ts @@ -0,0 +1,29 @@ +/** + * Spec Regeneration routes - HTTP API for AI-powered spec generation + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { createCreateHandler } from './routes/create.js'; +import { createGenerateHandler } from './routes/generate.js'; +import { createGenerateFeaturesHandler } from './routes/generate-features.js'; +import { createSyncHandler } from './routes/sync.js'; +import { createStopHandler } from './routes/stop.js'; +import { createStatusHandler } from './routes/status.js'; +import type { SettingsService } from '../../services/settings-service.js'; + +export function createSpecRegenerationRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { + const router = Router(); + + router.post('/create', createCreateHandler(events)); + router.post('/generate', createGenerateHandler(events, settingsService)); + router.post('/generate-features', createGenerateFeaturesHandler(events, settingsService)); + router.post('/sync', createSyncHandler(events, settingsService)); + router.post('/stop', createStopHandler()); + router.get('/status', createStatusHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/parse-and-create-features.ts b/jules_branch/apps/server/src/routes/app-spec/parse-and-create-features.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7a474d29d7b8d60cc2c29d1f1736d91274bfaa6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -0,0 +1,156 @@ +/** + * Parse agent response and create feature files + */ + +import path from 'path'; +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils'; +import { getFeaturesDir } from '@automaker/platform'; +import { extractJsonWithArray } from '../../lib/json-extractor.js'; +import { getNotificationService } from '../../services/notification-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { resolvePhaseModel } from '@automaker/model-resolver'; + +const logger = createLogger('SpecRegeneration'); + +export async function parseAndCreateFeatures( + projectPath: string, + content: string, + events: EventEmitter, + settingsService?: SettingsService +): Promise { + logger.info('========== parseAndCreateFeatures() started =========='); + logger.info(`Content length: ${content.length} chars`); + logger.info('========== CONTENT RECEIVED FOR PARSING =========='); + logger.info(content); + logger.info('========== END CONTENT =========='); + + // Load default model and planning settings from settingsService + let defaultModel: string | undefined; + let defaultPlanningMode: string = 'skip'; + let defaultRequirePlanApproval = false; + + if (settingsService) { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const projectSettings = await settingsService.getProjectSettings(projectPath); + + const defaultModelEntry = + projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel; + if (defaultModelEntry) { + const resolved = resolvePhaseModel(defaultModelEntry); + defaultModel = resolved.model; + } + + defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip'; + defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false; + + logger.info( + `[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}` + ); + } catch (settingsError) { + logger.warn( + '[parseAndCreateFeatures] Failed to load settings, using defaults:', + settingsError + ); + } + } + + try { + // Extract JSON from response using shared utility + logger.info('Extracting JSON from response using extractJsonWithArray...'); + + interface FeaturesResponse { + features: Array<{ + id: string; + category?: string; + title: string; + description: string; + priority?: number; + complexity?: string; + dependencies?: string[]; + }>; + } + + const parsed = extractJsonWithArray(content, 'features', { logger }); + + if (!parsed || !parsed.features) { + logger.error('❌ No valid JSON with "features" array found in response'); + logger.error('Full content received:'); + logger.error(content); + throw new Error('No valid JSON found in response'); + } + + logger.info(`Parsed ${parsed.features?.length || 0} features`); + logger.info('Parsed features:', JSON.stringify(parsed.features, null, 2)); + + const featuresDir = getFeaturesDir(projectPath); + await secureFs.mkdir(featuresDir, { recursive: true }); + + const createdFeatures: Array<{ id: string; title: string }> = []; + + for (const feature of parsed.features) { + logger.debug('Creating feature:', feature.id); + const featureDir = path.join(featuresDir, feature.id); + await secureFs.mkdir(featureDir, { recursive: true }); + + const featureData: Record = { + id: feature.id, + category: feature.category || 'Uncategorized', + title: feature.title, + description: feature.description, + status: 'backlog', // Features go to backlog - user must manually start them + priority: feature.priority || 2, + complexity: feature.complexity || 'moderate', + dependencies: feature.dependencies || [], + planningMode: defaultPlanningMode, + requirePlanApproval: + defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite' + ? false + : defaultRequirePlanApproval, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Apply default model if available from settings + if (defaultModel) { + featureData.model = defaultModel; + } + + // Use atomic write with backup support for crash protection + await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, { + backupCount: DEFAULT_BACKUP_COUNT, + }); + + createdFeatures.push({ id: feature.id, title: feature.title }); + } + + logger.info(`✓ Created ${createdFeatures.length} features successfully`); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: `Spec regeneration complete! Created ${createdFeatures.length} features.`, + projectPath: projectPath, + }); + + // Create notification for spec generation completion + const notificationService = getNotificationService(); + await notificationService.createNotification({ + type: 'spec_regeneration_complete', + title: 'Spec Generation Complete', + message: `Created ${createdFeatures.length} features from the project specification.`, + projectPath: projectPath, + }); + } catch (error) { + logger.error('❌ parseAndCreateFeatures() failed:'); + logger.error('Error:', error); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: (error as Error).message, + projectPath: projectPath, + }); + } + + logger.debug('========== parseAndCreateFeatures() completed =========='); +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/create.ts b/jules_branch/apps/server/src/routes/app-spec/routes/create.ts new file mode 100644 index 0000000000000000000000000000000000000000..31836867ee29efdfdac0f44caf5a5dd36930860c --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/create.ts @@ -0,0 +1,93 @@ +/** + * POST /create endpoint - Create project spec from overview + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { generateSpec } from '../generate-spec.js'; + +const logger = createLogger('SpecRegeneration'); + +export function createCreateHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /create endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath, projectOverview, generateFeatures, analyzeProject, maxFeatures } = + req.body as { + projectPath: string; + projectOverview: string; + generateFeatures?: boolean; + analyzeProject?: boolean; + maxFeatures?: number; + }; + + logger.debug('Parsed params:'); + logger.debug(' projectPath:', projectPath); + logger.debug(' projectOverview length:', `${projectOverview?.length || 0} chars`); + logger.debug(' generateFeatures:', generateFeatures); + logger.debug(' analyzeProject:', analyzeProject); + logger.debug(' maxFeatures:', maxFeatures); + + if (!projectPath || !projectOverview) { + logger.error('Missing required parameters'); + res.status(400).json({ + success: false, + error: 'projectPath and projectOverview required', + }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); + return; + } + + logAuthStatus('Before starting generation'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController); + logger.info('Starting background generation task...'); + + // Start generation in background + generateSpec( + projectPath, + projectOverview, + events, + abortController, + generateFeatures, + analyzeProject, + maxFeatures + ) + .catch((error) => { + logError(error, 'Generation failed with error'); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: getErrorMessage(error), + projectPath: projectPath, + }); + }) + .finally(() => { + logger.info('Generation task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (generation running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Create spec route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/generate-features.ts b/jules_branch/apps/server/src/routes/app-spec/routes/generate-features.ts new file mode 100644 index 0000000000000000000000000000000000000000..670652ea9915275f5976c5d27047afeb83085dff --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/generate-features.ts @@ -0,0 +1,76 @@ +/** + * POST /generate-features endpoint - Generate features from existing spec + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { generateFeaturesFromSpec } from '../generate-features-from-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +const logger = createLogger('SpecRegeneration'); + +export function createGenerateFeaturesHandler( + events: EventEmitter, + settingsService?: SettingsService +) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /generate-features endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath, maxFeatures } = req.body as { + projectPath: string; + maxFeatures?: number; + }; + + logger.debug('projectPath:', projectPath); + logger.debug('maxFeatures:', maxFeatures); + + if (!projectPath) { + logger.error('Missing projectPath parameter'); + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Generation already running for this project' }); + return; + } + + logAuthStatus('Before starting feature generation'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController, 'feature_generation'); + logger.info('Starting background feature generation task...'); + + generateFeaturesFromSpec(projectPath, events, abortController, maxFeatures, settingsService) + .catch((error) => { + logError(error, 'Feature generation failed with error'); + events.emit('spec-regeneration:event', { + type: 'features_error', + error: getErrorMessage(error), + }); + }) + .finally(() => { + logger.info('Feature generation task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (generation running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Generate features route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/generate.ts b/jules_branch/apps/server/src/routes/app-spec/routes/generate.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffc792aea80156819c726c395cd97d94839670aa --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/generate.ts @@ -0,0 +1,94 @@ +/** + * POST /generate endpoint - Generate spec from project definition + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { generateSpec } from '../generate-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +const logger = createLogger('SpecRegeneration'); + +export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /generate endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath, projectDefinition, generateFeatures, analyzeProject, maxFeatures } = + req.body as { + projectPath: string; + projectDefinition: string; + generateFeatures?: boolean; + analyzeProject?: boolean; + maxFeatures?: number; + }; + + logger.debug('Parsed params:'); + logger.debug(' projectPath:', projectPath); + logger.debug(' projectDefinition length:', `${projectDefinition?.length || 0} chars`); + logger.debug(' generateFeatures:', generateFeatures); + logger.debug(' analyzeProject:', analyzeProject); + logger.debug(' maxFeatures:', maxFeatures); + + if (!projectPath || !projectDefinition) { + logger.error('Missing required parameters'); + res.status(400).json({ + success: false, + error: 'projectPath and projectDefinition required', + }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation already running for project:', projectPath); + res.json({ success: false, error: 'Spec generation already running for this project' }); + return; + } + + logAuthStatus('Before starting generation'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController); + logger.info('Starting background generation task...'); + + generateSpec( + projectPath, + projectDefinition, + events, + abortController, + generateFeatures, + analyzeProject, + maxFeatures, + settingsService + ) + .catch((error) => { + logError(error, 'Generation failed with error'); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: getErrorMessage(error), + projectPath: projectPath, + }); + }) + .finally(() => { + logger.info('Generation task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (generation running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Generate spec route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/status.ts b/jules_branch/apps/server/src/routes/app-spec/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..34caea32607f694b42e5b3de86324989a8bc4202 --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/status.ts @@ -0,0 +1,18 @@ +/** + * GET /status endpoint - Get generation status + */ + +import type { Request, Response } from 'express'; +import { getSpecRegenerationStatus, getErrorMessage } from '../common.js'; + +export function createStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const projectPath = req.query.projectPath as string | undefined; + const { isRunning } = getSpecRegenerationStatus(projectPath); + res.json({ success: true, isRunning, projectPath }); + } catch (error) { + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/stop.ts b/jules_branch/apps/server/src/routes/app-spec/routes/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a7b0aab3e11a530ad3e5a62753faedba126b901 --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/stop.ts @@ -0,0 +1,24 @@ +/** + * POST /stop endpoint - Stop generation + */ + +import type { Request, Response } from 'express'; +import { getSpecRegenerationStatus, setRunningState, getErrorMessage } from '../common.js'; + +export function createStopHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath?: string }; + const { currentAbortController } = getSpecRegenerationStatus(projectPath); + if (currentAbortController) { + currentAbortController.abort(); + } + if (projectPath) { + setRunningState(projectPath, false, null); + } + res.json({ success: true }); + } catch (error) { + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/routes/sync.ts b/jules_branch/apps/server/src/routes/app-spec/routes/sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6c34e6868ac72d31b46ba6acb18fc50813920fb --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/routes/sync.ts @@ -0,0 +1,76 @@ +/** + * POST /sync endpoint - Sync spec with codebase and features + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { + getSpecRegenerationStatus, + setRunningState, + logAuthStatus, + logError, + getErrorMessage, +} from '../common.js'; +import { syncSpec } from '../sync-spec.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +const logger = createLogger('SpecSync'); + +export function createSyncHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + logger.info('========== /sync endpoint called =========='); + logger.debug('Request body:', JSON.stringify(req.body, null, 2)); + + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + logger.debug('projectPath:', projectPath); + + if (!projectPath) { + logger.error('Missing projectPath parameter'); + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + const { isRunning } = getSpecRegenerationStatus(projectPath); + if (isRunning) { + logger.warn('Generation/sync already running for project:', projectPath); + res.json({ success: false, error: 'Operation already running for this project' }); + return; + } + + logAuthStatus('Before starting spec sync'); + + const abortController = new AbortController(); + setRunningState(projectPath, true, abortController, 'sync'); + logger.info('Starting background spec sync task...'); + + syncSpec(projectPath, events, abortController, settingsService) + .then((result) => { + logger.info('Spec sync completed successfully'); + logger.info('Result:', JSON.stringify(result, null, 2)); + }) + .catch((error) => { + logError(error, 'Spec sync failed with error'); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: getErrorMessage(error), + projectPath, + }); + }) + .finally(() => { + logger.info('Spec sync task finished (success or error)'); + setRunningState(projectPath, false, null); + }); + + logger.info('Returning success response (sync running in background)'); + res.json({ success: true }); + } catch (error) { + logError(error, 'Sync route handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/app-spec/sync-spec.ts b/jules_branch/apps/server/src/routes/app-spec/sync-spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..53bdc91a353679108bb65e6ef6fdb54c22248c63 --- /dev/null +++ b/jules_branch/apps/server/src/routes/app-spec/sync-spec.ts @@ -0,0 +1,389 @@ +/** + * Sync spec with current codebase and feature state + * + * Updates the spec file based on: + * - Completed Automaker features + * - Code analysis for tech stack and implementations + * - Roadmap phase status updates + */ + +import * as secureFs from '../../lib/secure-fs.js'; +import type { EventEmitter } from '../../lib/events.js'; +import { createLogger } from '@automaker/utils'; +import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { streamingQuery } from '../../providers/simple-query-service.js'; +import { extractJson } from '../../lib/json-extractor.js'; +import { getAppSpecPath } from '@automaker/platform'; +import type { SettingsService } from '../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getPhaseModelWithOverrides, +} from '../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import { + extractImplementedFeatures, + extractTechnologyStack, + extractRoadmapPhases, + updateImplementedFeaturesSection, + updateTechnologyStack, + updateRoadmapPhaseStatus, + type ImplementedFeature, +} from '../../lib/xml-extractor.js'; +import { getNotificationService } from '../../services/notification-service.js'; + +const logger = createLogger('SpecSync'); + +/** + * Type for extracted tech stack JSON response + */ +interface TechStackExtractionResult { + technologies: string[]; +} + +/** + * JSON schema for tech stack analysis output (Claude/Codex structured output) + */ +const techStackOutputSchema = { + type: 'object', + properties: { + technologies: { + type: 'array', + items: { type: 'string' }, + description: 'List of technologies detected in the project', + }, + }, + required: ['technologies'], +} as const; + +/** + * Result of a sync operation + */ +export interface SyncResult { + techStackUpdates: { + added: string[]; + removed: string[]; + }; + implementedFeaturesUpdates: { + addedFromFeatures: string[]; + removed: string[]; + }; + roadmapUpdates: Array<{ phaseName: string; newStatus: string }>; + summary: string; +} + +/** + * Sync the spec with current codebase and feature state + */ +export async function syncSpec( + projectPath: string, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService +): Promise { + logger.info('========== syncSpec() started =========='); + logger.info('projectPath:', projectPath); + + const result: SyncResult = { + techStackUpdates: { added: [], removed: [] }, + implementedFeaturesUpdates: { addedFromFeatures: [], removed: [] }, + roadmapUpdates: [], + summary: '', + }; + + // Read existing spec + const specPath = getAppSpecPath(projectPath); + let specContent: string; + + try { + specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + logger.info(`Spec loaded successfully (${specContent.length} chars)`); + } catch (readError) { + logger.error('Failed to read spec file:', readError); + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_error', + error: 'No project spec found. Create or regenerate spec first.', + projectPath, + }); + throw new Error('No project spec found'); + } + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: '[Phase: sync] Starting spec sync...\n', + projectPath, + }); + + // Extract current state from spec + const currentImplementedFeatures = extractImplementedFeatures(specContent); + const currentTechStack = extractTechnologyStack(specContent); + const currentRoadmapPhases = extractRoadmapPhases(specContent); + + logger.info(`Current spec has ${currentImplementedFeatures.length} implemented features`); + logger.info(`Current spec has ${currentTechStack.length} technologies`); + logger.info(`Current spec has ${currentRoadmapPhases.length} roadmap phases`); + + // Load completed Automaker features + const featureLoader = new FeatureLoader(); + const allFeatures = await featureLoader.getAll(projectPath); + const completedFeatures = allFeatures.filter( + (f) => f.status === 'completed' || f.status === 'verified' + ); + + logger.info(`Found ${completedFeatures.length} completed/verified features in Automaker`); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: `Found ${completedFeatures.length} completed features to sync...\n`, + projectPath, + }); + + // Build new implemented features list from completed Automaker features + const newImplementedFeatures: ImplementedFeature[] = []; + const existingNames = new Set(currentImplementedFeatures.map((f) => f.name.toLowerCase())); + + for (const feature of completedFeatures) { + const name = feature.title || `Feature: ${feature.id}`; + if (!existingNames.has(name.toLowerCase())) { + newImplementedFeatures.push({ + name, + description: feature.description || '', + }); + result.implementedFeaturesUpdates.addedFromFeatures.push(name); + } + } + + // Merge: keep existing + add new from completed features + const mergedFeatures = [...currentImplementedFeatures, ...newImplementedFeatures]; + + // Update spec with merged features + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + specContent = updateImplementedFeaturesSection(specContent, mergedFeatures); + logger.info( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} features to spec` + ); + } + + // Analyze codebase for tech stack updates using AI + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Analyzing codebase for technology updates...\n', + projectPath, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[SpecSync]' + ); + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = settingsService + ? await getPhaseModelWithOverrides( + 'specGenerationModel', + settingsService, + projectPath, + '[SpecSync]' + ) + : { + phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel, + provider: undefined, + credentials: undefined, + }; + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API'); + + // Determine if we should use structured output based on model type + const useStructuredOutput = supportsStructuredOutput(model); + logger.info( + `Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}` + ); + + // Use AI to analyze tech stack + let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack. + +Current known technologies: ${currentTechStack.join(', ')} + +Look at package.json, config files, and source code to identify: +- Frameworks (React, Vue, Express, etc.) +- Languages (TypeScript, JavaScript, Python, etc.) +- Build tools (Vite, Webpack, etc.) +- Databases (PostgreSQL, MongoDB, etc.) +- Key libraries and tools + +Return ONLY this JSON format, no other text: +{ + "technologies": ["Technology 1", "Technology 2", ...] +}`; + + // Add explicit JSON instructions for non-Claude/Codex models + if (!useStructuredOutput) { + techAnalysisPrompt = `${techAnalysisPrompt} + +CRITICAL INSTRUCTIONS: +1. DO NOT write any files. Return the JSON in your response only. +2. Your entire response should be valid JSON starting with { and ending with }. +3. No explanations, no markdown, no text before or after the JSON.`; + } + + try { + const techResult = await streamingQuery({ + prompt: techAnalysisPrompt, + model, + cwd: projectPath, + maxTurns: 10, + allowedTools: ['Read', 'Glob', 'Grep'], + abortController, + thinkingLevel, + readOnly: true, + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: techStackOutputSchema, + } + : undefined, + onText: (text) => { + logger.debug(`Tech analysis text: ${text.substring(0, 100)}`); + }, + }); + + // Parse tech stack from response - prefer structured output if available + let parsedTechnologies: string[] | null = null; + + if (techResult.structured_output) { + // Use structured output from Claude/Codex models + const structured = techResult.structured_output as unknown as TechStackExtractionResult; + if (Array.isArray(structured.technologies)) { + parsedTechnologies = structured.technologies; + logger.info('✅ Received structured output for tech analysis'); + } + } else { + // Fall back to text parsing for non-Claude/Codex models + const extracted = extractJson(techResult.text, { + logger, + requiredKey: 'technologies', + requireArray: true, + }); + if (extracted && Array.isArray(extracted.technologies)) { + parsedTechnologies = extracted.technologies; + logger.info('✅ Extracted tech stack from text response'); + } else { + logger.warn('⚠️ Failed to extract tech stack JSON from response'); + } + } + + if (parsedTechnologies) { + const newTechStack = parsedTechnologies; + + // Calculate differences + const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase())); + const newSet = new Set(newTechStack.map((t) => t.toLowerCase())); + + for (const tech of newTechStack) { + if (!currentSet.has(tech.toLowerCase())) { + result.techStackUpdates.added.push(tech); + } + } + + for (const tech of currentTechStack) { + if (!newSet.has(tech.toLowerCase())) { + result.techStackUpdates.removed.push(tech); + } + } + + // Update spec with new tech stack if there are changes + if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) { + specContent = updateTechnologyStack(specContent, newTechStack); + logger.info( + `Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}` + ); + } + } + } catch (error) { + logger.warn('Failed to analyze tech stack:', error); + // Continue with other sync operations + } + + // Update roadmap phase statuses based on completed features + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_progress', + content: 'Checking roadmap phase statuses...\n', + projectPath, + }); + + // For each phase, check if all its features are completed + // This is a heuristic - we check if the phase name appears in any feature titles/descriptions + for (const phase of currentRoadmapPhases) { + if (phase.status === 'completed') continue; // Already completed + + // Check if this phase should be marked as completed + // A phase is considered complete if we have completed features that mention it + const phaseNameLower = phase.name.toLowerCase(); + const relatedCompletedFeatures = completedFeatures.filter( + (f) => + f.title?.toLowerCase().includes(phaseNameLower) || + f.description?.toLowerCase().includes(phaseNameLower) || + f.category?.toLowerCase().includes(phaseNameLower) + ); + + // If we have related completed features and the phase is still pending/in_progress, + // update it to in_progress or completed based on feature count + if (relatedCompletedFeatures.length > 0 && phase.status !== 'completed') { + const newStatus = 'in_progress'; + specContent = updateRoadmapPhaseStatus(specContent, phase.name, newStatus); + result.roadmapUpdates.push({ phaseName: phase.name, newStatus }); + logger.info(`Updated phase "${phase.name}" to ${newStatus}`); + } + } + + // Save updated spec + await secureFs.writeFile(specPath, specContent, 'utf-8'); + logger.info('Spec saved successfully'); + + // Build summary + const summaryParts: string[] = []; + if (result.implementedFeaturesUpdates.addedFromFeatures.length > 0) { + summaryParts.push( + `Added ${result.implementedFeaturesUpdates.addedFromFeatures.length} implemented features` + ); + } + if (result.techStackUpdates.added.length > 0) { + summaryParts.push(`Added ${result.techStackUpdates.added.length} technologies`); + } + if (result.techStackUpdates.removed.length > 0) { + summaryParts.push(`Removed ${result.techStackUpdates.removed.length} technologies`); + } + if (result.roadmapUpdates.length > 0) { + summaryParts.push(`Updated ${result.roadmapUpdates.length} roadmap phases`); + } + + result.summary = summaryParts.length > 0 ? summaryParts.join(', ') : 'Spec is already up to date'; + + // Create notification + const notificationService = getNotificationService(); + await notificationService.createNotification({ + type: 'spec_regeneration_complete', + title: 'Spec Sync Complete', + message: result.summary, + projectPath, + }); + + events.emit('spec-regeneration:event', { + type: 'spec_regeneration_complete', + message: `Spec sync complete! ${result.summary}`, + projectPath, + }); + + logger.info('========== syncSpec() completed =========='); + logger.info('Summary:', result.summary); + + return result; +} diff --git a/jules_branch/apps/server/src/routes/auth/index.ts b/jules_branch/apps/server/src/routes/auth/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..558065c4c92faa4b98c47a585c8fa01a4da863bb --- /dev/null +++ b/jules_branch/apps/server/src/routes/auth/index.ts @@ -0,0 +1,266 @@ +/** + * Auth routes - Login, logout, and status endpoints + * + * Security model: + * - Web mode: User enters API key (shown on server console) to get HTTP-only session cookie + * - Electron mode: Uses X-API-Key header (handled automatically via IPC) + * + * The session cookie is: + * - HTTP-only: JavaScript cannot read it (protects against XSS) + * - SameSite=Strict: Only sent for same-site requests (protects against CSRF) + * + * Mounted at /api/auth in the main server (BEFORE auth middleware). + */ + +import { Router } from 'express'; +import type { Request } from 'express'; +import { + validateApiKey, + createSession, + invalidateSession, + getSessionCookieOptions, + getSessionCookieName, + isRequestAuthenticated, + createWsConnectionToken, +} from '../../lib/auth.js'; + +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute window +const RATE_LIMIT_MAX_ATTEMPTS = 5; // Max 5 attempts per window + +// Check if we're in test mode - disable rate limiting for E2E tests +const isTestMode = process.env.AUTOMAKER_MOCK_AGENT === 'true'; + +// In-memory rate limit tracking (resets on server restart) +const loginAttempts = new Map(); + +// Clean up old rate limit entries periodically (every 5 minutes) +setInterval( + () => { + const now = Date.now(); + loginAttempts.forEach((data, ip) => { + if (now - data.windowStart > RATE_LIMIT_WINDOW_MS * 2) { + loginAttempts.delete(ip); + } + }); + }, + 5 * 60 * 1000 +); + +/** + * Get client IP address from request + * Handles X-Forwarded-For header for reverse proxy setups + */ +function getClientIp(req: Request): string { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + // X-Forwarded-For can be a comma-separated list; take the first (original client) + const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0]; + return forwardedIp.trim(); + } + return req.ip || req.socket.remoteAddress || 'unknown'; +} + +/** + * Check if an IP is rate limited + * Returns { limited: boolean, retryAfter?: number } + */ +function checkRateLimit(ip: string): { limited: boolean; retryAfter?: number } { + const now = Date.now(); + const attempt = loginAttempts.get(ip); + + if (!attempt) { + return { limited: false }; + } + + // Check if window has expired + if (now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) { + loginAttempts.delete(ip); + return { limited: false }; + } + + // Check if over limit + if (attempt.count >= RATE_LIMIT_MAX_ATTEMPTS) { + const retryAfter = Math.ceil((RATE_LIMIT_WINDOW_MS - (now - attempt.windowStart)) / 1000); + return { limited: true, retryAfter }; + } + + return { limited: false }; +} + +/** + * Record a login attempt for rate limiting + */ +function recordLoginAttempt(ip: string): void { + const now = Date.now(); + const attempt = loginAttempts.get(ip); + + if (!attempt || now - attempt.windowStart > RATE_LIMIT_WINDOW_MS) { + // Start new window + loginAttempts.set(ip, { count: 1, windowStart: now }); + } else { + // Increment existing window + attempt.count++; + } +} + +/** + * Create auth routes + * + * @returns Express Router with auth endpoints + */ +export function createAuthRoutes(): Router { + const router = Router(); + + /** + * GET /api/auth/status + * + * Returns whether the current request is authenticated. + * Used by the UI to determine if login is needed. + * + * If AUTOMAKER_AUTO_LOGIN=true is set, automatically creates a session + * for unauthenticated requests (useful for development). + */ + router.get('/status', async (req, res) => { + let authenticated = isRequestAuthenticated(req); + + // Auto-login for development: create session automatically if enabled + // Only works in non-production environments as a safeguard + if ( + !authenticated && + process.env.AUTOMAKER_AUTO_LOGIN === 'true' && + process.env.NODE_ENV !== 'production' + ) { + const sessionToken = await createSession(); + const cookieOptions = getSessionCookieOptions(); + const cookieName = getSessionCookieName(); + res.cookie(cookieName, sessionToken, cookieOptions); + authenticated = true; + } + + res.json({ + success: true, + authenticated, + required: true, + }); + }); + + /** + * POST /api/auth/login + * + * Validates the API key and sets a session cookie. + * Body: { apiKey: string } + * + * Rate limited to 5 attempts per minute per IP to prevent brute force attacks. + */ + router.post('/login', async (req, res) => { + const clientIp = getClientIp(req); + + // Skip rate limiting in test mode to allow parallel E2E tests + if (!isTestMode) { + // Check rate limit before processing + const rateLimit = checkRateLimit(clientIp); + if (rateLimit.limited) { + res.status(429).json({ + success: false, + error: 'Too many login attempts. Please try again later.', + retryAfter: rateLimit.retryAfter, + }); + return; + } + } + + const { apiKey } = req.body as { apiKey?: string }; + + if (!apiKey) { + res.status(400).json({ + success: false, + error: 'API key is required.', + }); + return; + } + + // Record this attempt (only for actual API key validation attempts, skip in test mode) + if (!isTestMode) { + recordLoginAttempt(clientIp); + } + + if (!validateApiKey(apiKey)) { + res.status(401).json({ + success: false, + error: 'Invalid API key.', + }); + return; + } + + // Create session and set cookie + const sessionToken = await createSession(); + const cookieOptions = getSessionCookieOptions(); + const cookieName = getSessionCookieName(); + + res.cookie(cookieName, sessionToken, cookieOptions); + res.json({ + success: true, + message: 'Logged in successfully.', + // Return token for explicit header-based auth (works around cross-origin cookie issues) + token: sessionToken, + }); + }); + + /** + * GET /api/auth/token + * + * Generates a short-lived WebSocket connection token if the user has a valid session. + * This token is used for initial WebSocket handshake authentication and expires in 5 minutes. + * The token is NOT the session cookie value - it's a separate, short-lived token. + */ + router.get('/token', (req, res) => { + // Validate the session is still valid (via cookie, API key, or session token header) + if (!isRequestAuthenticated(req)) { + res.status(401).json({ + success: false, + error: 'Authentication required.', + }); + return; + } + + // Generate a new short-lived WebSocket connection token + const wsToken = createWsConnectionToken(); + + res.json({ + success: true, + token: wsToken, + expiresIn: 300, // 5 minutes in seconds + }); + }); + + /** + * POST /api/auth/logout + * + * Clears the session cookie and invalidates the session. + */ + router.post('/logout', async (req, res) => { + const cookieName = getSessionCookieName(); + const sessionToken = req.cookies?.[cookieName] as string | undefined; + + if (sessionToken) { + await invalidateSession(sessionToken); + } + + // Clear the cookie by setting it to empty with immediate expiration + // Using res.cookie() with maxAge: 0 is more reliable than clearCookie() + // in cross-origin development environments + res.cookie(cookieName, '', { + ...getSessionCookieOptions(), + maxAge: 0, + expires: new Date(0), + }); + + res.json({ + success: true, + message: 'Logged out successfully.', + }); + }); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/common.ts b/jules_branch/apps/server/src/routes/auto-mode/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fe9c3ab2dfba6bbc2d45838f4a4cc98f790ff83 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for auto-mode routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/auto-mode/index.ts b/jules_branch/apps/server/src/routes/auto-mode/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..016447d739e9831866544bd7b2fbbb158229e803 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/index.ts @@ -0,0 +1,92 @@ +/** + * Auto Mode routes - HTTP API for autonomous feature implementation + * + * Uses AutoModeServiceCompat which provides the old interface while + * delegating to GlobalAutoModeService and per-project facades. + */ + +import { Router } from 'express'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createStopFeatureHandler } from './routes/stop-feature.js'; +import { createStatusHandler } from './routes/status.js'; +import { createRunFeatureHandler } from './routes/run-feature.js'; +import { createStartHandler } from './routes/start.js'; +import { createStopHandler } from './routes/stop.js'; +import { createVerifyFeatureHandler } from './routes/verify-feature.js'; +import { createResumeFeatureHandler } from './routes/resume-feature.js'; +import { createContextExistsHandler } from './routes/context-exists.js'; +import { createAnalyzeProjectHandler } from './routes/analyze-project.js'; +import { createFollowUpFeatureHandler } from './routes/follow-up-feature.js'; +import { createCommitFeatureHandler } from './routes/commit-feature.js'; +import { createApprovePlanHandler } from './routes/approve-plan.js'; +import { createResumeInterruptedHandler } from './routes/resume-interrupted.js'; +import { createReconcileHandler } from './routes/reconcile.js'; + +/** + * Create auto-mode routes. + * + * @param autoModeService - AutoModeServiceCompat instance + */ +export function createAutoModeRoutes(autoModeService: AutoModeServiceCompat): Router { + const router = Router(); + + // Auto loop control routes + router.post('/start', validatePathParams('projectPath'), createStartHandler(autoModeService)); + router.post('/stop', validatePathParams('projectPath'), createStopHandler(autoModeService)); + + router.post('/stop-feature', createStopFeatureHandler(autoModeService)); + router.post('/status', validatePathParams('projectPath?'), createStatusHandler(autoModeService)); + router.post( + '/run-feature', + validatePathParams('projectPath'), + createRunFeatureHandler(autoModeService) + ); + router.post( + '/verify-feature', + validatePathParams('projectPath'), + createVerifyFeatureHandler(autoModeService) + ); + router.post( + '/resume-feature', + validatePathParams('projectPath'), + createResumeFeatureHandler(autoModeService) + ); + router.post( + '/context-exists', + validatePathParams('projectPath'), + createContextExistsHandler(autoModeService) + ); + router.post( + '/analyze-project', + validatePathParams('projectPath'), + createAnalyzeProjectHandler(autoModeService) + ); + router.post( + '/follow-up-feature', + validatePathParams('projectPath', 'imagePaths[]'), + createFollowUpFeatureHandler(autoModeService) + ); + router.post( + '/commit-feature', + validatePathParams('projectPath', 'worktreePath?'), + createCommitFeatureHandler(autoModeService) + ); + router.post( + '/approve-plan', + validatePathParams('projectPath'), + createApprovePlanHandler(autoModeService) + ); + router.post( + '/resume-interrupted', + validatePathParams('projectPath'), + createResumeInterruptedHandler(autoModeService) + ); + router.post( + '/reconcile', + validatePathParams('projectPath'), + createReconcileHandler(autoModeService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/analyze-project.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/analyze-project.ts new file mode 100644 index 0000000000000000000000000000000000000000..cae70c3689b338d54aa73de7fa6262a13d5d7fb0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/analyze-project.ts @@ -0,0 +1,34 @@ +/** + * POST /analyze-project endpoint - Analyze project + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // Kick off analysis in the background; attach a rejection handler so + // unhandled-promise warnings don't surface and errors are at least logged. + // Synchronous throws (e.g. "not implemented") still propagate here. + const analysisPromise = autoModeService.analyzeProject(projectPath); + analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed')); + + res.json({ success: true, message: 'Project analysis started' }); + } catch (error) { + logError(error, 'Analyze project failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/approve-plan.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/approve-plan.ts new file mode 100644 index 0000000000000000000000000000000000000000..14673e31bba81d2f11135679b75634f924a88285 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/approve-plan.ts @@ -0,0 +1,86 @@ +/** + * POST /approve-plan endpoint - Approve or reject a generated plan/spec + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createApprovePlanHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { featureId, approved, editedPlan, feedback, projectPath } = req.body as { + featureId: string; + approved: boolean; + editedPlan?: string; + feedback?: string; + projectPath: string; + }; + + if (!featureId) { + res.status(400).json({ + success: false, + error: 'featureId is required', + }); + return; + } + + if (typeof approved !== 'boolean') { + res.status(400).json({ + success: false, + error: 'approved must be a boolean', + }); + return; + } + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Note: We no longer check hasPendingApproval here because resolvePlanApproval + // can handle recovery when pending approval is not in Map but feature has planSpec.status='generated' + // This supports cases where the server restarted while waiting for approval + + logger.info( + `[AutoMode] Plan ${approved ? 'approved' : 'rejected'} for feature ${featureId}${ + editedPlan ? ' (with edits)' : '' + }${feedback ? ` - Feedback: ${feedback}` : ''}` + ); + + // Resolve the pending approval (with recovery support) + const result = await autoModeService.resolvePlanApproval( + projectPath, + featureId, + approved, + editedPlan, + feedback + ); + + if (!result.success) { + res.status(500).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + approved, + message: approved + ? 'Plan approved - implementation will continue' + : 'Plan rejected - feature execution stopped', + }); + } catch (error) { + logError(error, 'Approve plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/commit-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/commit-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..16b9000d2ec6e52139c0dab429b97895c21b3b79 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/commit-feature.ts @@ -0,0 +1,33 @@ +/** + * POST /commit-feature endpoint - Commit feature changes + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createCommitFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, worktreePath } = req.body as { + projectPath: string; + featureId: string; + worktreePath?: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const commitHash = await autoModeService.commitFeature(projectPath, featureId, worktreePath); + res.json({ success: true, commitHash }); + } catch (error) { + logError(error, 'Commit feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/context-exists.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/context-exists.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c85c2abf758b56cf4ce139eef92ed6437fbd4fb --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/context-exists.ts @@ -0,0 +1,32 @@ +/** + * POST /context-exists endpoint - Check if context exists for a feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createContextExistsHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const exists = await autoModeService.contextExists(projectPath, featureId); + res.json({ success: true, exists }); + } catch (error) { + logError(error, 'Check context exists failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..312edcdea8661db0e54364b2f50a1d302c0cf326 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/follow-up-feature.ts @@ -0,0 +1,47 @@ +/** + * POST /follow-up-feature endpoint - Follow up on a feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createFollowUpFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, prompt, imagePaths, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + prompt: string; + imagePaths?: string[]; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId || !prompt) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and prompt are required', + }); + return; + } + + // 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 + .followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false) + .catch((error) => { + logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Follow up feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/reconcile.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/reconcile.ts new file mode 100644 index 0000000000000000000000000000000000000000..96109051ab151461ea4f1ed07cb3bb342c3d41ad --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/reconcile.ts @@ -0,0 +1,53 @@ +/** + * Reconcile Feature States Handler + * + * On-demand endpoint to reconcile all feature states for a project. + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to resting states (ready/backlog) and emits events to update the UI. + * + * This is useful when: + * - The UI reconnects after a server restart + * - A client detects stale feature states + * - An admin wants to force-reset stuck features + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; + +const logger = createLogger('ReconcileFeatures'); + +interface ReconcileRequest { + projectPath: string; +} + +export function createReconcileHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ReconcileRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Reconciling feature states for ${projectPath}`); + + try { + const reconciledCount = await autoModeService.reconcileFeatureStates(projectPath); + + res.json({ + success: true, + reconciledCount, + message: + reconciledCount > 0 + ? `Reconciled ${reconciledCount} feature(s)` + : 'No features needed reconciliation', + }); + } catch (error) { + logger.error('Error reconciling feature states:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/resume-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/resume-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9f5de32baa751ac3b8416990109c59384419564 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/resume-feature.ts @@ -0,0 +1,43 @@ +/** + * POST /resume-feature endpoint - Resume a feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createResumeFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + // Start resume in background + // Default to false - worktrees should only be used when explicitly enabled + autoModeService + .resumeFeature(projectPath, featureId, useWorktrees ?? false) + .catch((error) => { + logger.error(`Resume feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Resume feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts new file mode 100644 index 0000000000000000000000000000000000000000..314bc067f8704c7218e4068164dada9979055c80 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/resume-interrupted.ts @@ -0,0 +1,43 @@ +/** + * Resume Interrupted Features Handler + * + * Checks for features that were interrupted (in pipeline steps or in_progress) + * when the server was restarted and resumes them. + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; + +const logger = createLogger('ResumeInterrupted'); + +interface ResumeInterruptedRequest { + projectPath: string; +} + +export function createResumeInterruptedHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as ResumeInterruptedRequest; + + if (!projectPath) { + res.status(400).json({ error: 'Project path is required' }); + return; + } + + logger.info(`Checking for interrupted features in ${projectPath}`); + + try { + await autoModeService.resumeInterruptedFeatures(projectPath); + + res.json({ + success: true, + message: 'Resume check completed', + }); + } catch (error) { + logger.error('Error resuming interrupted features:', error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/run-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/run-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..a61a406415ba72d569a7310657405e87b227c0cb --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/run-feature.ts @@ -0,0 +1,47 @@ +/** + * POST /run-feature endpoint - Run a single feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + // Note: No concurrency limit check here. Manual feature starts always run + // immediately and bypass the concurrency limit. Their presence IS counted + // by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks. + + // Start execution in background + // executeFeature derives workDir from feature.branchName + autoModeService + .executeFeature(projectPath, featureId, useWorktrees ?? false, false) + .catch((error) => { + logger.error(`Feature ${featureId} error:`, error); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Run feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/start.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/start.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8cc8bff47b7ac8434011223cd0a212cd2249af6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/start.ts @@ -0,0 +1,67 @@ +/** + * POST /start endpoint - Start auto mode loop for a project + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createStartHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, branchName, maxConcurrency } = req.body as { + projectPath: string; + branchName?: string | null; + maxConcurrency?: number; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + + // Check if already running + if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { + res.json({ + success: true, + message: `Auto mode is already running for ${worktreeDesc}`, + alreadyRunning: true, + branchName: normalizedBranchName, + }); + return; + } + + // Start the auto loop for this project/worktree + const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject( + projectPath, + normalizedBranchName, + maxConcurrency + ); + + logger.info( + `Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}` + ); + + res.json({ + success: true, + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, + branchName: normalizedBranchName, + }); + } catch (error) { + logError(error, 'Start auto mode failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/status.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..765ff73a61000a4ffd4d54127b47bf480b720f08 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/status.ts @@ -0,0 +1,60 @@ +/** + * POST /status endpoint - Get auto mode status + * + * If projectPath is provided, returns per-project status including autoloop state. + * If no projectPath, returns global status for backward compatibility. + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create status handler. + */ +export function createStatusHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, branchName } = req.body as { + projectPath?: string; + branchName?: string | null; + }; + + // If projectPath is provided, return per-project/worktree status + if (projectPath) { + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + + const projectStatus = await autoModeService.getStatusForProject( + projectPath, + normalizedBranchName + ); + res.json({ + success: true, + isRunning: projectStatus.runningCount > 0, + isAutoLoopRunning: projectStatus.isAutoLoopRunning, + runningFeatures: projectStatus.runningFeatures, + runningCount: projectStatus.runningCount, + maxConcurrency: projectStatus.maxConcurrency, + projectPath, + branchName: normalizedBranchName, + }); + return; + } + + // Global status for backward compatibility + const status = autoModeService.getStatus(); + const activeProjects = autoModeService.getActiveAutoLoopProjects(); + const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees(); + res.json({ + success: true, + ...status, + activeAutoLoopProjects: activeProjects, + activeAutoLoopWorktrees: activeWorktrees, + }); + } catch (error) { + logError(error, 'Get status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/stop-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/stop-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e3e69eb6c4065fa51c2a5d3c2588f30a04abc52 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/stop-feature.ts @@ -0,0 +1,26 @@ +/** + * POST /stop-feature endpoint - Stop a specific feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStopFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { featureId } = req.body as { featureId: string }; + + if (!featureId) { + res.status(400).json({ success: false, error: 'featureId is required' }); + return; + } + + const stopped = await autoModeService.stopFeature(featureId); + res.json({ success: true, stopped }); + } catch (error) { + logError(error, 'Stop feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/stop.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..224b0daf47248e08cb05392faeb7d27c42587444 --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/stop.ts @@ -0,0 +1,66 @@ +/** + * POST /stop endpoint - Stop auto mode loop for a project + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('AutoMode'); + +export function createStopHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, branchName } = req.body as { + projectPath: string; + branchName?: string | null; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Normalize branchName: undefined becomes null + const normalizedBranchName = branchName ?? null; + const worktreeDesc = normalizedBranchName + ? `worktree ${normalizedBranchName}` + : 'main worktree'; + + // Check if running + if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) { + res.json({ + success: true, + message: `Auto mode is not running for ${worktreeDesc}`, + wasRunning: false, + branchName: normalizedBranchName, + }); + return; + } + + // Stop the auto loop for this project/worktree + const runningCount = await autoModeService.stopAutoLoopForProject( + projectPath, + normalizedBranchName + ); + + logger.info( + `Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running` + ); + + res.json({ + success: true, + message: 'Auto mode stopped', + runningFeaturesCount: runningCount, + branchName: normalizedBranchName, + }); + } catch (error) { + logError(error, 'Stop auto mode failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/auto-mode/routes/verify-feature.ts b/jules_branch/apps/server/src/routes/auto-mode/routes/verify-feature.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c036812a1b9c6b218c75529a07a46fb52e33ddb --- /dev/null +++ b/jules_branch/apps/server/src/routes/auto-mode/routes/verify-feature.ts @@ -0,0 +1,32 @@ +/** + * POST /verify-feature endpoint - Verify a feature + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createVerifyFeatureHandler(autoModeService: AutoModeServiceCompat) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const passes = await autoModeService.verifyFeature(projectPath, featureId); + res.json({ success: true, passes }); + } catch (error) { + logError(error, 'Verify feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/common.ts b/jules_branch/apps/server/src/routes/backlog-plan/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..27993e95ee7de0945467e3fe9187fad780224f1c --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/common.ts @@ -0,0 +1,174 @@ +/** + * Common utilities for backlog plan routes + */ + +import { createLogger } from '@automaker/utils'; +import { ensureAutomakerDir, getAutomakerDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import path from 'path'; +import type { BacklogPlanResult } from '@automaker/types'; + +const logger = createLogger('BacklogPlan'); + +// State for tracking running generation +let isRunning = false; +let currentAbortController: AbortController | null = null; +let runningDetails: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null = null; + +const BACKLOG_PLAN_FILENAME = 'backlog-plan.json'; + +export interface StoredBacklogPlan { + savedAt: string; + prompt: string; + model?: string; + result: BacklogPlanResult; +} + +export function getBacklogPlanStatus(): { isRunning: boolean } { + return { isRunning }; +} + +export function setRunningState(running: boolean, abortController?: AbortController | null): void { + isRunning = running; + if (!running) { + runningDetails = null; + } + if (abortController !== undefined) { + currentAbortController = abortController; + } +} + +export function setRunningDetails( + details: { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; + } | null +): void { + runningDetails = details; +} + +export function getRunningDetails(): { + projectPath: string; + prompt: string; + model?: string; + startedAt: string; +} | null { + return runningDetails; +} + +function getBacklogPlanPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), BACKLOG_PLAN_FILENAME); +} + +export async function saveBacklogPlan(projectPath: string, plan: StoredBacklogPlan): Promise { + await ensureAutomakerDir(projectPath); + const filePath = getBacklogPlanPath(projectPath); + await secureFs.writeFile(filePath, JSON.stringify(plan, null, 2), 'utf-8'); +} + +export async function loadBacklogPlan(projectPath: string): Promise { + try { + const filePath = getBacklogPlanPath(projectPath); + const raw = await secureFs.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw as string) as StoredBacklogPlan; + if (!Array.isArray(parsed?.result?.changes)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export async function clearBacklogPlan(projectPath: string): Promise { + try { + const filePath = getBacklogPlanPath(projectPath); + await secureFs.unlink(filePath); + } catch { + // ignore missing file + } +} + +export function getAbortController(): AbortController | null { + return currentAbortController; +} + +/** + * Map SDK/CLI errors to user-friendly messages + */ +export function mapBacklogPlanError(rawMessage: string): string { + // Claude Code spawn failures + if ( + rawMessage.includes('Failed to spawn Claude Code process') || + rawMessage.includes('spawn node ENOENT') || + rawMessage.includes('Claude Code executable not found') || + rawMessage.includes('Claude Code native binary not found') + ) { + return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.'; + } + + // Claude Code process crash - extract exit code for diagnostics + if (rawMessage.includes('Claude Code process exited')) { + const exitCodeMatch = rawMessage.match(/exited with code (\d+)/); + const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown'; + logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`); + return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`; + } + + // Claude Code process killed by signal + if (rawMessage.includes('Claude Code process terminated by signal')) { + const signalMatch = rawMessage.match(/terminated by signal (\w+)/); + const signal = signalMatch ? signalMatch[1] : 'unknown'; + logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`); + return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`; + } + + // Rate limiting + if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) { + return 'Rate limited. Please wait a moment and try again.'; + } + + // Network errors + if ( + rawMessage.toLowerCase().includes('network') || + rawMessage.toLowerCase().includes('econnrefused') || + rawMessage.toLowerCase().includes('timeout') + ) { + return 'Network error. Check your internet connection and try again.'; + } + + // Authentication errors + if ( + rawMessage.toLowerCase().includes('not authenticated') || + rawMessage.toLowerCase().includes('unauthorized') || + rawMessage.includes('401') + ) { + return 'Authentication failed. Please check your API key or run `claude login` to authenticate.'; + } + + // Return original message for unknown errors + return rawMessage; +} + +export function getErrorMessage(error: unknown): string { + let rawMessage: string; + if (error instanceof Error) { + rawMessage = error.message; + } else { + rawMessage = String(error); + } + return mapBacklogPlanError(rawMessage); +} + +export function logError(error: unknown, context: string): void { + logger.error(`[BacklogPlan] ${context}:`, getErrorMessage(error)); +} + +export { logger }; diff --git a/jules_branch/apps/server/src/routes/backlog-plan/generate-plan.ts b/jules_branch/apps/server/src/routes/backlog-plan/generate-plan.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a1dc95afaea17ed3fa8f968ceb5f2eab71c000b --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/generate-plan.ts @@ -0,0 +1,491 @@ +/** + * Generate backlog plan using Claude AI + * + * Model is configurable via phaseModels.backlogPlanningModel in settings + * (defaults to Sonnet). Can be overridden per-call via model parameter. + * + * Includes automatic retry for transient CLI failures (e.g., "Claude Code + * process exited unexpectedly") to improve reliability. + */ + +import type { EventEmitter } from '../../lib/events.js'; +import type { Feature, BacklogPlanResult } from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + isCursorModel, + stripProviderPrefix, + type ThinkingLevel, + type SystemPromptPreset, +} from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { getCurrentBranch } from '@automaker/git-utils'; +import { FeatureLoader } from '../../services/feature-loader.js'; +import { ProviderFactory } from '../../providers/provider-factory.js'; +import { extractJsonWithArray } from '../../lib/json-extractor.js'; +import { + logger, + setRunningState, + setRunningDetails, + getErrorMessage, + saveBacklogPlan, +} from './common.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + getPromptCustomization, + getPhaseModelWithOverrides, + getProviderByModelId, +} from '../../lib/settings-helpers.js'; + +/** Maximum number of retry attempts for transient CLI failures */ +const MAX_RETRIES = 2; +/** Delay between retries in milliseconds */ +const RETRY_DELAY_MS = 2000; + +/** + * Check if an error is retryable (transient CLI process failure) + */ +function isRetryableError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('Claude Code process exited') || + message.includes('Claude Code process terminated by signal') + ); +} + +const featureLoader = new FeatureLoader(); + +/** + * Format features for the AI prompt + */ +function formatFeaturesForPrompt(features: Feature[]): string { + if (features.length === 0) { + return 'No features in backlog yet.'; + } + + return features + .map((f) => { + const deps = f.dependencies?.length ? `Dependencies: [${f.dependencies.join(', ')}]` : ''; + const priority = f.priority !== undefined ? `Priority: ${f.priority}` : ''; + return `- ID: ${f.id} + Title: ${f.title || 'Untitled'} + Description: ${f.description} + Category: ${f.category} + Status: ${f.status || 'backlog'} + ${priority} + ${deps}`.trim(); + }) + .join('\n\n'); +} + +/** + * Parse the AI response into a BacklogPlanResult + */ +function parsePlanResponse(response: string): BacklogPlanResult { + // Use shared JSON extraction utility for robust parsing + // extractJsonWithArray validates that 'changes' exists AND is an array + const parsed = extractJsonWithArray(response, 'changes', { + logger, + }); + + if (parsed) { + return parsed; + } + + // If parsing fails, log details and return an empty result + logger.warn('[BacklogPlan] Failed to parse AI response as JSON'); + logger.warn('[BacklogPlan] Response text length:', response.length); + logger.warn('[BacklogPlan] Response preview:', response.slice(0, 500)); + if (response.length === 0) { + logger.error('[BacklogPlan] Response text is EMPTY! No content was extracted from stream.'); + } + return { + changes: [], + summary: 'Failed to parse AI response', + dependencyUpdates: [], + }; +} + +/** + * Try to parse a valid plan response without fallback behavior. + * Returns null if parsing fails. + */ +function tryParsePlanResponse(response: string): BacklogPlanResult | null { + if (!response || response.trim().length === 0) { + return null; + } + return extractJsonWithArray(response, 'changes', { logger }); +} + +/** + * Choose the most reliable response text between streamed assistant chunks + * and provider final result payload. + */ +function selectBestResponseText(accumulatedText: string, providerResultText: string): string { + const hasAccumulated = accumulatedText.trim().length > 0; + const hasProviderResult = providerResultText.trim().length > 0; + + if (!hasProviderResult) { + return accumulatedText; + } + if (!hasAccumulated) { + return providerResultText; + } + + const accumulatedParsed = tryParsePlanResponse(accumulatedText); + const providerParsed = tryParsePlanResponse(providerResultText); + + if (providerParsed && !accumulatedParsed) { + logger.info('[BacklogPlan] Using provider result (parseable JSON)'); + return providerResultText; + } + if (accumulatedParsed && !providerParsed) { + logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)'); + return accumulatedText; + } + + if (providerResultText.length > accumulatedText.length) { + logger.info('[BacklogPlan] Using provider result (longer content)'); + return providerResultText; + } + + logger.info('[BacklogPlan] Keeping accumulated text (longer content)'); + return accumulatedText; +} + +/** + * Generate a backlog modification plan based on user prompt + */ +export async function generateBacklogPlan( + projectPath: string, + prompt: string, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService, + model?: string, + branchName?: string +): Promise { + try { + // Load current features + const allFeatures = await featureLoader.getAll(projectPath); + + // Filter features by branch if specified (worktree-scoped backlog) + let features: Feature[]; + if (branchName) { + // Determine the primary branch so unassigned features show for the main worktree + let primaryBranch: string | null = null; + try { + primaryBranch = await getCurrentBranch(projectPath); + } catch { + // If git fails, fall back to 'main' so unassigned features are visible + // when branchName matches a common default branch name + primaryBranch = 'main'; + } + const isMainBranch = branchName === primaryBranch; + + features = allFeatures.filter((f) => { + if (!f.branchName) { + // Unassigned features belong to the main/primary worktree + return isMainBranch; + } + return f.branchName === branchName; + }); + logger.info( + `[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}` + ); + } else { + features = allFeatures; + } + + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: `Loaded ${features.length} features from backlog`, + }); + + // Load prompts from settings + const prompts = await getPromptCustomization(settingsService, '[BacklogPlan]'); + + // Build the system prompt + const systemPrompt = prompts.backlogPlan.systemPrompt; + + // Build the user prompt from template + const currentFeatures = formatFeaturesForPrompt(features); + const userPrompt = prompts.backlogPlan.userPromptTemplate + .replace('{{currentFeatures}}', currentFeatures) + .replace('{{userRequest}}', prompt); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: 'Generating plan with AI...', + }); + + // Get the model to use from settings or provided override with provider info + let effectiveModel = model; + let thinkingLevel: ThinkingLevel | undefined; + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let credentials: import('@automaker/types').Credentials | undefined; + + if (effectiveModel) { + // Use explicit override - resolve model alias and get credentials + const resolved = resolvePhaseModel({ model: effectiveModel }); + effectiveModel = resolved.model; + thinkingLevel = resolved.thinkingLevel; + credentials = await settingsService?.getCredentials(); + // Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM) + if (settingsService) { + const providerResult = await getProviderByModelId( + effectiveModel, + settingsService, + '[BacklogPlan]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + if (providerResult.credentials) { + credentials = providerResult.credentials; + } + } + // Fallback: use phase settings provider if model lookup found nothing (e.g. model + // string format differs from provider's model id, but backlog planning phase has providerId). + if (!claudeCompatibleProvider) { + const phaseResult = await getPhaseModelWithOverrides( + 'backlogPlanningModel', + settingsService, + projectPath, + '[BacklogPlan]' + ); + const phaseResolved = resolvePhaseModel(phaseResult.phaseModel); + if (phaseResult.provider && phaseResolved.model === effectiveModel) { + claudeCompatibleProvider = phaseResult.provider; + credentials = phaseResult.credentials ?? credentials; + } + } + } + } else if (settingsService) { + // Use settings-based model with provider info + const phaseResult = await getPhaseModelWithOverrides( + 'backlogPlanningModel', + settingsService, + projectPath, + '[BacklogPlan]' + ); + const resolved = resolvePhaseModel(phaseResult.phaseModel); + effectiveModel = resolved.model; + thinkingLevel = resolved.thinkingLevel; + claudeCompatibleProvider = phaseResult.provider; + credentials = phaseResult.credentials; + } else { + // Fallback to defaults + const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel); + effectiveModel = resolved.model; + thinkingLevel = resolved.thinkingLevel; + } + logger.info( + '[BacklogPlan] Using model:', + effectiveModel, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); + + const provider = ProviderFactory.getProviderForModel(effectiveModel); + // Strip provider prefix - providers expect bare model IDs + const bareModel = stripProviderPrefix(effectiveModel); + + // Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[BacklogPlan]' + ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + settingsService, + '[BacklogPlan]' + ); + + // For Cursor models, we need to combine prompts with explicit instructions + // because Cursor doesn't support systemPrompt separation like Claude SDK + let finalPrompt = userPrompt; + let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt; + let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined; + + if (isCursorModel(effectiveModel)) { + logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions'); + finalPrompt = `${systemPrompt} + +CRITICAL INSTRUCTIONS: +1. DO NOT write any files. Return the JSON in your response only. +2. DO NOT use Write, Edit, or any file modification tools. +3. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. +4. Your entire response should be valid JSON starting with { and ending with }. +5. No text before or after the JSON object. + +${userPrompt}`; + finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt + } else if (claudeCompatibleProvider) { + // Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use + // the claude_code preset (which is for Claude CLI/subprocess and can break the request). + finalSystemPrompt = systemPrompt; + } else if (useClaudeCodeSystemPrompt) { + // Use claude_code preset for native Claude so the SDK subprocess + // authenticates via CLI OAuth or API key the same way all other SDK calls do. + finalSystemPrompt = { + type: 'preset', + preset: 'claude_code', + append: systemPrompt, + }; + } + // Include settingSources when autoLoadClaudeMd is enabled + if (autoLoadClaudeMd) { + finalSettingSources = ['user', 'project']; + } + + // Execute the query with retry logic for transient CLI failures + const queryOptions = { + prompt: finalPrompt, + model: bareModel, + cwd: projectPath, + systemPrompt: finalSystemPrompt, + maxTurns: 1, + tools: [] as string[], // Disable all built-in tools - plan generation only needs text output + abortController, + settingSources: finalSettingSources, + thinkingLevel, // Pass thinking level for extended thinking + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }; + + let responseText = ''; + let bestResponseText = ''; // Preserve best response across all retry attempts + let recoveredResult: BacklogPlanResult | null = null; + let lastError: unknown = null; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (abortController.signal.aborted) { + throw new Error('Generation aborted'); + } + + if (attempt > 0) { + logger.info( + `[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure` + ); + events.emit('backlog-plan:event', { + type: 'backlog_plan_progress', + content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`, + }); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + + let accumulatedText = ''; + let providerResultText = ''; + + try { + const stream = provider.executeQuery(queryOptions); + + for await (const msg of stream) { + if (abortController.signal.aborted) { + throw new Error('Generation aborted'); + } + + if (msg.type === 'assistant') { + if (msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + accumulatedText += block.text; + } + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + providerResultText = msg.result; + logger.info( + '[BacklogPlan] Received result from provider, length:', + providerResultText.length + ); + logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length); + } + } + + responseText = selectBestResponseText(accumulatedText, providerResultText); + + // If we got here, the stream completed successfully + lastError = null; + break; + } catch (error) { + lastError = error; + const errorMessage = error instanceof Error ? error.message : String(error); + responseText = selectBestResponseText(accumulatedText, providerResultText); + + // Preserve the best response text across all attempts so that if a retry + // crashes immediately (empty response), we can still recover from an earlier attempt + bestResponseText = selectBestResponseText(bestResponseText, responseText); + + // Claude SDK can occasionally exit non-zero after emitting a complete response. + // If we already have valid JSON, recover instead of failing the entire planning flow. + if (isRetryableError(error)) { + const parsed = tryParsePlanResponse(bestResponseText); + if (parsed) { + logger.warn( + '[BacklogPlan] Recovered from transient CLI exit using accumulated valid response' + ); + recoveredResult = parsed; + lastError = null; + break; + } + + // On final retryable failure, degrade gracefully if we have text from any attempt. + if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) { + logger.warn( + '[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse' + ); + recoveredResult = parsePlanResponse(bestResponseText); + lastError = null; + break; + } + } + + // Only retry on transient CLI failures, not on user aborts or other errors + if (!isRetryableError(error) || attempt >= MAX_RETRIES) { + throw error; + } + + logger.warn( + `[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}` + ); + } + } + + // If we exhausted retries, throw the last error + if (lastError) { + throw lastError; + } + + // Parse the response + const result = recoveredResult ?? parsePlanResponse(responseText); + + await saveBacklogPlan(projectPath, { + savedAt: new Date().toISOString(), + prompt, + model: effectiveModel, + result, + }); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_complete', + result, + }); + + return result; + } catch (error) { + const errorMessage = getErrorMessage(error); + logger.error('[BacklogPlan] Generation failed:', errorMessage); + + events.emit('backlog-plan:event', { + type: 'backlog_plan_error', + error: errorMessage, + }); + + throw error; + } finally { + setRunningState(false, null); + setRunningDetails(null); + } +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/index.ts b/jules_branch/apps/server/src/routes/backlog-plan/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b7e424d98d077eaca27d864886c0dd7714d248 --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/index.ts @@ -0,0 +1,32 @@ +/** + * Backlog Plan routes - HTTP API for AI-assisted backlog modification + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createGenerateHandler } from './routes/generate.js'; +import { createStopHandler } from './routes/stop.js'; +import { createStatusHandler } from './routes/status.js'; +import { createApplyHandler } from './routes/apply.js'; +import { createClearHandler } from './routes/clear.js'; +import type { SettingsService } from '../../services/settings-service.js'; + +export function createBacklogPlanRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { + const router = Router(); + + router.post( + '/generate', + validatePathParams('projectPath'), + createGenerateHandler(events, settingsService) + ); + router.post('/stop', createStopHandler()); + router.get('/status', validatePathParams('projectPath'), createStatusHandler()); + router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService)); + router.post('/clear', validatePathParams('projectPath'), createClearHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/routes/apply.ts b/jules_branch/apps/server/src/routes/backlog-plan/routes/apply.ts new file mode 100644 index 0000000000000000000000000000000000000000..6efd1658016e407cebd7fbf2cdf8c0f032f88cea --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -0,0 +1,213 @@ +/** + * POST /apply endpoint - Apply a backlog plan + */ + +import type { Request, Response } from 'express'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; + +const featureLoader = new FeatureLoader(); + +function normalizePhaseModelEntry( + entry: PhaseModelEntry | string | undefined | null +): PhaseModelEntry | undefined { + if (!entry) return undefined; + if (typeof entry === 'string') return { model: entry }; + return entry; +} + +export function createApplyHandler(settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + plan, + branchName: rawBranchName, + } = req.body as { + projectPath: string; + plan: BacklogPlanResult; + branchName?: string; + }; + + // Validate branchName: must be undefined or a non-empty trimmed string + const branchName = + typeof rawBranchName === 'string' && rawBranchName.trim().length > 0 + ? rawBranchName.trim() + : undefined; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + if (!plan || !plan.changes) { + res.status(400).json({ success: false, error: 'plan with changes required' }); + return; + } + + let defaultPlanningMode: PlanningMode = 'skip'; + let defaultRequirePlanApproval = false; + let defaultModelEntry: PhaseModelEntry | undefined; + + if (settingsService) { + const globalSettings = await settingsService.getGlobalSettings(); + const projectSettings = await settingsService.getProjectSettings(projectPath); + + defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip'; + defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false; + defaultModelEntry = normalizePhaseModelEntry( + projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel + ); + } + + const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry); + + const appliedChanges: string[] = []; + + // Load current features for dependency validation + const allFeatures = await featureLoader.getAll(projectPath); + const featureMap = new Map(allFeatures.map((f) => [f.id, f])); + + // Process changes in order: deletes first, then adds, then updates + // This ensures we can remove dependencies before they cause issues + + // 1. First pass: Handle deletes + const deletions = plan.changes.filter((c) => c.type === 'delete'); + for (const change of deletions) { + if (!change.featureId) continue; + + try { + // Before deleting, update any features that depend on this one + for (const feature of allFeatures) { + if (feature.dependencies?.includes(change.featureId)) { + const newDeps = feature.dependencies.filter((d) => d !== change.featureId); + await featureLoader.update(projectPath, feature.id, { dependencies: newDeps }); + // Mutate the in-memory feature object so subsequent deletions use the updated + // dependency list and don't reintroduce already-removed dependency IDs. + feature.dependencies = newDeps; + logger.info( + `[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}` + ); + } + } + + // Now delete the feature + const deleted = await featureLoader.delete(projectPath, change.featureId); + if (deleted) { + appliedChanges.push(`deleted:${change.featureId}`); + featureMap.delete(change.featureId); + logger.info(`[BacklogPlan] Deleted feature ${change.featureId}`); + } + } catch (error) { + logger.error( + `[BacklogPlan] Failed to delete ${change.featureId}:`, + getErrorMessage(error) + ); + } + } + + // 2. Second pass: Handle adds + const additions = plan.changes.filter((c) => c.type === 'add'); + for (const change of additions) { + if (!change.feature) continue; + + try { + const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode; + const effectiveRequirePlanApproval = + effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite' + ? false + : (change.feature.requirePlanApproval ?? defaultRequirePlanApproval); + + // Create the new feature - use the AI-generated ID if provided + const newFeature = await featureLoader.create(projectPath, { + id: change.feature.id, // Use descriptive ID from AI if provided + title: change.feature.title, + description: change.feature.description || '', + category: change.feature.category || 'Uncategorized', + dependencies: change.feature.dependencies, + priority: change.feature.priority, + status: 'backlog', + model: change.feature.model ?? resolvedDefaultModel.model, + thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel, + reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort, + providerId: change.feature.providerId ?? resolvedDefaultModel.providerId, + planningMode: effectivePlanningMode, + requirePlanApproval: effectiveRequirePlanApproval, + branchName, + }); + + appliedChanges.push(`added:${newFeature.id}`); + featureMap.set(newFeature.id, newFeature); + logger.info(`[BacklogPlan] Created feature ${newFeature.id}: ${newFeature.title}`); + } catch (error) { + logger.error(`[BacklogPlan] Failed to add feature:`, getErrorMessage(error)); + } + } + + // 3. Third pass: Handle updates + const updates = plan.changes.filter((c) => c.type === 'update'); + for (const change of updates) { + if (!change.featureId || !change.feature) continue; + + try { + const updated = await featureLoader.update(projectPath, change.featureId, change.feature); + appliedChanges.push(`updated:${change.featureId}`); + featureMap.set(change.featureId, updated); + logger.info(`[BacklogPlan] Updated feature ${change.featureId}`); + } catch (error) { + logger.error( + `[BacklogPlan] Failed to update ${change.featureId}:`, + getErrorMessage(error) + ); + } + } + + // 4. Apply dependency updates from the plan + if (plan.dependencyUpdates) { + for (const depUpdate of plan.dependencyUpdates) { + try { + const feature = featureMap.get(depUpdate.featureId); + if (feature) { + const currentDeps = feature.dependencies || []; + const newDeps = currentDeps + .filter((d) => !depUpdate.removedDependencies.includes(d)) + .concat(depUpdate.addedDependencies.filter((d) => !currentDeps.includes(d))); + + await featureLoader.update(projectPath, depUpdate.featureId, { + dependencies: newDeps, + }); + logger.info(`[BacklogPlan] Updated dependencies for ${depUpdate.featureId}`); + } + } catch (error) { + logger.error( + `[BacklogPlan] Failed to update dependencies for ${depUpdate.featureId}:`, + getErrorMessage(error) + ); + } + } + } + + // Clear the plan before responding + try { + await clearBacklogPlan(projectPath); + } catch (error) { + logger.warn( + `[BacklogPlan] Failed to clear backlog plan after apply:`, + getErrorMessage(error) + ); + // Don't throw - operation succeeded, just cleanup failed + } + + res.json({ + success: true, + appliedChanges, + }); + } catch (error) { + logError(error, 'Apply backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/routes/clear.ts b/jules_branch/apps/server/src/routes/backlog-plan/routes/clear.ts new file mode 100644 index 0000000000000000000000000000000000000000..855dc5075804491a95c3535ed353f80d99912da3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/routes/clear.ts @@ -0,0 +1,25 @@ +/** + * POST /clear endpoint - Clear saved backlog plan + */ + +import type { Request, Response } from 'express'; +import { clearBacklogPlan, getErrorMessage, logError } from '../common.js'; + +export function createClearHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + await clearBacklogPlan(projectPath); + res.json({ success: true }); + } catch (error) { + logError(error, 'Clear backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/routes/generate.ts b/jules_branch/apps/server/src/routes/backlog-plan/routes/generate.ts new file mode 100644 index 0000000000000000000000000000000000000000..befe96e8b727032833194b3abec1a9aa7b7e258d --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/routes/generate.ts @@ -0,0 +1,77 @@ +/** + * POST /generate endpoint - Generate a backlog plan + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { + getBacklogPlanStatus, + setRunningState, + setRunningDetails, + getErrorMessage, + logError, +} from '../common.js'; +import { generateBacklogPlan } from '../generate-plan.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, prompt, model, branchName } = req.body as { + projectPath: string; + prompt: string; + model?: string; + branchName?: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + if (!prompt) { + res.status(400).json({ success: false, error: 'prompt required' }); + return; + } + + const { isRunning } = getBacklogPlanStatus(); + if (isRunning) { + res.json({ + success: false, + error: 'Backlog plan generation is already running', + }); + return; + } + + const abortController = new AbortController(); + setRunningState(true, abortController); + setRunningDetails({ + projectPath, + prompt, + model, + startedAt: new Date().toISOString(), + }); + + // Start generation in background + // Note: generateBacklogPlan handles its own error event emission + // and state cleanup in its finally block, so we only log here + generateBacklogPlan( + projectPath, + prompt, + events, + abortController, + settingsService, + model, + branchName + ).catch((error) => { + // Just log - error event already emitted by generateBacklogPlan + logError(error, 'Generate backlog plan failed (background)'); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Generate backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/routes/status.ts b/jules_branch/apps/server/src/routes/backlog-plan/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f20f1e2e7b8ef9786339166923ce7b896883bdc --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/routes/status.ts @@ -0,0 +1,20 @@ +/** + * GET /status endpoint - Get backlog plan generation status + */ + +import type { Request, Response } from 'express'; +import { getBacklogPlanStatus, loadBacklogPlan, getErrorMessage, logError } from '../common.js'; + +export function createStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const status = getBacklogPlanStatus(); + const projectPath = typeof req.query.projectPath === 'string' ? req.query.projectPath : ''; + const savedPlan = projectPath ? await loadBacklogPlan(projectPath) : null; + res.json({ success: true, ...status, savedPlan }); + } catch (error) { + logError(error, 'Get backlog plan status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/backlog-plan/routes/stop.ts b/jules_branch/apps/server/src/routes/backlog-plan/routes/stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..d969f1b1ee6f86173892709b590b55ef0edab852 --- /dev/null +++ b/jules_branch/apps/server/src/routes/backlog-plan/routes/stop.ts @@ -0,0 +1,29 @@ +/** + * POST /stop endpoint - Stop the current backlog plan generation + */ + +import type { Request, Response } from 'express'; +import { + getAbortController, + setRunningState, + setRunningDetails, + getErrorMessage, + logError, +} from '../common.js'; + +export function createStopHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const abortController = getAbortController(); + if (abortController) { + abortController.abort(); + setRunningState(false, null); + setRunningDetails(null); + } + res.json({ success: true }); + } catch (error) { + logError(error, 'Stop backlog plan failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/claude/index.ts b/jules_branch/apps/server/src/routes/claude/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec35ca1baffdf9868c6b10e1961c7d5c9ae006c0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/claude/index.ts @@ -0,0 +1,57 @@ +import { Router, Request, Response } from 'express'; +import { ClaudeUsageService } from '../../services/claude-usage-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Claude'); + +export function createClaudeRoutes(service: ClaudeUsageService): Router { + const router = Router(); + + // Get current usage (fetches from Claude CLI) + router.get('/usage', async (req: Request, res: Response) => { + try { + // Check if Claude CLI is available first + const isAvailable = await service.isAvailable(); + if (!isAvailable) { + // IMPORTANT: This endpoint is behind Automaker session auth already. + // Use a 200 + error payload for Claude CLI issues so the UI doesn't + // interpret it as an invalid Automaker session (401/403 triggers logout). + res.status(200).json({ + error: 'Claude CLI not found', + message: "Please install Claude Code CLI and run 'claude login' to authenticate", + }); + return; + } + + const usage = await service.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('Authentication required') || message.includes('token_expired')) { + // Do NOT use 401/403 here: that status code is reserved for Automaker session auth. + res.status(200).json({ + error: 'Authentication required', + message: "Please run 'claude login' to authenticate", + }); + } else if (message.includes('TRUST_PROMPT_PENDING')) { + // Trust prompt appeared but couldn't be auto-approved + res.status(200).json({ + error: 'Trust prompt pending', + message: + 'Claude CLI needs folder permission. Please run "claude" in your terminal and approve access.', + }); + } else if (message.includes('timed out')) { + res.status(200).json({ + error: 'Command timed out', + message: 'The Claude CLI took too long to respond', + }); + } else { + logger.error('Error fetching usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/claude/types.ts b/jules_branch/apps/server/src/routes/claude/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd8927462d2cfcc852eb06bb37040cb62e883aeb --- /dev/null +++ b/jules_branch/apps/server/src/routes/claude/types.ts @@ -0,0 +1,35 @@ +/** + * Claude Usage types for CLI-based usage tracking + */ + +export type ClaudeUsage = { + sessionTokensUsed: number; + sessionLimit: number; + sessionPercentage: number; + sessionResetTime: string; // ISO date string + sessionResetText: string; // Raw text like "Resets 10:59am (Asia/Dubai)" + + weeklyTokensUsed: number; + weeklyLimit: number; + weeklyPercentage: number; + weeklyResetTime: string; // ISO date string + weeklyResetText: string; // Raw text like "Resets Dec 22 at 7:59pm (Asia/Dubai)" + + sonnetWeeklyTokensUsed: number; + sonnetWeeklyPercentage: number; + sonnetResetText: string; // Raw text like "Resets Dec 27 at 9:59am (Asia/Dubai)" + + costUsed: number | null; + costLimit: number | null; + costCurrency: string | null; + + lastUpdated: string; // ISO date string + userTimezone: string; +}; + +export type ClaudeStatus = { + indicator: { + color: 'green' | 'yellow' | 'orange' | 'red' | 'gray'; + }; + description: string; +}; diff --git a/jules_branch/apps/server/src/routes/codex/index.ts b/jules_branch/apps/server/src/routes/codex/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..005a81bc4109ae86f12f9dc0ef6c08d6e8a34298 --- /dev/null +++ b/jules_branch/apps/server/src/routes/codex/index.ts @@ -0,0 +1,90 @@ +import { Router, Request, Response } from 'express'; +import { CodexUsageService } from '../../services/codex-usage-service.js'; +import { CodexModelCacheService } from '../../services/codex-model-cache-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Codex'); + +export function createCodexRoutes( + usageService: CodexUsageService, + modelCacheService: CodexModelCacheService +): Router { + const router = Router(); + + // Get current usage (attempts to fetch from Codex CLI) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if Codex CLI is available first + const isAvailable = await usageService.isAvailable(); + if (!isAvailable) { + // IMPORTANT: This endpoint is behind Automaker session auth already. + // Use a 200 + error payload for Codex CLI issues so the UI doesn't + // interpret it as an invalid Automaker session (401/403 triggers logout). + res.status(200).json({ + error: 'Codex CLI not found', + message: "Please install Codex CLI and run 'codex login' to authenticate", + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not authenticated') || message.includes('login')) { + // Do NOT use 401/403 here: that status code is reserved for Automaker session auth. + res.status(200).json({ + error: 'Authentication required', + message: "Please run 'codex login' to authenticate", + }); + } else if (message.includes('not available') || message.includes('does not provide')) { + // This is the expected case - Codex doesn't provide usage stats + res.status(200).json({ + error: 'Usage statistics not available', + message: message, + }); + } else if (message.includes('timed out')) { + res.status(200).json({ + error: 'Command timed out', + message: 'The Codex CLI took too long to respond', + }); + } else { + logger.error('Error fetching usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Get available Codex models (cached) + router.get('/models', async (req: Request, res: Response) => { + try { + const forceRefresh = req.query.refresh === 'true'; + const { models, cachedAt } = await modelCacheService.getModelsWithMetadata(forceRefresh); + + if (models.length === 0) { + res.status(503).json({ + success: false, + error: 'Codex CLI not available or not authenticated', + message: "Please install Codex CLI and run 'codex login' to authenticate", + }); + return; + } + + res.json({ + success: true, + models, + cachedAt, + }); + } catch (error) { + logger.error('Error fetching models:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ + success: false, + error: message, + }); + } + }); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/common.ts b/jules_branch/apps/server/src/routes/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..14589ffd81ffce765cbda775c5adcca09f8c4ee4 --- /dev/null +++ b/jules_branch/apps/server/src/routes/common.ts @@ -0,0 +1,38 @@ +/** + * Common utilities shared across all route modules + */ + +import { createLogger } from '@automaker/utils'; + +// Re-export git utilities from shared package +export { + BINARY_EXTENSIONS, + GIT_STATUS_MAP, + type FileStatus, + isGitRepo, + parseGitStatus, + generateSyntheticDiffForNewFile, + appendUntrackedFileDiffs, + listAllFilesInDirectory, + generateDiffsForNonGitDirectory, + getGitRepositoryDiffs, +} from '@automaker/git-utils'; + +type Logger = ReturnType; + +/** + * Get error message from error object + */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} + +/** + * Create a logError function for a specific logger + * This ensures consistent error logging format across all routes + */ +export function createLogError(logger: Logger) { + return (error: unknown, context: string): void => { + logger.error(`❌ ${context}:`, error); + }; +} diff --git a/jules_branch/apps/server/src/routes/context/index.ts b/jules_branch/apps/server/src/routes/context/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f49f1c178f511ceaea3bf16860956b473facf5b --- /dev/null +++ b/jules_branch/apps/server/src/routes/context/index.ts @@ -0,0 +1,26 @@ +/** + * Context routes - HTTP API for context file operations + * + * Provides endpoints for managing context files including + * AI-powered image description generation. + */ + +import { Router } from 'express'; +import { createDescribeImageHandler } from './routes/describe-image.js'; +import { createDescribeFileHandler } from './routes/describe-file.js'; +import type { SettingsService } from '../../services/settings-service.js'; + +/** + * Create the context router + * + * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting + * @returns Express router with context endpoints + */ +export function createContextRoutes(settingsService?: SettingsService): Router { + const router = Router(); + + router.post('/describe-image', createDescribeImageHandler(settingsService)); + router.post('/describe-file', createDescribeFileHandler(settingsService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/context/routes/describe-file.ts b/jules_branch/apps/server/src/routes/context/routes/describe-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..a59dfb7437c9d9fd5a41185b30c6dfa87d73c62b --- /dev/null +++ b/jules_branch/apps/server/src/routes/context/routes/describe-file.ts @@ -0,0 +1,220 @@ +/** + * POST /context/describe-file endpoint - Generate description for a text file + * + * Uses AI to analyze a text file and generate a concise description + * suitable for context file metadata. Model is configurable via + * phaseModels.fileDescriptionModel in settings (defaults to Haiku). + * + * SECURITY: This endpoint validates file paths against ALLOWED_ROOT_DIRECTORY + * and reads file content directly (not via Claude's Read tool) to prevent + * arbitrary file reads and prompt injection attacks. + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import { PathNotAllowedError } from '@automaker/platform'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; +import * as secureFs from '../../../lib/secure-fs.js'; +import * as path from 'path'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getPhaseModelWithOverrides, +} from '../../../lib/settings-helpers.js'; + +const logger = createLogger('DescribeFile'); + +/** + * Request body for the describe-file endpoint + */ +interface DescribeFileRequestBody { + /** Path to the file */ + filePath: string; +} + +/** + * Success response from the describe-file endpoint + */ +interface DescribeFileSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-file endpoint + */ +interface DescribeFileErrorResponse { + success: false; + error: string; +} + +/** + * Create the describe-file request handler + * + * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting + * @returns Express request handler for file description + */ +export function createDescribeFileHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as DescribeFileRequestBody; + + // Validate required fields + if (!filePath || typeof filePath !== 'string') { + const response: DescribeFileErrorResponse = { + success: false, + error: 'filePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`Starting description generation for: ${filePath}`); + + // Resolve the path for logging and cwd derivation + const resolvedPath = secureFs.resolvePath(filePath); + + // Read file content using secureFs (validates path against ALLOWED_ROOT_DIRECTORY) + // This prevents arbitrary file reads (e.g., /etc/passwd, ~/.ssh/id_rsa) + // and prompt injection attacks where malicious filePath values could inject instructions + let fileContent: string; + try { + const content = await secureFs.readFile(resolvedPath, 'utf-8'); + fileContent = typeof content === 'string' ? content : content.toString('utf-8'); + } catch (readError) { + // Path not allowed - return 403 Forbidden + if (readError instanceof PathNotAllowedError) { + logger.warn(`Path not allowed: ${filePath}`); + const response: DescribeFileErrorResponse = { + success: false, + error: 'File path is not within the allowed directory', + }; + res.status(403).json(response); + return; + } + + // File not found + if ( + readError !== null && + typeof readError === 'object' && + 'code' in readError && + readError.code === 'ENOENT' + ) { + logger.warn(`File not found: ${resolvedPath}`); + const response: DescribeFileErrorResponse = { + success: false, + error: `File not found: ${filePath}`, + }; + res.status(404).json(response); + return; + } + + const errorMessage = readError instanceof Error ? readError.message : 'Unknown error'; + logger.error(`Failed to read file: ${errorMessage}`); + const response: DescribeFileErrorResponse = { + success: false, + error: `Failed to read file: ${errorMessage}`, + }; + res.status(500).json(response); + return; + } + + // Truncate very large files to avoid token limits + const MAX_CONTENT_LENGTH = 50000; + const truncated = fileContent.length > MAX_CONTENT_LENGTH; + const contentToAnalyze = truncated + ? fileContent.substring(0, MAX_CONTENT_LENGTH) + : fileContent; + + // Get the filename for context + const fileName = path.basename(resolvedPath); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[DescribeFile]'); + + // Build prompt with file content passed as structured data + // The file content is included directly, not via tool invocation + const prompt = `${prompts.contextDescription.describeFilePrompt} + +File: ${fileName}${truncated ? ' (truncated)' : ''} + +--- FILE CONTENT --- +${contentToAnalyze}`; + + // Use the file's directory as the working directory + const cwd = path.dirname(resolvedPath); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + cwd, + settingsService, + '[DescribeFile]' + ); + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = await getPhaseModelWithOverrides( + 'fileDescriptionModel', + settingsService, + cwd, + '[DescribeFile]' + ); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info( + `Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`, + provider ? `via provider: ${provider.name}` : 'direct API' + ); + + // Use simpleQuery - provider abstraction handles routing to correct provider + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // File description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); + + const description = result.text; + + if (!description || description.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: DescribeFileErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Description generated, length: ${description.length} chars`); + + const response: DescribeFileSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('File description failed:', errorMessage); + + const response: DescribeFileErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/context/routes/describe-image.ts b/jules_branch/apps/server/src/routes/context/routes/describe-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b645d5d0e2aeb3161f8321c43a1d76f83e05020 --- /dev/null +++ b/jules_branch/apps/server/src/routes/context/routes/describe-image.ts @@ -0,0 +1,423 @@ +/** + * POST /context/describe-image endpoint - Generate description for an image + * + * Uses AI to analyze an image and generate a concise description + * suitable for context file metadata. Model is configurable via + * phaseModels.imageDescriptionModel in settings (defaults to Haiku). + * + * IMPORTANT: + * The agent runner (chat/auto-mode) sends images as multi-part content blocks (base64 image blocks), + * not by asking Claude to use the Read tool to open files. This endpoint now mirrors that approach + * so it doesn't depend on Claude's filesystem tool access or working directory restrictions. + */ + +import type { Request, Response } from 'express'; +import { createLogger, readImageAsBase64 } from '@automaker/utils'; +import { isCursorModel } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; +import * as secureFs from '../../../lib/secure-fs.js'; +import * as path from 'path'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getPromptCustomization, + getPhaseModelWithOverrides, +} from '../../../lib/settings-helpers.js'; + +const logger = createLogger('DescribeImage'); + +/** + * Allowlist of safe headers to log + * All other headers are excluded to prevent leaking sensitive values + */ +const SAFE_HEADERS_ALLOWLIST = new Set([ + 'content-type', + 'accept', + 'user-agent', + 'host', + 'referer', + 'content-length', + 'origin', + 'x-request-id', +]); + +/** + * Filter request headers to only include safe, non-sensitive values + */ +function filterSafeHeaders(headers: Record): Record { + const filtered: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (SAFE_HEADERS_ALLOWLIST.has(key.toLowerCase())) { + filtered[key] = value; + } + } + return filtered; +} + +/** + * Find the actual file path, handling Unicode character variations. + * macOS screenshots use U+202F (NARROW NO-BREAK SPACE) before AM/PM, + * but this may be transmitted as a regular space through the API. + */ +function findActualFilePath(requestedPath: string): string | null { + // First, try the exact path + if (secureFs.existsSync(requestedPath)) { + return requestedPath; + } + + // Try with Unicode normalization + const normalizedPath = requestedPath.normalize('NFC'); + if (secureFs.existsSync(normalizedPath)) { + return normalizedPath; + } + + // If not found, try to find the file in the directory by matching the basename + // This handles cases where the space character differs (U+0020 vs U+202F vs U+00A0) + const dir = path.dirname(requestedPath); + const baseName = path.basename(requestedPath); + + if (!secureFs.existsSync(dir)) { + return null; + } + + try { + const files = secureFs.readdirSync(dir); + + // Normalize the requested basename for comparison + // Replace various space-like characters with regular space for comparison + const normalizeSpaces = (s: string): string => s.replace(/[\u00A0\u202F\u2009\u200A]/g, ' '); + + const normalizedBaseName = normalizeSpaces(baseName); + + for (const file of files) { + if (normalizeSpaces(file) === normalizedBaseName) { + logger.info(`Found matching file with different space encoding: ${file}`); + return path.join(dir, file); + } + } + } catch (err) { + logger.error(`Error reading directory ${dir}: ${err}`); + } + + return null; +} + +/** + * Request body for the describe-image endpoint + */ +interface DescribeImageRequestBody { + /** Path to the image file */ + imagePath: string; +} + +/** + * Success response from the describe-image endpoint + */ +interface DescribeImageSuccessResponse { + success: true; + description: string; +} + +/** + * Error response from the describe-image endpoint + */ +interface DescribeImageErrorResponse { + success: false; + error: string; + requestId?: string; +} + +/** + * Map SDK/CLI errors to a stable status + user-facing message. + */ +function mapDescribeImageError(rawMessage: string | undefined): { + statusCode: number; + userMessage: string; +} { + const baseResponse = { + statusCode: 500, + userMessage: 'Failed to generate an image description. Please try again.', + }; + + if (!rawMessage) return baseResponse; + + if ( + rawMessage.includes('Claude Code process exited') || + rawMessage.includes('Claude Code process terminated by signal') + ) { + const exitCodeMatch = rawMessage.match(/exited with code (\d+)/); + const signalMatch = rawMessage.match(/terminated by signal (\w+)/); + const detail = exitCodeMatch + ? ` (exit code: ${exitCodeMatch[1]})` + : signalMatch + ? ` (signal: ${signalMatch[1]})` + : ''; + + // Crash/OS-kill signals suggest a process crash, not an auth failure — + // omit auth recovery advice and suggest retry/reporting instead. + const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP']; + const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false; + + if (isCrashSignal) { + return { + statusCode: 503, + userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`, + }; + } + + return { + statusCode: 503, + userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`, + }; + } + + if ( + rawMessage.includes('Failed to spawn Claude Code process') || + rawMessage.includes('Claude Code executable not found') || + rawMessage.includes('Claude Code native binary not found') + ) { + return { + statusCode: 503, + userMessage: + 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, then try again.', + }; + } + + if (rawMessage.toLowerCase().includes('rate limit') || rawMessage.includes('429')) { + return { + statusCode: 429, + userMessage: 'Rate limited while describing the image. Please wait a moment and try again.', + }; + } + + if (rawMessage.toLowerCase().includes('payload too large') || rawMessage.includes('413')) { + return { + statusCode: 413, + userMessage: + 'The image is too large to send for description. Please resize/compress it and try again.', + }; + } + + return baseResponse; +} + +/** + * Create the describe-image request handler + * + * Uses the provider abstraction with multi-part content blocks to include the image (base64), + * matching the agent runner behavior. + * + * @param settingsService - Optional settings service for loading autoLoadClaudeMd setting + * @returns Express request handler for image description + */ +export function createDescribeImageHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + const requestId = `describe-image-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const startedAt = Date.now(); + + // Request envelope logs (high value when correlating failures) + // Only log safe headers to prevent leaking sensitive values (auth tokens, cookies, etc.) + logger.info(`[${requestId}] ===== POST /api/context/describe-image =====`); + logger.info(`[${requestId}] headers=${JSON.stringify(filterSafeHeaders(req.headers))}`); + logger.info(`[${requestId}] body=${JSON.stringify(req.body)}`); + + try { + const { imagePath } = req.body as DescribeImageRequestBody; + + // Validate required fields + if (!imagePath || typeof imagePath !== 'string') { + const response: DescribeImageErrorResponse = { + success: false, + error: 'imagePath is required and must be a string', + requestId, + }; + res.status(400).json(response); + return; + } + + logger.info(`[${requestId}] imagePath="${imagePath}" type=${typeof imagePath}`); + + // Find the actual file path (handles Unicode space character variations) + const actualPath = findActualFilePath(imagePath); + if (!actualPath) { + logger.error(`[${requestId}] File not found: ${imagePath}`); + // Log hex representation of the path for debugging + const hexPath = Buffer.from(imagePath).toString('hex'); + logger.error(`[${requestId}] imagePath hex: ${hexPath}`); + const response: DescribeImageErrorResponse = { + success: false, + error: `File not found: ${imagePath}`, + requestId, + }; + res.status(404).json(response); + return; + } + + if (actualPath !== imagePath) { + logger.info(`[${requestId}] Using actual path: ${actualPath}`); + } + + // Log path + stats (this is often where issues start: missing file, perms, size) + let stat: ReturnType | null = null; + try { + stat = secureFs.statSync(actualPath); + logger.info( + `[${requestId}] fileStats size=${stat.size} bytes mtime=${stat.mtime.toISOString()}` + ); + } catch (statErr) { + logger.warn( + `[${requestId}] Unable to stat image file (continuing to read base64): ${String(statErr)}` + ); + } + + // Read image and convert to base64 (same as agent runner) + logger.info(`[${requestId}] Reading image into base64...`); + const imageReadStart = Date.now(); + const imageData = await readImageAsBase64(actualPath); + const imageReadMs = Date.now() - imageReadStart; + + const base64Length = imageData.base64.length; + const estimatedBytes = Math.ceil((base64Length * 3) / 4); + logger.info(`[${requestId}] imageReadMs=${imageReadMs}`); + logger.info( + `[${requestId}] image meta filename=${imageData.filename} mime=${imageData.mimeType} base64Len=${base64Length} estBytes=${estimatedBytes}` + ); + + const cwd = path.dirname(actualPath); + logger.info(`[${requestId}] Using cwd=${cwd}`); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + cwd, + settingsService, + '[DescribeImage]' + ); + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider, + credentials, + } = await getPhaseModelWithOverrides( + 'imageDescriptionModel', + settingsService, + cwd, + '[DescribeImage]' + ); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info( + `[${requestId}] Using model: ${model}`, + provider ? `via provider: ${provider.name}` : 'direct API' + ); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[DescribeImage]'); + + // Build the instruction text from centralized prompts + const instructionText = prompts.contextDescription.describeImagePrompt; + + // Build prompt based on provider capability + // Some providers (like Cursor) may not support image content blocks + let prompt: string | Array<{ type: string; text?: string; source?: object }>; + + if (isCursorModel(model)) { + // Cursor may not support base64 image blocks directly + // Use text prompt with image path reference + logger.info(`[${requestId}] Using text prompt for Cursor model`); + prompt = `${instructionText}\n\nImage file: ${actualPath}\nMIME type: ${imageData.mimeType}`; + } else { + // Claude and other vision-capable models support multi-part prompts with images + logger.info(`[${requestId}] Using multi-part prompt with image block`); + prompt = [ + { type: 'text', text: instructionText }, + { + type: 'image', + source: { + type: 'base64', + media_type: imageData.mimeType, + data: imageData.base64, + }, + }, + ]; + } + + logger.info(`[${requestId}] Calling simpleQuery...`); + const queryStart = Date.now(); + + // Use simpleQuery - provider abstraction handles routing + const result = await simpleQuery({ + prompt, + model, + cwd, + maxTurns: 1, + allowedTools: isCursorModel(model) ? ['Read'] : [], // Allow Read for Cursor to read image if needed + thinkingLevel, + readOnly: true, // Image description only reads, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); + + logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`); + + const description = result.text; + + if (!description || description.trim().length === 0) { + logger.warn(`[${requestId}] Received empty response from AI`); + const response: DescribeImageErrorResponse = { + success: false, + error: 'Failed to generate description - empty response', + requestId, + }; + res.status(500).json(response); + return; + } + + const totalMs = Date.now() - startedAt; + logger.info(`[${requestId}] Success descriptionLen=${description.length} totalMs=${totalMs}`); + + const response: DescribeImageSuccessResponse = { + success: true, + description: description.trim(), + }; + res.json(response); + } catch (error) { + const totalMs = Date.now() - startedAt; + const err = error as unknown; + const errMessage = err instanceof Error ? err.message : String(err); + const errName = err instanceof Error ? err.name : 'UnknownError'; + const errStack = err instanceof Error ? err.stack : undefined; + + logger.error(`[${requestId}] FAILED totalMs=${totalMs}`); + logger.error(`[${requestId}] errorName=${errName}`); + logger.error(`[${requestId}] errorMessage=${errMessage}`); + if (errStack) logger.error(`[${requestId}] errorStack=${errStack}`); + + // Dump all enumerable + non-enumerable props (this is where stderr/stdout/exitCode often live) + try { + const props = err && typeof err === 'object' ? Object.getOwnPropertyNames(err) : []; + logger.error(`[${requestId}] errorProps=${JSON.stringify(props)}`); + if (err && typeof err === 'object') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyErr = err as any; + const details = JSON.stringify(anyErr, props as unknown as string[]); + logger.error(`[${requestId}] errorDetails=${details}`); + } + } catch (stringifyErr) { + logger.error(`[${requestId}] Failed to serialize error object: ${String(stringifyErr)}`); + } + + const { statusCode, userMessage } = mapDescribeImageError(errMessage); + const response: DescribeImageErrorResponse = { + success: false, + error: `${userMessage} (requestId: ${requestId})`, + requestId, + }; + res.status(statusCode).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/enhance-prompt/index.ts b/jules_branch/apps/server/src/routes/enhance-prompt/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..db50ea4cd04e60c076ee07f9118afecfa2d504f9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/enhance-prompt/index.ts @@ -0,0 +1,24 @@ +/** + * Enhance prompt routes - HTTP API for AI-powered text enhancement + * + * Provides endpoints for enhancing user input text using Claude AI + * with different enhancement modes (improve, expand, simplify, etc.) + */ + +import { Router } from 'express'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createEnhanceHandler } from './routes/enhance.js'; + +/** + * Create the enhance-prompt router + * + * @param settingsService - Settings service for loading custom prompts + * @returns Express router with enhance-prompt endpoints + */ +export function createEnhancePromptRoutes(settingsService?: SettingsService): Router { + const router = Router(); + + router.post('/', createEnhanceHandler(settingsService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/jules_branch/apps/server/src/routes/enhance-prompt/routes/enhance.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dbd72686aa041a99c023161c17ed0f173afc528 --- /dev/null +++ b/jules_branch/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -0,0 +1,276 @@ +/** + * POST /enhance-prompt endpoint - Enhance user input text + * + * Uses the provider abstraction to enhance text based on the specified + * enhancement mode. Works with any configured provider (Claude, Cursor, etc.). + * Supports modes: improve, technical, simplify, acceptance, ux-reviewer + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import { resolveModelString } from '@automaker/model-resolver'; +import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types'; +import { getAppSpecPath } from '@automaker/platform'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { + buildUserPrompt, + isValidEnhancementMode, + type EnhancementMode, +} from '../../../lib/enhancement-prompts.js'; +import { + extractTechnologyStack, + extractXmlElements, + extractXmlSection, + unescapeXml, +} from '../../../lib/xml-extractor.js'; + +const logger = createLogger('EnhancePrompt'); + +/** + * Request body for the enhance endpoint + */ +interface EnhanceRequestBody { + /** The original text to enhance */ + originalText: string; + /** The enhancement mode to apply */ + enhancementMode: string; + /** Optional model override */ + model?: string; + /** Optional thinking level for Claude models */ + thinkingLevel?: ThinkingLevel; + /** Optional project path for per-project Claude API profile */ + projectPath?: string; +} + +/** + * Success response from the enhance endpoint + */ +interface EnhanceSuccessResponse { + success: true; + enhancedText: string; +} + +/** + * Error response from the enhance endpoint + */ +interface EnhanceErrorResponse { + success: false; + error: string; +} + +async function buildProjectContext(projectPath: string): Promise { + const contextBlocks: string[] = []; + + try { + const appSpecPath = getAppSpecPath(projectPath); + const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string; + + const projectName = extractXmlSection(specContent, 'project_name'); + const overview = extractXmlSection(specContent, 'overview'); + const techStack = extractTechnologyStack(specContent); + const coreSection = extractXmlSection(specContent, 'core_capabilities'); + const coreCapabilities = coreSection ? extractXmlElements(coreSection, 'capability') : []; + + const summaryLines: string[] = []; + if (projectName) { + summaryLines.push(`Name: ${unescapeXml(projectName.trim())}`); + } + if (overview) { + summaryLines.push(`Overview: ${unescapeXml(overview.trim())}`); + } + if (techStack.length > 0) { + summaryLines.push(`Tech Stack: ${techStack.join(', ')}`); + } + if (coreCapabilities.length > 0) { + summaryLines.push(`Core Capabilities: ${coreCapabilities.slice(0, 10).join(', ')}`); + } + + if (summaryLines.length > 0) { + contextBlocks.push(`PROJECT CONTEXT:\n${summaryLines.map((line) => `- ${line}`).join('\n')}`); + } + } catch (error) { + logger.debug('No app_spec.txt context available for enhancement', error); + } + + try { + const featureLoader = new FeatureLoader(); + const features = await featureLoader.getAll(projectPath); + const featureTitles = features + .map((feature) => feature.title || feature.name || feature.id) + .filter((title) => Boolean(title)); + + if (featureTitles.length > 0) { + const listed = featureTitles.slice(0, 30).map((title) => `- ${title}`); + contextBlocks.push( + `EXISTING FEATURES (avoid duplicates):\n${listed.join('\n')}${ + featureTitles.length > 30 ? '\n- ...' : '' + }` + ); + } + } catch (error) { + logger.debug('Failed to load existing features for enhancement context', error); + } + + if (contextBlocks.length === 0) { + return null; + } + + return contextBlocks.join('\n\n'); +} + +/** + * Create the enhance request handler + * + * @param settingsService - Optional settings service for loading custom prompts + * @returns Express request handler for text enhancement + */ +export function createEnhanceHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { originalText, enhancementMode, model, thinkingLevel, projectPath } = + req.body as EnhanceRequestBody; + + // Validate required fields + if (!originalText || typeof originalText !== 'string') { + const response: EnhanceErrorResponse = { + success: false, + error: 'originalText is required and must be a string', + }; + res.status(400).json(response); + return; + } + + if (!enhancementMode || typeof enhancementMode !== 'string') { + const response: EnhanceErrorResponse = { + success: false, + error: 'enhancementMode is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate text is not empty + const trimmedText = originalText.trim(); + if (trimmedText.length === 0) { + const response: EnhanceErrorResponse = { + success: false, + error: 'originalText cannot be empty', + }; + res.status(400).json(response); + return; + } + + // Validate and normalize enhancement mode + const normalizedMode = enhancementMode.toLowerCase(); + const validMode: EnhancementMode = isValidEnhancementMode(normalizedMode) + ? normalizedMode + : 'improve'; + + logger.info(`Enhancing text with mode: ${validMode}, length: ${trimmedText.length} chars`); + + // Load enhancement prompts from settings (merges custom + defaults) + const prompts = await getPromptCustomization(settingsService, '[EnhancePrompt]'); + + // Get the system prompt for this mode from merged prompts + const systemPromptMap: Record = { + improve: prompts.enhancement.improveSystemPrompt, + technical: prompts.enhancement.technicalSystemPrompt, + simplify: prompts.enhancement.simplifySystemPrompt, + acceptance: prompts.enhancement.acceptanceSystemPrompt, + 'ux-reviewer': prompts.enhancement.uxReviewerSystemPrompt, + }; + const systemPrompt = systemPromptMap[validMode]; + + logger.debug(`Using ${validMode} system prompt (length: ${systemPrompt.length} chars)`); + + // Build the user prompt with few-shot examples + const userPrompt = buildUserPrompt(validMode, trimmedText, true); + const projectContext = projectPath ? await buildProjectContext(projectPath) : null; + if (projectContext) { + logger.debug('Including project context in enhancement prompt'); + } + + // Check if the model is a provider model (like "GLM-4.5-Air") + // If so, get the provider config and resolved Claude model + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + let credentials = await settingsService?.getCredentials(); + + if (model && settingsService) { + const providerResult = await getProviderByModelId( + model, + settingsService, + '[EnhancePrompt]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + credentials = providerResult.credentials; + logger.info( + `Using provider "${providerResult.provider.name}" for model "${model}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } + + // Resolve the model for API call. + // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") + // to the API, NOT the resolved Claude model - otherwise we get "model not found" + const modelForApi = claudeCompatibleProvider + ? model + : providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet); + + logger.debug(`Using model: ${modelForApi}`); + + // Use simpleQuery - provider abstraction handles routing to correct provider + // The system prompt is combined with user prompt since some providers + // don't have a separate system prompt concept + const result = await simpleQuery({ + prompt: [systemPrompt, projectContext, userPrompt].filter(Boolean).join('\n\n'), + model: modelForApi, + cwd: process.cwd(), // Enhancement doesn't need a specific working directory + maxTurns: 1, + allowedTools: [], + thinkingLevel, + readOnly: true, // Prompt enhancement only generates text, doesn't write files + credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + }); + + const enhancedText = result.text; + + if (!enhancedText || enhancedText.trim().length === 0) { + logger.warn('Received empty response from AI'); + const response: EnhanceErrorResponse = { + success: false, + error: 'Failed to generate enhanced text - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Enhancement complete, output length: ${enhancedText.length} chars`); + + const response: EnhanceSuccessResponse = { + success: true, + enhancedText: enhancedText.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('Enhancement failed:', errorMessage); + + const response: EnhanceErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/event-history/common.ts b/jules_branch/apps/server/src/routes/event-history/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd0ad3fe8d0dbbb887a85c74a9b3dac41e39fb39 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/common.ts @@ -0,0 +1,19 @@ +/** + * Common utilities for event history routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for event history operations */ +export const logger = createLogger('EventHistory'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/event-history/index.ts b/jules_branch/apps/server/src/routes/event-history/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..93297ddd926f8a6dfb87a26252633d6c3d27a442 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/index.ts @@ -0,0 +1,68 @@ +/** + * Event History routes - HTTP API for event history management + * + * Provides endpoints for: + * - Listing events with filtering + * - Getting individual event details + * - Deleting events + * - Clearing all events + * - Replaying events to test hooks + * + * Mounted at /api/event-history in the main server. + */ + +import { Router } from 'express'; +import type { EventHistoryService } from '../../services/event-history-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createListHandler } from './routes/list.js'; +import { createGetHandler } from './routes/get.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createClearHandler } from './routes/clear.js'; +import { createReplayHandler } from './routes/replay.js'; + +/** + * Create event history router with all endpoints + * + * Endpoints: + * - POST /list - List events with optional filtering + * - POST /get - Get a single event by ID + * - POST /delete - Delete an event by ID + * - POST /clear - Clear all events for a project + * - POST /replay - Replay an event to trigger hooks + * + * @param eventHistoryService - Instance of EventHistoryService + * @param settingsService - Instance of SettingsService (for replay) + * @returns Express Router configured with all event history endpoints + */ +export function createEventHistoryRoutes( + eventHistoryService: EventHistoryService, + settingsService: SettingsService +): Router { + const router = Router(); + + // List events with filtering + router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService)); + + // Get single event + router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService)); + + // Delete event + router.post( + '/delete', + validatePathParams('projectPath'), + createDeleteHandler(eventHistoryService) + ); + + // Clear all events + router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService)); + + // Replay event + router.post( + '/replay', + validatePathParams('projectPath'), + createReplayHandler(eventHistoryService, settingsService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/event-history/routes/clear.ts b/jules_branch/apps/server/src/routes/event-history/routes/clear.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6e6bb5899bc806a64af0c2d05b05056642734ef --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/routes/clear.ts @@ -0,0 +1,33 @@ +/** + * POST /api/event-history/clear - Clear all events for a project + * + * Request body: { projectPath: string } + * Response: { success: true, cleared: number } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createClearHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const cleared = await eventHistoryService.clearEvents(projectPath); + + res.json({ + success: true, + cleared, + }); + } catch (error) { + logError(error, 'Clear events failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/event-history/routes/delete.ts b/jules_branch/apps/server/src/routes/event-history/routes/delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea3f6b16c22627adb378f0e779290521bdd71db9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/routes/delete.ts @@ -0,0 +1,43 @@ +/** + * POST /api/event-history/delete - Delete an event by ID + * + * Request body: { projectPath: string, eventId: string } + * Response: { success: true } or { success: false, error: string } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId } = req.body as { + projectPath: string; + eventId: string; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + const deleted = await eventHistoryService.deleteEvent(projectPath, eventId); + + if (!deleted) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/event-history/routes/get.ts b/jules_branch/apps/server/src/routes/event-history/routes/get.ts new file mode 100644 index 0000000000000000000000000000000000000000..f892fd414b3700b60fa0db4e650a2bb8a3f07cf1 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/routes/get.ts @@ -0,0 +1,46 @@ +/** + * POST /api/event-history/get - Get a single event by ID + * + * Request body: { projectPath: string, eventId: string } + * Response: { success: true, event: StoredEvent } or { success: false, error: string } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId } = req.body as { + projectPath: string; + eventId: string; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + const event = await eventHistoryService.getEvent(projectPath, eventId); + + if (!event) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + res.json({ + success: true, + event, + }); + } catch (error) { + logError(error, 'Get event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/event-history/routes/list.ts b/jules_branch/apps/server/src/routes/event-history/routes/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..551594f281a0d239206207d38ad6e7677d06e265 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/routes/list.ts @@ -0,0 +1,53 @@ +/** + * POST /api/event-history/list - List events for a project + * + * Request body: { + * projectPath: string, + * filter?: { + * trigger?: EventHookTrigger, + * featureId?: string, + * since?: string, + * until?: string, + * limit?: number, + * offset?: number + * } + * } + * Response: { success: true, events: StoredEventSummary[], total: number } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import type { EventHistoryFilter } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createListHandler(eventHistoryService: EventHistoryService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, filter } = req.body as { + projectPath: string; + filter?: EventHistoryFilter; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const events = await eventHistoryService.getEvents(projectPath, filter); + const total = await eventHistoryService.getEventCount(projectPath, { + ...filter, + limit: undefined, + offset: undefined, + }); + + res.json({ + success: true, + events, + total, + }); + } catch (error) { + logError(error, 'List events failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/event-history/routes/replay.ts b/jules_branch/apps/server/src/routes/event-history/routes/replay.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6f27a404307fc673c75604b292b74b4cd645bf4 --- /dev/null +++ b/jules_branch/apps/server/src/routes/event-history/routes/replay.ts @@ -0,0 +1,234 @@ +/** + * POST /api/event-history/replay - Replay an event to trigger hooks + * + * Request body: { + * projectPath: string, + * eventId: string, + * hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks) + * } + * Response: { success: true, result: EventReplayResult } + */ + +import type { Request, Response } from 'express'; +import type { EventHistoryService } from '../../../services/event-history-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError, logger } from '../common.js'; + +const execAsync = promisify(exec); + +/** Default timeout for shell commands (30 seconds) */ +const DEFAULT_SHELL_TIMEOUT = 30000; + +/** Default timeout for HTTP requests (10 seconds) */ +const DEFAULT_HTTP_TIMEOUT = 10000; + +interface HookContext { + featureId?: string; + featureName?: string; + projectPath?: string; + projectName?: string; + error?: string; + errorType?: string; + timestamp: string; + eventType: string; +} + +/** + * Substitute {{variable}} placeholders in a string + */ +function substituteVariables(template: string, context: HookContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof HookContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); +} + +/** + * Execute a single hook and return the result + */ +async function executeHook(hook: EventHook, context: HookContext): Promise { + const hookName = hook.name || hook.id; + const startTime = Date.now(); + + try { + if (hook.action.type === 'shell') { + const command = substituteVariables(hook.action.command, context); + const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT; + + logger.info(`Replaying shell hook "${hookName}": ${command}`); + + await execAsync(command, { + timeout, + maxBuffer: 1024 * 1024, + }); + + return { + hookId: hook.id, + hookName: hook.name, + success: true, + durationMs: Date.now() - startTime, + }; + } else if (hook.action.type === 'http') { + const url = substituteVariables(hook.action.url, context); + const method = hook.action.method || 'POST'; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (hook.action.headers) { + for (const [key, value] of Object.entries(hook.action.headers)) { + headers[key] = substituteVariables(value, context); + } + } + + let body: string | undefined; + if (hook.action.body) { + body = substituteVariables(hook.action.body, context); + } else if (method !== 'GET') { + body = JSON.stringify({ + eventType: context.eventType, + timestamp: context.timestamp, + featureId: context.featureId, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + }); + } + + logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT); + + const response = await fetch(url, { + method, + headers, + body: method !== 'GET' ? body : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + durationMs: Date.now() - startTime, + }; + } + + return { + hookId: hook.id, + hookName: hook.name, + success: true, + durationMs: Date.now() - startTime, + }; + } + + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: 'Unknown hook action type', + durationMs: Date.now() - startTime, + }; + } catch (error) { + const errorMessage = + error instanceof Error + ? error.name === 'AbortError' + ? 'Request timed out' + : error.message + : String(error); + + return { + hookId: hook.id, + hookName: hook.name, + success: false, + error: errorMessage, + durationMs: Date.now() - startTime, + }; + } +} + +export function createReplayHandler( + eventHistoryService: EventHistoryService, + settingsService: SettingsService +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, eventId, hookIds } = req.body as { + projectPath: string; + eventId: string; + hookIds?: string[]; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!eventId || typeof eventId !== 'string') { + res.status(400).json({ success: false, error: 'eventId is required' }); + return; + } + + // Get the event + const event = await eventHistoryService.getEvent(projectPath, eventId); + if (!event) { + res.status(404).json({ success: false, error: 'Event not found' }); + return; + } + + // Get hooks from settings + const settings = await settingsService.getGlobalSettings(); + let hooks = settings.eventHooks || []; + + // Filter to matching trigger and enabled hooks + hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger); + + // If specific hook IDs requested, filter to those + if (hookIds && hookIds.length > 0) { + hooks = hooks.filter((h) => hookIds.includes(h.id)); + } + + // Build context for variable substitution + const context: HookContext = { + featureId: event.featureId, + featureName: event.featureName, + projectPath: event.projectPath, + projectName: event.projectName, + error: event.error, + errorType: event.errorType, + timestamp: event.timestamp, + eventType: event.trigger, + }; + + // Execute all hooks in parallel + const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context))); + + const result: EventReplayResult = { + eventId, + hooksTriggered: hooks.length, + hookResults, + }; + + logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`); + + res.json({ + success: true, + result, + }); + } catch (error) { + logError(error, 'Replay event failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/common.ts b/jules_branch/apps/server/src/routes/features/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a5bf8f167d9bc141dbd3173240918a03e28bb4f --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for features routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Features'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/features/index.ts b/jules_branch/apps/server/src/routes/features/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..60ef92317af5cc73a82ffd94efdc486dc23c4ae2 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/index.ts @@ -0,0 +1,95 @@ +/** + * Features routes - HTTP API for feature management + */ + +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 { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createListHandler } from './routes/list.js'; +import { createGetHandler } from './routes/get.js'; +import { createCreateHandler } from './routes/create.js'; +import { createUpdateHandler } from './routes/update.js'; +import { createBulkUpdateHandler } from './routes/bulk-update.js'; +import { createBulkDeleteHandler } from './routes/bulk-delete.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js'; +import { createGenerateTitleHandler } from './routes/generate-title.js'; +import { createExportHandler } from './routes/export.js'; +import { createImportHandler, createConflictCheckHandler } from './routes/import.js'; +import { + createOrphanedListHandler, + createOrphanedResolveHandler, + createOrphanedBulkResolveHandler, +} from './routes/orphaned.js'; + +export function createFeaturesRoutes( + featureLoader: FeatureLoader, + settingsService?: SettingsService, + events?: EventEmitter, + autoModeService?: AutoModeServiceCompat +): Router { + const router = Router(); + + router.post( + '/list', + validatePathParams('projectPath'), + createListHandler(featureLoader, autoModeService) + ); + router.get( + '/list', + validatePathParams('projectPath'), + createListHandler(featureLoader, autoModeService) + ); + router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader)); + router.post( + '/create', + validatePathParams('projectPath'), + createCreateHandler(featureLoader, events) + ); + router.post( + '/update', + validatePathParams('projectPath'), + createUpdateHandler(featureLoader, events) + ); + router.post( + '/bulk-update', + validatePathParams('projectPath'), + createBulkUpdateHandler(featureLoader) + ); + router.post( + '/bulk-delete', + validatePathParams('projectPath'), + createBulkDeleteHandler(featureLoader) + ); + router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader)); + router.post('/agent-output', createAgentOutputHandler(featureLoader)); + router.post('/raw-output', createRawOutputHandler(featureLoader)); + router.post('/generate-title', createGenerateTitleHandler(settingsService)); + router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader)); + router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader)); + router.post( + '/check-conflicts', + validatePathParams('projectPath'), + createConflictCheckHandler(featureLoader) + ); + router.post( + '/orphaned', + validatePathParams('projectPath'), + createOrphanedListHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/resolve', + validatePathParams('projectPath'), + createOrphanedResolveHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/bulk-resolve', + validatePathParams('projectPath'), + createOrphanedBulkResolveHandler(featureLoader) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/agent-output.ts b/jules_branch/apps/server/src/routes/features/routes/agent-output.ts new file mode 100644 index 0000000000000000000000000000000000000000..d88e6d6f1f5b9cbdf469e4a25cee67a154e20f9c --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/agent-output.ts @@ -0,0 +1,61 @@ +/** + * POST /agent-output endpoint - Get agent output for a feature + * POST /raw-output endpoint - Get raw JSONL output for debugging + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAgentOutputHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const content = await featureLoader.getAgentOutput(projectPath, featureId); + res.json({ success: true, content }); + } catch (error) { + logError(error, 'Get agent output failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler for getting raw JSONL output for debugging + */ +export function createRawOutputHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const content = await featureLoader.getRawOutput(projectPath, featureId); + res.json({ success: true, content }); + } catch (error) { + logError(error, 'Get raw output failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/bulk-delete.ts b/jules_branch/apps/server/src/routes/features/routes/bulk-delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..851c288cf9bbba78d39a8da407ad2e71fc748677 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/bulk-delete.ts @@ -0,0 +1,69 @@ +/** + * POST /bulk-delete endpoint - Delete multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkDeleteRequest { + projectPath: string; + featureIds: string[]; +} + +interface BulkDeleteResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds } = req.body as BulkDeleteRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + const results: BulkDeleteResult[] = []; + + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + const success = await featureLoader.delete(projectPath, featureId); + if (success) { + return { featureId, success: true }; + } + return { + featureId, + success: false, + error: 'Deletion failed. Check server logs for details.', + }; + }) + ); + results.push(...batchResults); + } + + const successCount = results.reduce((count, r) => count + (r.success ? 1 : 0), 0); + const failureCount = results.length - successCount; + + res.json({ + success: failureCount === 0, + deletedCount: successCount, + failedCount: failureCount, + results, + }); + } catch (error) { + logError(error, 'Bulk delete features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/bulk-update.ts b/jules_branch/apps/server/src/routes/features/routes/bulk-update.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fb8cc9f8aca14e9f7a99d4f772db0e848ce23b6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/bulk-update.ts @@ -0,0 +1,94 @@ +/** + * POST /bulk-update endpoint - Update multiple features at once + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { Feature } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +interface BulkUpdateRequest { + projectPath: string; + featureIds: string[]; + updates: Partial; +} + +interface BulkUpdateResult { + featureId: string; + success: boolean; + error?: string; +} + +export function createBulkUpdateHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest; + + if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) { + res.status(400).json({ + success: false, + error: 'projectPath and featureIds (non-empty array) are required', + }); + return; + } + + if (!updates || Object.keys(updates).length === 0) { + res.status(400).json({ + success: false, + error: 'updates object with at least one field is required', + }); + return; + } + + const results: BulkUpdateResult[] = []; + const updatedFeatures: Feature[] = []; + + // Process in parallel batches of 20 for efficiency + const BATCH_SIZE = 20; + for (let i = 0; i < featureIds.length; i += BATCH_SIZE) { + const batch = featureIds.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(async (featureId) => { + try { + const updated = await featureLoader.update(projectPath, featureId, updates); + return { featureId, success: true as const, feature: updated }; + } catch (error) { + return { + featureId, + success: false as const, + error: getErrorMessage(error), + }; + } + }) + ); + + for (const result of batchResults) { + if (result.success) { + results.push({ featureId: result.featureId, success: true }); + updatedFeatures.push(result.feature); + } else { + results.push({ + featureId: result.featureId, + success: false, + error: result.error, + }); + } + } + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + res.json({ + success: failureCount === 0, + updatedCount: successCount, + failedCount: failureCount, + results, + features: updatedFeatures, + }); + } catch (error) { + logError(error, 'Bulk update features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/create.ts b/jules_branch/apps/server/src/routes/features/routes/create.ts new file mode 100644 index 0000000000000000000000000000000000000000..c607e72e414b05d7dbf4f1d7922508b1037f3893 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/create.ts @@ -0,0 +1,44 @@ +/** + * POST /create endpoint - Create a new feature + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { Feature } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, feature } = req.body as { + projectPath: string; + feature: Partial; + }; + + if (!projectPath || !feature) { + res.status(400).json({ + success: false, + error: 'projectPath and feature are required', + }); + return; + } + + const created = await featureLoader.create(projectPath, feature); + + // Emit feature_created event for hooks + if (events) { + events.emit('feature:created', { + featureId: created.id, + featureName: created.title || 'Untitled Feature', + projectPath, + }); + } + + res.json({ success: true, feature: created }); + } catch (error) { + logError(error, 'Create feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/delete.ts b/jules_branch/apps/server/src/routes/features/routes/delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b6831f6770d4e6a01771c13beef06dbe59857f5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/delete.ts @@ -0,0 +1,32 @@ +/** + * POST /delete endpoint - Delete a feature + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const success = await featureLoader.delete(projectPath, featureId); + res.json({ success }); + } catch (error) { + logError(error, 'Delete feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/export.ts b/jules_branch/apps/server/src/routes/features/routes/export.ts new file mode 100644 index 0000000000000000000000000000000000000000..28a048b4b59f74fd5a3d843fc6d3d93a21c922c3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/export.ts @@ -0,0 +1,96 @@ +/** + * POST /export endpoint - Export features to JSON or YAML format + */ + +import type { Request, Response } from 'express'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import { + getFeatureExportService, + type ExportFormat, + type BulkExportOptions, +} from '../../../services/feature-export-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface ExportRequest { + projectPath: string; + /** Feature IDs to export. If empty/undefined, exports all features */ + featureIds?: string[]; + /** Export format: 'json' or 'yaml' */ + format?: ExportFormat; + /** Whether to include description history */ + includeHistory?: boolean; + /** Whether to include plan spec */ + includePlanSpec?: boolean; + /** Filter by category */ + category?: string; + /** Filter by status */ + status?: string; + /** Pretty print output */ + prettyPrint?: boolean; + /** Optional metadata to include */ + metadata?: { + projectName?: string; + projectPath?: string; + branch?: string; + [key: string]: unknown; + }; +} + +export function createExportHandler(_featureLoader: FeatureLoader) { + const exportService = getFeatureExportService(); + + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + featureIds, + format = 'json', + includeHistory = true, + includePlanSpec = true, + category, + status, + prettyPrint = true, + metadata, + } = req.body as ExportRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // Validate format + if (format !== 'json' && format !== 'yaml') { + res.status(400).json({ + success: false, + error: 'format must be "json" or "yaml"', + }); + return; + } + + const options: BulkExportOptions = { + format, + includeHistory, + includePlanSpec, + category, + status, + featureIds, + prettyPrint, + metadata, + }; + + const exportData = await exportService.exportFeatures(projectPath, options); + + // Return the export data as a string in the response + res.json({ + success: true, + data: exportData, + format, + contentType: format === 'json' ? 'application/json' : 'application/x-yaml', + filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`, + }); + } catch (error) { + logError(error, 'Export features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/generate-title.ts b/jules_branch/apps/server/src/routes/features/routes/generate-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..a84680b0c45b8da06382d937d98aed64f511acc6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/generate-title.ts @@ -0,0 +1,109 @@ +/** + * POST /features/generate-title endpoint - Generate a concise title from description + * + * Uses the provider abstraction to generate a short, descriptive title + * from a feature description. Works with any configured provider (Claude, Cursor, etc.). + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { simpleQuery } from '../../../providers/simple-query-service.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getPromptCustomization } from '../../../lib/settings-helpers.js'; + +const logger = createLogger('GenerateTitle'); + +interface GenerateTitleRequestBody { + description: string; + projectPath?: string; +} + +interface GenerateTitleSuccessResponse { + success: true; + title: string; +} + +interface GenerateTitleErrorResponse { + success: false; + error: string; +} + +export function createGenerateTitleHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { description } = req.body as GenerateTitleRequestBody; + + if (!description || typeof description !== 'string') { + const response: GenerateTitleErrorResponse = { + success: false, + error: 'description is required and must be a string', + }; + res.status(400).json(response); + return; + } + + const trimmedDescription = description.trim(); + if (trimmedDescription.length === 0) { + const response: GenerateTitleErrorResponse = { + success: false, + error: 'description cannot be empty', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating title for description: ${trimmedDescription.substring(0, 50)}...`); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]'); + const systemPrompt = prompts.titleGeneration.systemPrompt; + + // Get credentials for API calls (uses hardcoded haiku model, no phase setting) + const credentials = await settingsService?.getCredentials(); + + const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`; + + // Use simpleQuery - provider abstraction handles all the streaming/extraction + const result = await simpleQuery({ + prompt: `${systemPrompt}\n\n${userPrompt}`, + model: CLAUDE_MODEL_MAP.haiku, + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); + + const title = result.text; + + if (!title || title.trim().length === 0) { + logger.warn('Received empty response from AI'); + const response: GenerateTitleErrorResponse = { + success: false, + error: 'Failed to generate title - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated title: ${title.trim()}`); + + const response: GenerateTitleSuccessResponse = { + success: true, + title: title.trim(), + }; + res.json(response); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('Title generation failed:', errorMessage); + + const response: GenerateTitleErrorResponse = { + success: false, + error: errorMessage, + }; + res.status(500).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/get.ts b/jules_branch/apps/server/src/routes/features/routes/get.ts new file mode 100644 index 0000000000000000000000000000000000000000..96f63fb8a06763755a0eb6d7531a8ff440c9617a --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/get.ts @@ -0,0 +1,37 @@ +/** + * POST /get endpoint - Get a single feature + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId are required', + }); + return; + } + + const feature = await featureLoader.get(projectPath, featureId); + if (!feature) { + res.status(404).json({ success: false, error: 'Feature not found' }); + return; + } + + res.json({ success: true, feature }); + } catch (error) { + logError(error, 'Get feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/import.ts b/jules_branch/apps/server/src/routes/features/routes/import.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa8cfce14eab7ac1dd041388527301994f5ca100 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/import.ts @@ -0,0 +1,210 @@ +/** + * POST /import endpoint - Import features from JSON or YAML format + */ + +import type { Request, Response } from 'express'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types'; +import { getFeatureExportService } from '../../../services/feature-export-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface ImportRequest { + projectPath: string; + /** Raw JSON or YAML string containing feature data */ + data: string; + /** Whether to overwrite existing features with same ID */ + overwrite?: boolean; + /** Whether to preserve branch info from imported features */ + preserveBranchInfo?: boolean; + /** Optional category to assign to all imported features */ + targetCategory?: string; +} + +interface ConflictCheckRequest { + projectPath: string; + /** Raw JSON or YAML string containing feature data */ + data: string; +} + +interface ConflictInfo { + featureId: string; + title?: string; + existingTitle?: string; + hasConflict: boolean; +} + +export function createImportHandler(_featureLoader: FeatureLoader) { + const exportService = getFeatureExportService(); + + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + data, + overwrite = false, + preserveBranchInfo = false, + targetCategory, + } = req.body as ImportRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!data) { + res.status(400).json({ success: false, error: 'data is required' }); + return; + } + + // Detect format and parse the data + const format = exportService.detectFormat(data); + if (!format) { + res.status(400).json({ + success: false, + error: 'Invalid data format. Expected valid JSON or YAML.', + }); + return; + } + + const parsed = exportService.parseImportData(data); + if (!parsed) { + res.status(400).json({ + success: false, + error: 'Failed to parse import data. Ensure it is valid JSON or YAML.', + }); + return; + } + + // Determine if this is a single feature or bulk import + const isBulkImport = + 'features' in parsed && Array.isArray((parsed as { features: unknown }).features); + + let results: FeatureImportResult[]; + + if (isBulkImport) { + // Bulk import + results = await exportService.importFeatures(projectPath, data, { + overwrite, + preserveBranchInfo, + targetCategory, + }); + } else { + // Single feature import - we know it's not a bulk export at this point + // It must be either a Feature or FeatureExport + const singleData = parsed as Feature | FeatureExport; + + const result = await exportService.importFeature(projectPath, { + data: singleData, + overwrite, + preserveBranchInfo, + targetCategory, + }); + results = [result]; + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + const allSuccessful = failureCount === 0; + + res.json({ + success: allSuccessful, + importedCount: successCount, + failedCount: failureCount, + results, + }); + } catch (error) { + logError(error, 'Import features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Create handler for checking conflicts before import + */ +export function createConflictCheckHandler(featureLoader: FeatureLoader) { + const exportService = getFeatureExportService(); + + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, data } = req.body as ConflictCheckRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!data) { + res.status(400).json({ success: false, error: 'data is required' }); + return; + } + + // Parse the import data + const format = exportService.detectFormat(data); + if (!format) { + res.status(400).json({ + success: false, + error: 'Invalid data format. Expected valid JSON or YAML.', + }); + return; + } + + const parsed = exportService.parseImportData(data); + if (!parsed) { + res.status(400).json({ + success: false, + error: 'Failed to parse import data.', + }); + return; + } + + // Extract features from the data using type guards + let featuresToCheck: Array<{ id: string; title?: string }> = []; + + if (exportService.isBulkExport(parsed)) { + // Bulk export format + featuresToCheck = parsed.features.map((f) => ({ + id: f.feature.id, + title: f.feature.title, + })); + } else if (exportService.isFeatureExport(parsed)) { + // Single FeatureExport format + featuresToCheck = [ + { + id: parsed.feature.id, + title: parsed.feature.title, + }, + ]; + } else if (exportService.isRawFeature(parsed)) { + // Raw Feature format + featuresToCheck = [{ id: parsed.id, title: parsed.title }]; + } + + // Check each feature for conflicts in parallel + const conflicts: ConflictInfo[] = await Promise.all( + featuresToCheck.map(async (feature) => { + const existing = await featureLoader.get(projectPath, feature.id); + return { + featureId: feature.id, + title: feature.title, + existingTitle: existing?.title, + hasConflict: !!existing, + }; + }) + ); + + const hasConflicts = conflicts.some((c) => c.hasConflict); + + res.json({ + success: true, + hasConflicts, + conflicts, + totalFeatures: featuresToCheck.length, + conflictCount: conflicts.filter((c) => c.hasConflict).length, + }); + } catch (error) { + logError(error, 'Conflict check failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/list.ts b/jules_branch/apps/server/src/routes/features/routes/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..46ff3b921e44ba51744097fad3abecb2264e4e7e --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/list.ts @@ -0,0 +1,73 @@ +/** + * POST/GET /list endpoint - List all features for a project + * + * projectPath may come from req.body (POST) or req.query (GET fallback). + * + * Also performs orphan detection when a project is loaded to identify + * features whose branches no longer exist. This runs on every project load/switch. + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('FeaturesListRoute'); + +export function createListHandler( + featureLoader: FeatureLoader, + autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const bodyProjectPath = + typeof req.body === 'object' && req.body !== null + ? (req.body as { projectPath?: unknown }).projectPath + : undefined; + const queryProjectPath = req.query.projectPath; + const projectPath = + typeof bodyProjectPath === 'string' + ? bodyProjectPath + : typeof queryProjectPath === 'string' + ? queryProjectPath + : undefined; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const features = await featureLoader.getAll(projectPath); + + // Run orphan detection in background when project is loaded + // This detects features whose branches no longer exist (e.g., after merge/delete) + // We don't await this to keep the list response fast + // Note: detectOrphanedFeatures handles errors internally and always resolves + if (autoModeService) { + autoModeService + .detectOrphanedFeatures(projectPath, features) + .then((orphanedFeatures) => { + if (orphanedFeatures.length > 0) { + logger.info( + `[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}` + ); + for (const { feature, missingBranch } of orphanedFeatures) { + logger.info( + `[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists` + ); + } + } + }) + .catch((error) => { + logger.warn(`[ProjectLoad] Orphan detection failed for ${projectPath}:`, error); + }); + } + + res.json({ success: true, features }); + } catch (error) { + logError(error, 'List features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/orphaned.ts b/jules_branch/apps/server/src/routes/features/routes/orphaned.ts new file mode 100644 index 0000000000000000000000000000000000000000..e44711be1be64edeb2fe329930c9dfa472834b5a --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/orphaned.ts @@ -0,0 +1,287 @@ +/** + * POST /orphaned endpoint - Detect orphaned features (features with missing branches) + * POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch) + * POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once + */ + +import crypto from 'crypto'; +import path from 'path'; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OrphanedFeatures'); + +type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch'; +const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch']; + +export function createOrphanedListHandler( + featureLoader: FeatureLoader, + autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!autoModeService) { + res.status(500).json({ success: false, error: 'Auto-mode service not available' }); + return; + } + + const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath); + + res.json({ success: true, orphanedFeatures }); + } catch (error) { + logError(error, 'Detect orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createOrphanedResolveHandler( + featureLoader: FeatureLoader, + _autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, action, targetBranch } = req.body as { + projectPath: string; + featureId: string; + action: ResolveAction; + targetBranch?: string | null; + }; + + if (!projectPath || !featureId || !action) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + + if (!result.success) { + res.status(result.error === 'Feature not found' ? 404 : 500).json(result); + return; + } + + res.json(result); + } catch (error) { + logError(error, 'Resolve orphaned feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +interface BulkResolveResult { + featureId: string; + success: boolean; + action?: string; + error?: string; +} + +async function resolveOrphanedFeature( + featureLoader: FeatureLoader, + projectPath: string, + featureId: string, + action: ResolveAction, + targetBranch?: string | null +): Promise { + try { + const feature = await featureLoader.get(projectPath, featureId); + if (!feature) { + return { featureId, success: false, error: 'Feature not found' }; + } + + const missingBranch = feature.branchName; + + switch (action) { + case 'delete': { + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + const success = await featureLoader.delete(projectPath, featureId); + if (!success) { + return { featureId, success: false, error: 'Deletion failed' }; + } + logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`); + return { featureId, success: true, action: 'deleted' }; + } + + case 'create-worktree': { + if (!missingBranch) { + return { featureId, success: false, error: 'Feature has no branch name to recreate' }; + } + + const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-'); + const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8); + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`); + + try { + await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath); + } catch (error) { + const msg = getErrorMessage(error); + if (msg.includes('already exists')) { + try { + await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath); + } catch (innerError) { + return { + featureId, + success: false, + error: `Failed to create worktree: ${getErrorMessage(innerError)}`, + }; + } + } else { + return { featureId, success: false, error: `Failed to create worktree: ${msg}` }; + } + } + + logger.info( + `Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})` + ); + return { featureId, success: true, action: 'worktree-created' }; + } + + case 'move-to-branch': { + // Move the feature to a different branch (or clear branch to use main worktree) + const newBranch = targetBranch || null; + + // Validate that the target branch exists if one is specified + if (newBranch) { + try { + await execGitCommand(['rev-parse', '--verify', newBranch], projectPath); + } catch { + return { + featureId, + success: false, + error: `Target branch "${newBranch}" does not exist`, + }; + } + } + + await featureLoader.update(projectPath, featureId, { + branchName: newBranch, + status: 'pending', + }); + + // Clean up old worktree metadata + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + + const destination = newBranch ?? 'main worktree'; + logger.info( + `Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})` + ); + return { featureId, success: true, action: 'moved' }; + } + } + } catch (error) { + return { featureId, success: false, error: getErrorMessage(error) }; + } +} + +export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, action, targetBranch } = req.body as { + projectPath: string; + featureIds: string[]; + action: ResolveAction; + targetBranch?: string | null; + }; + + if ( + !projectPath || + !featureIds || + !Array.isArray(featureIds) || + featureIds.length === 0 || + !action + ) { + res.status(400).json({ + success: false, + error: 'projectPath, featureIds (non-empty array), and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + // Process sequentially for worktree creation (git operations shouldn't race), + // in parallel for delete/move-to-branch + const results: BulkResolveResult[] = []; + + if (action === 'create-worktree') { + for (const featureId of featureIds) { + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + results.push(result); + } + } else { + const batchResults = await Promise.all( + featureIds.map((featureId) => + resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch) + ) + ); + results.push(...batchResults); + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.length - successCount; + + res.json({ + success: failedCount === 0, + resolvedCount: successCount, + failedCount, + results, + }); + } catch (error) { + logError(error, 'Bulk resolve orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/features/routes/update.ts b/jules_branch/apps/server/src/routes/features/routes/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..205838a492626098d20ff93a17db6e66243c6c96 --- /dev/null +++ b/jules_branch/apps/server/src/routes/features/routes/update.ts @@ -0,0 +1,93 @@ +/** + * POST /update endpoint - Update a feature + */ + +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { Feature, FeatureStatus } from '@automaker/types'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('features/update'); + +// Statuses that should trigger syncing to app_spec.txt +const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed']; + +export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription, + } = req.body as { + projectPath: string; + featureId: string; + updates: Partial; + descriptionHistorySource?: 'enhance' | 'edit'; + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer'; + preEnhancementDescription?: string; + }; + + if (!projectPath || !featureId || !updates) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and updates are required', + }); + return; + } + + // Get the current feature to detect status changes + const currentFeature = await featureLoader.get(projectPath, featureId); + if (!currentFeature) { + res.status(404).json({ success: false, error: `Feature ${featureId} not found` }); + return; + } + const previousStatus = currentFeature.status as FeatureStatus; + const newStatus = updates.status as FeatureStatus | undefined; + + const updated = await featureLoader.update( + projectPath, + featureId, + updates, + descriptionHistorySource, + enhancementMode, + preEnhancementDescription + ); + + // Emit completion event and sync to app_spec.txt when status transitions to verified/completed + if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) { + events?.emit('feature:completed', { + featureId, + featureName: updated.title, + projectPath, + passes: true, + message: + newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually', + executionMode: 'manual', + }); + + try { + const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated); + if (synced) { + logger.info( + `Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}` + ); + } + } catch (syncError) { + // Log the sync error but don't fail the update operation + logger.error(`Failed to sync feature to app_spec.txt:`, syncError); + } + } + + res.json({ success: true, feature: updated }); + } catch (error) { + logError(error, 'Update feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/common.ts b/jules_branch/apps/server/src/routes/fs/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..6386c83e5c5c2f9f0435772a11ba6d88db0525fa --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for fs routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('FS'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/fs/index.ts b/jules_branch/apps/server/src/routes/fs/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8805102e59b67b7d35bd204d5287fe52b82e417 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/index.ts @@ -0,0 +1,50 @@ +/** + * File system routes + * Provides REST API equivalents for Electron IPC file operations + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { createReadHandler } from './routes/read.js'; +import { createWriteHandler } from './routes/write.js'; +import { createMkdirHandler } from './routes/mkdir.js'; +import { createReaddirHandler } from './routes/readdir.js'; +import { createExistsHandler } from './routes/exists.js'; +import { createStatHandler } from './routes/stat.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createValidatePathHandler } from './routes/validate-path.js'; +import { createResolveDirectoryHandler } from './routes/resolve-directory.js'; +import { createSaveImageHandler } from './routes/save-image.js'; +import { createBrowseHandler } from './routes/browse.js'; +import { createImageHandler } from './routes/image.js'; +import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js'; +import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js'; +import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js'; +import { createCopyHandler } from './routes/copy.js'; +import { createMoveHandler } from './routes/move.js'; +import { createDownloadHandler } from './routes/download.js'; + +export function createFsRoutes(_events: EventEmitter): Router { + const router = Router(); + + router.post('/read', createReadHandler()); + router.post('/write', createWriteHandler()); + router.post('/mkdir', createMkdirHandler()); + router.post('/readdir', createReaddirHandler()); + router.post('/exists', createExistsHandler()); + router.post('/stat', createStatHandler()); + router.post('/delete', createDeleteHandler()); + router.post('/validate-path', createValidatePathHandler()); + router.post('/resolve-directory', createResolveDirectoryHandler()); + router.post('/save-image', createSaveImageHandler()); + router.post('/browse', createBrowseHandler()); + router.get('/image', createImageHandler()); + router.post('/save-board-background', createSaveBoardBackgroundHandler()); + router.post('/delete-board-background', createDeleteBoardBackgroundHandler()); + router.post('/browse-project-files', createBrowseProjectFilesHandler()); + router.post('/copy', createCopyHandler()); + router.post('/move', createMoveHandler()); + router.post('/download', createDownloadHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/browse-project-files.ts b/jules_branch/apps/server/src/routes/fs/routes/browse-project-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..50afee0d2ebc20358b40d1d7d428e9fb8519a0c7 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/browse-project-files.ts @@ -0,0 +1,191 @@ +/** + * POST /browse-project-files endpoint - Browse files and directories within a project + * + * Unlike /browse which only lists directories (for project folder selection), + * this endpoint lists both files and directories relative to a project root. + * Used by the file selector for "Copy files to worktree" settings. + * + * Features: + * - Lists both files and directories + * - Hides .git, .worktrees, node_modules, and other build artifacts + * - Returns entries relative to the project root + * - Supports navigating into subdirectories + * - Security: prevents path traversal outside project root + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +// Directories to hide from the listing (build artifacts, caches, etc.) +const HIDDEN_DIRECTORIES = new Set([ + '.git', + '.worktrees', + 'node_modules', + '.automaker', + '__pycache__', + '.cache', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.vercel', + '.output', + 'coverage', + '.nyc_output', + 'dist', + 'build', + 'out', + '.tmp', + 'tmp', + '.venv', + 'venv', + 'target', + 'vendor', + '.gradle', + '.idea', + '.vscode', +]); + +interface ProjectFileEntry { + name: string; + relativePath: string; + isDirectory: boolean; + isFile: boolean; +} + +export function createBrowseProjectFilesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, relativePath } = req.body as { + projectPath: string; + relativePath?: string; // Relative path within the project to browse (empty = project root) + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const resolvedProjectPath = path.resolve(projectPath); + + // Determine the target directory to browse + let targetPath = resolvedProjectPath; + let currentRelativePath = ''; + + if (relativePath) { + // Security: normalize and validate the relative path + const normalized = path.normalize(relativePath); + if (normalized.startsWith('..') || path.isAbsolute(normalized)) { + res.status(400).json({ + success: false, + error: 'Invalid relative path - must be within the project directory', + }); + return; + } + targetPath = path.join(resolvedProjectPath, normalized); + currentRelativePath = normalized; + + // Double-check the resolved path is within the project + // Use a separator-terminated prefix to prevent matching sibling dirs + // that share the same prefix (e.g. /projects/foo vs /projects/foobar). + const resolvedTarget = path.resolve(targetPath); + const projectPrefix = resolvedProjectPath.endsWith(path.sep) + ? resolvedProjectPath + : resolvedProjectPath + path.sep; + if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) { + res.status(400).json({ + success: false, + error: 'Path traversal detected', + }); + return; + } + } + + // Determine parent relative path + let parentRelativePath: string | null = null; + if (currentRelativePath) { + const parent = path.dirname(currentRelativePath); + parentRelativePath = parent === '.' ? '' : parent; + } + + try { + const stat = await secureFs.stat(targetPath); + + if (!stat.isDirectory()) { + res.status(400).json({ success: false, error: 'Path is not a directory' }); + return; + } + + // Read directory contents + const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true }); + + // Filter and map entries + const entries: ProjectFileEntry[] = dirEntries + .filter((entry) => { + // Skip hidden directories (build artifacts, etc.) + if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) { + return false; + } + // Skip entries starting with . (hidden files) except common config files + // We keep hidden files visible since users often need .env, .eslintrc, etc. + return true; + }) + .map((entry) => { + const entryRelativePath = currentRelativePath + ? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name) + : entry.name; + + return { + name: entry.name, + relativePath: entryRelativePath, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + }; + }) + // Sort: directories first, then files, alphabetically within each group + .sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + res.json({ + success: true, + currentRelativePath, + parentRelativePath, + entries, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to read directory'; + const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES'); + + if (isPermissionError) { + res.json({ + success: true, + currentRelativePath, + parentRelativePath, + entries: [], + warning: 'Permission denied - unable to read this directory', + }); + } else { + res.status(400).json({ + success: false, + error: errorMessage, + }); + } + } + } catch (error) { + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Browse project files failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/browse.ts b/jules_branch/apps/server/src/routes/fs/routes/browse.ts new file mode 100644 index 0000000000000000000000000000000000000000..68259291dc127af0af8eceb47ece8513f4b1423e --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/browse.ts @@ -0,0 +1,118 @@ +/** + * POST /browse endpoint - Browse directories for file browser UI + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import os from 'os'; +import path from 'path'; +import { getAllowedRootDirectory, PathNotAllowedError, isPathAllowed } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createBrowseHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { dirPath } = req.body as { dirPath?: string }; + + // Default to ALLOWED_ROOT_DIRECTORY if set, otherwise home directory + const defaultPath = getAllowedRootDirectory() || os.homedir(); + const targetPath = dirPath ? path.resolve(dirPath) : defaultPath; + + // Detect available drives on Windows + const detectDrives = async (): Promise => { + if (os.platform() !== 'win32') { + return []; + } + + const drives: string[] = []; + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + for (const letter of letters) { + const drivePath = `${letter}:\\`; + try { + await secureFs.access(drivePath); + drives.push(drivePath); + } catch { + // Drive doesn't exist, skip it + } + } + + return drives; + }; + + // Get parent directory - only if it's within the allowed root + const parentPath = path.dirname(targetPath); + + // Determine if parent navigation should be allowed: + // 1. Must have a different parent (not at filesystem root) + // 2. If ALLOWED_ROOT_DIRECTORY is set, parent must be within it + const hasParent = parentPath !== targetPath && isPathAllowed(parentPath); + + // Security: Don't expose parent path outside allowed root + const safeParentPath = hasParent ? parentPath : null; + + // Get available drives + const drives = await detectDrives(); + + try { + const stats = await secureFs.stat(targetPath); + + if (!stats.isDirectory()) { + res.status(400).json({ success: false, error: 'Path is not a directory' }); + return; + } + + // Read directory contents + const entries = await secureFs.readdir(targetPath, { withFileTypes: true }); + + // Filter for directories only and add parent directory option + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => ({ + name: entry.name, + path: path.join(targetPath, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + success: true, + currentPath: targetPath, + parentPath: safeParentPath, + directories, + drives, + }); + } catch (error) { + // Handle permission errors gracefully - still return path info so user can navigate away + const errorMessage = error instanceof Error ? error.message : 'Failed to read directory'; + const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES'); + + if (isPermissionError) { + // Return success with empty directories so user can still navigate to parent + res.json({ + success: true, + currentPath: targetPath, + parentPath: safeParentPath, + directories: [], + drives, + warning: + 'Permission denied - grant Full Disk Access to Terminal in System Preferences > Privacy & Security', + }); + } else { + res.status(400).json({ + success: false, + error: errorMessage, + }); + } + } + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Browse directories failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/copy.ts b/jules_branch/apps/server/src/routes/fs/routes/copy.ts new file mode 100644 index 0000000000000000000000000000000000000000..c52a546e6154ce50a9be5637116dad41d90b77b2 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/copy.ts @@ -0,0 +1,99 @@ +/** + * POST /copy endpoint - Copy file or directory to a new location + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { mkdirSafe } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Recursively copy a directory and its contents + */ +async function copyDirectoryRecursive(src: string, dest: string): Promise { + await mkdirSafe(dest); + const entries = await secureFs.readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + await copyDirectoryRecursive(srcPath, destPath); + } else { + await secureFs.copyFile(srcPath, destPath); + } + } +} + +export function createCopyHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { sourcePath, destinationPath, overwrite } = req.body as { + sourcePath: string; + destinationPath: string; + overwrite?: boolean; + }; + + if (!sourcePath || !destinationPath) { + res + .status(400) + .json({ success: false, error: 'sourcePath and destinationPath are required' }); + return; + } + + // Prevent copying a folder into itself or its own descendant (infinite recursion) + const resolvedSrc = path.resolve(sourcePath); + const resolvedDest = path.resolve(destinationPath); + if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) { + res.status(400).json({ + success: false, + error: 'Cannot copy a folder into itself or one of its own descendants', + }); + return; + } + + // Check if destination already exists + try { + await secureFs.stat(destinationPath); + // Destination exists + if (!overwrite) { + res.status(409).json({ + success: false, + error: 'Destination already exists', + exists: true, + }); + return; + } + // If overwrite is true, remove the existing destination first to avoid merging + await secureFs.rm(destinationPath, { recursive: true }); + } catch { + // Destination doesn't exist - good to proceed + } + + // Ensure parent directory exists + await mkdirSafe(path.dirname(path.resolve(destinationPath))); + + // Check if source is a directory + const stats = await secureFs.stat(sourcePath); + + if (stats.isDirectory()) { + await copyDirectoryRecursive(sourcePath, destinationPath); + } else { + await secureFs.copyFile(sourcePath, destinationPath); + } + + res.json({ success: true }); + } catch (error) { + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Copy file failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/delete-board-background.ts b/jules_branch/apps/server/src/routes/fs/routes/delete-board-background.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f053f2c1c1bd698ab685d59c076be1c4a71b7dd --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/delete-board-background.ts @@ -0,0 +1,45 @@ +/** + * POST /delete-board-background endpoint - Delete board background image + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getBoardDir } from '@automaker/platform'; + +export function createDeleteBoardBackgroundHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Get board directory + const boardDir = getBoardDir(projectPath); + + try { + // Try to remove all background files in the board directory + const files = await secureFs.readdir(boardDir); + for (const file of files) { + if (file.startsWith('background')) { + await secureFs.unlink(path.join(boardDir, file)); + } + } + } catch { + // Directory may not exist, that's fine + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete board background failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/delete.ts b/jules_branch/apps/server/src/routes/fs/routes/delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffb404449a5e5e6d12d2d62a66def6edf118fa12 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/delete.ts @@ -0,0 +1,34 @@ +/** + * POST /delete endpoint - Delete file + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + await secureFs.rm(filePath, { recursive: true }); + + res.json({ success: true }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Delete file failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/download.ts b/jules_branch/apps/server/src/routes/fs/routes/download.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ac44078d2dc62086c091b932b120c346e8f86cc --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/download.ts @@ -0,0 +1,142 @@ +/** + * POST /download endpoint - Download a file, or GET /download for streaming + * For folders, creates a zip archive on the fly + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; +import { createReadStream } from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { tmpdir } from 'os'; + +const execFileAsync = promisify(execFile); + +/** + * Get total size of a directory recursively + */ +async function getDirectorySize(dirPath: string): Promise { + let totalSize = 0; + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + totalSize += await getDirectorySize(entryPath); + } else { + const stats = await secureFs.stat(entryPath); + totalSize += Number(stats.size); + } + } + + return totalSize; +} + +export function createDownloadHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + const stats = await secureFs.stat(filePath); + const fileName = path.basename(filePath); + + if (stats.isDirectory()) { + // For directories, create a zip archive + const dirSize = await getDirectorySize(filePath); + const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit + + if (dirSize > MAX_DIR_SIZE) { + res.status(413).json({ + success: false, + error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`, + size: dirSize, + }); + return; + } + + // Create a temporary zip file + const zipFileName = `${fileName}.zip`; + const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`); + + try { + // Use system zip command (available on macOS and Linux) + // Use execFile to avoid shell injection via user-provided paths + await execFileAsync('zip', ['-r', tmpZipPath, fileName], { + cwd: path.dirname(filePath), + maxBuffer: 50 * 1024 * 1024, + }); + + const zipStats = await secureFs.stat(tmpZipPath); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`); + res.setHeader('Content-Length', zipStats.size.toString()); + res.setHeader('X-Directory-Size', dirSize.toString()); + + const stream = createReadStream(tmpZipPath); + stream.pipe(res); + + stream.on('end', async () => { + // Cleanup temp file + try { + await secureFs.rm(tmpZipPath); + } catch { + // Ignore cleanup errors + } + }); + + stream.on('error', async (err) => { + logError(err, 'Download stream error'); + try { + await secureFs.rm(tmpZipPath); + } catch { + // Ignore cleanup errors + } + if (!res.headersSent) { + res.status(500).json({ success: false, error: 'Stream error during download' }); + } + }); + } catch (zipError) { + // Cleanup on zip failure + try { + await secureFs.rm(tmpZipPath); + } catch { + // Ignore + } + throw zipError; + } + } else { + // For individual files, stream directly + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.setHeader('Content-Length', stats.size.toString()); + + const stream = createReadStream(filePath); + stream.pipe(res); + + stream.on('error', (err) => { + logError(err, 'Download stream error'); + if (!res.headersSent) { + res.status(500).json({ success: false, error: 'Stream error during download' }); + } + }); + } + } catch (error) { + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Download failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/exists.ts b/jules_branch/apps/server/src/routes/fs/routes/exists.ts new file mode 100644 index 0000000000000000000000000000000000000000..88050889e529e3cc1732792fdc16a83c6f98579e --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/exists.ts @@ -0,0 +1,41 @@ +/** + * POST /exists endpoint - Check if file/directory exists + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createExistsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + try { + await secureFs.access(filePath); + res.json({ success: true, exists: true }); + } catch (accessError) { + // Check if it's a path not allowed error vs file not existing + if (accessError instanceof PathNotAllowedError) { + throw accessError; + } + res.json({ success: true, exists: false }); + } + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Check exists failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/image.ts b/jules_branch/apps/server/src/routes/fs/routes/image.ts new file mode 100644 index 0000000000000000000000000000000000000000..32f3b3cbe0a2e6effd219f23435b91dfc16f4ef3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/image.ts @@ -0,0 +1,73 @@ +/** + * GET /image endpoint - Serve image files + * + * Requires authentication via auth middleware: + * - apiKey query parameter (Electron mode) + * - token query parameter (web mode) + * - session cookie (web mode) + * - X-API-Key header (Electron mode) + * - X-Session-Token header (web mode) + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createImageHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { path: imagePath, projectPath } = req.query as { + path?: string; + projectPath?: string; + }; + + if (!imagePath) { + res.status(400).json({ success: false, error: 'path is required' }); + return; + } + + // Resolve full path + const fullPath = path.isAbsolute(imagePath) + ? imagePath + : projectPath + ? path.join(projectPath, imagePath) + : imagePath; + + // Check if file exists + try { + await secureFs.access(fullPath); + } catch (accessError) { + if (accessError instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: 'Path not allowed' }); + return; + } + res.status(404).json({ success: false, error: 'Image not found' }); + return; + } + + // Read the file + const buffer = await secureFs.readFile(fullPath); + + // Determine MIME type from extension + const ext = path.extname(fullPath).toLowerCase(); + const mimeTypes: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.bmp': 'image/bmp', + }; + + res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(buffer); + } catch (error) { + logError(error, 'Serve image failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/mkdir.ts b/jules_branch/apps/server/src/routes/fs/routes/mkdir.ts new file mode 100644 index 0000000000000000000000000000000000000000..f813abcd6eb5b6fd01472ca4d19d000dbbfa23e3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/mkdir.ts @@ -0,0 +1,70 @@ +/** + * POST /mkdir endpoint - Create directory + * Handles symlinks safely to avoid ELOOP errors + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createMkdirHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { dirPath } = req.body as { dirPath: string }; + + if (!dirPath) { + res.status(400).json({ success: false, error: 'dirPath is required' }); + return; + } + + const resolvedPath = path.resolve(dirPath); + + // Check if path already exists using lstat (doesn't follow symlinks) + try { + const stats = await secureFs.lstat(resolvedPath); + // Path exists - if it's a directory or symlink, consider it success + if (stats.isDirectory() || stats.isSymbolicLink()) { + res.json({ success: true }); + return; + } + // It's a file - can't create directory + res.status(400).json({ + success: false, + error: 'Path exists and is not a directory', + }); + return; + } catch (statError: unknown) { + // ENOENT means path doesn't exist - we should create it + if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') { + // Some other error (could be ELOOP in parent path) + throw statError; + } + } + + // Path doesn't exist, create it + await secureFs.mkdir(resolvedPath, { recursive: true }); + + res.json({ success: true }); + } catch (error: unknown) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + // Handle ELOOP specifically + if ((error as NodeJS.ErrnoException).code === 'ELOOP') { + logError(error, 'Create directory failed - symlink loop detected'); + res.status(400).json({ + success: false, + error: 'Cannot create directory: symlink loop detected in path', + }); + return; + } + logError(error, 'Create directory failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/move.ts b/jules_branch/apps/server/src/routes/fs/routes/move.ts new file mode 100644 index 0000000000000000000000000000000000000000..8979db5547a0d62488a859c0c89215fc1cba0eca --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/move.ts @@ -0,0 +1,79 @@ +/** + * POST /move endpoint - Move (rename) file or directory to a new location + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { mkdirSafe } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +export function createMoveHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { sourcePath, destinationPath, overwrite } = req.body as { + sourcePath: string; + destinationPath: string; + overwrite?: boolean; + }; + + if (!sourcePath || !destinationPath) { + res + .status(400) + .json({ success: false, error: 'sourcePath and destinationPath are required' }); + return; + } + + // Prevent moving to same location or into its own descendant + const resolvedSrc = path.resolve(sourcePath); + const resolvedDest = path.resolve(destinationPath); + if (resolvedDest === resolvedSrc) { + // No-op: source and destination are the same + res.json({ success: true }); + return; + } + if (resolvedDest.startsWith(resolvedSrc + path.sep)) { + res.status(400).json({ + success: false, + error: 'Cannot move a folder into one of its own descendants', + }); + return; + } + + // Check if destination already exists + try { + await secureFs.stat(destinationPath); + // Destination exists + if (!overwrite) { + res.status(409).json({ + success: false, + error: 'Destination already exists', + exists: true, + }); + return; + } + // If overwrite is true, remove the existing destination first + await secureFs.rm(destinationPath, { recursive: true }); + } catch { + // Destination doesn't exist - good to proceed + } + + // Ensure parent directory exists + await mkdirSafe(path.dirname(path.resolve(destinationPath))); + + // Use rename for the move operation + await secureFs.rename(sourcePath, destinationPath); + + res.json({ success: true }); + } catch (error) { + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Move file failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/read.ts b/jules_branch/apps/server/src/routes/fs/routes/read.ts new file mode 100644 index 0000000000000000000000000000000000000000..6dfcb9fd77aa0556e31d9379f45f6692f34e2336 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/read.ts @@ -0,0 +1,65 @@ +/** + * POST /read endpoint - Read file + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +// Optional files that are expected to not exist in new projects +// Don't log ENOENT errors for these to reduce noise +const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json']; + +function isOptionalFile(filePath: string): boolean { + const basename = path.basename(filePath); + if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) { + return true; + } + // Context and memory files may not exist yet during create/delete or test races + if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) { + const name = path.basename(filePath); + const lower = name.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) { + return true; + } + } + return false; +} + +function isENOENT(error: unknown): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; +} + +export function createReadHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + const content = await secureFs.readFile(filePath, 'utf-8'); + + res.json({ success: true, content }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + const filePath = req.body?.filePath || ''; + const optionalMissing = isENOENT(error) && isOptionalFile(filePath); + if (!optionalMissing) { + logError(error, 'Read file failed'); + } + // Return 404 for missing optional files so clients can handle "not found" + const status = optionalMissing ? 404 : 500; + res.status(status).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/readdir.ts b/jules_branch/apps/server/src/routes/fs/routes/readdir.ts new file mode 100644 index 0000000000000000000000000000000000000000..4393277828de6fd26b551e014fa05d24054f1a33 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/readdir.ts @@ -0,0 +1,40 @@ +/** + * POST /readdir endpoint - Read directory + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createReaddirHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { dirPath } = req.body as { dirPath: string }; + + if (!dirPath) { + res.status(400).json({ success: false, error: 'dirPath is required' }); + return; + } + + const entries = await secureFs.readdir(dirPath, { withFileTypes: true }); + + const result = entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isFile: entry.isFile(), + })); + + res.json({ success: true, entries: result }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Read directory failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/resolve-directory.ts b/jules_branch/apps/server/src/routes/fs/routes/resolve-directory.ts new file mode 100644 index 0000000000000000000000000000000000000000..be5a5b0d2ddf6c64666ec737d9a0e13f14e38cb9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/resolve-directory.ts @@ -0,0 +1,121 @@ +/** + * POST /resolve-directory endpoint - Resolve directory path from directory name + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; + +export function createResolveDirectoryHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { + directoryName, + sampleFiles, + fileCount: _fileCount, + } = req.body as { + directoryName: string; + sampleFiles?: string[]; + fileCount?: number; + }; + + if (!directoryName) { + res.status(400).json({ success: false, error: 'directoryName is required' }); + return; + } + + // If directoryName looks like an absolute path, try validating it directly + if (path.isAbsolute(directoryName) || directoryName.includes(path.sep)) { + try { + const resolvedPath = path.resolve(directoryName); + const stats = await secureFs.stat(resolvedPath); + if (stats.isDirectory()) { + res.json({ + success: true, + path: resolvedPath, + }); + return; + } + } catch { + // Not a valid absolute path, continue to search + } + } + + // Search for directory in common locations + const searchPaths: string[] = [ + process.cwd(), // Current working directory + process.env.HOME || process.env.USERPROFILE || '', // User home + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Documents'), + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Desktop'), + // Common project locations + path.join(process.env.HOME || process.env.USERPROFILE || '', 'Projects'), + ].filter(Boolean); + + // Also check parent of current working directory + try { + const parentDir = path.dirname(process.cwd()); + if (!searchPaths.includes(parentDir)) { + searchPaths.push(parentDir); + } + } catch { + // Ignore + } + + // Search for directory matching the name and file structure + for (const searchPath of searchPaths) { + try { + const candidatePath = path.join(searchPath, directoryName); + const stats = await secureFs.stat(candidatePath); + + if (stats.isDirectory()) { + // Verify it matches by checking for sample files + if (sampleFiles && sampleFiles.length > 0) { + let matches = 0; + for (const sampleFile of sampleFiles.slice(0, 5)) { + // Remove directory name prefix from sample file path + const relativeFile = sampleFile.startsWith(directoryName + '/') + ? sampleFile.substring(directoryName.length + 1) + : sampleFile.split('/').slice(1).join('/') || + sampleFile.split('/').pop() || + sampleFile; + + try { + const filePath = path.join(candidatePath, relativeFile); + await secureFs.access(filePath); + matches++; + } catch { + // File doesn't exist, continue checking + } + } + + // If at least one file matches, consider it a match + if (matches === 0 && sampleFiles.length > 0) { + continue; // Try next candidate + } + } + + // Found matching directory + res.json({ + success: true, + path: candidatePath, + }); + return; + } + } catch { + // Directory doesn't exist at this location, continue searching + continue; + } + } + + // Directory not found + res.status(404).json({ + success: false, + error: `Directory "${directoryName}" not found in common locations. Please ensure the directory exists.`, + }); + } catch (error) { + logError(error, 'Resolve directory failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/save-board-background.ts b/jules_branch/apps/server/src/routes/fs/routes/save-board-background.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8b82169a6616b4201e49a55410259a33166a198 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/save-board-background.ts @@ -0,0 +1,53 @@ +/** + * POST /save-board-background endpoint - Save board background image + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getBoardDir } from '@automaker/platform'; + +export function createSaveBoardBackgroundHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { data, filename, projectPath } = req.body as { + data: string; + filename: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: 'data, filename, and projectPath are required', + }); + return; + } + + // Get board directory + const boardDir = getBoardDir(projectPath); + await secureFs.mkdir(boardDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + // Use a regex that handles all data URL formats including those with extra params + // e.g., data:image/gif;charset=utf-8;base64,R0lGOD... + const base64Data = data.replace(/^data:[^,]+,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + // Use a fixed filename for the board background (overwrite previous) + const ext = path.extname(filename) || '.png'; + const uniqueFilename = `background${ext}`; + const filePath = path.join(boardDir, uniqueFilename); + + // Write file + await secureFs.writeFile(filePath, buffer); + + // Return the absolute path + res.json({ success: true, path: filePath }); + } catch (error) { + logError(error, 'Save board background failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/save-image.ts b/jules_branch/apps/server/src/routes/fs/routes/save-image.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d48661cfcb1ee08f0ab8ae3b5a7f095d9dcc61a --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/save-image.ts @@ -0,0 +1,56 @@ +/** + * POST /save-image endpoint - Save image to .automaker images directory + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import { getImagesDir } from '@automaker/platform'; +import { sanitizeFilename } from '@automaker/utils'; + +export function createSaveImageHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { data, filename, projectPath } = req.body as { + data: string; + filename: string; + projectPath: string; + }; + + if (!data || !filename || !projectPath) { + res.status(400).json({ + success: false, + error: 'data, filename, and projectPath are required', + }); + return; + } + + // Get images directory + const imagesDir = getImagesDir(projectPath); + await secureFs.mkdir(imagesDir, { recursive: true }); + + // Decode base64 data (remove data URL prefix if present) + // Use a regex that handles all data URL formats including those with extra params + // e.g., data:image/gif;charset=utf-8;base64,R0lGOD... + const base64Data = data.replace(/^data:[^,]+,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + // Generate unique filename with timestamp + const timestamp = Date.now(); + const ext = path.extname(filename) || '.png'; + const baseName = sanitizeFilename(path.basename(filename, ext), 'image'); + const uniqueFilename = `${baseName}-${timestamp}${ext}`; + const filePath = path.join(imagesDir, uniqueFilename); + + // Write file + await secureFs.writeFile(filePath, buffer); + + // Return the absolute path + res.json({ success: true, path: filePath }); + } catch (error) { + logError(error, 'Save image failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/stat.ts b/jules_branch/apps/server/src/routes/fs/routes/stat.ts new file mode 100644 index 0000000000000000000000000000000000000000..54e0ada1554056a17350c4a0ff58da6e38733fc1 --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/stat.ts @@ -0,0 +1,52 @@ +/** + * POST /stat endpoint - Get file stats + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStatHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + const stats = await secureFs.stat(filePath); + + res.json({ + success: true, + stats: { + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + size: stats.size, + mtime: stats.mtime, + }, + }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + // File or directory does not exist - return 404 so UI can handle missing paths + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code: string }).code + : ''; + if (code === 'ENOENT') { + res.status(404).json({ success: false, error: 'File or directory not found' }); + return; + } + + logError(error, 'Get file stats failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/validate-path.ts b/jules_branch/apps/server/src/routes/fs/routes/validate-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..9405e0c1ef4b6b3f0fb2661b0c12107762624ebf --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/validate-path.ts @@ -0,0 +1,59 @@ +/** + * POST /validate-path endpoint - Validate and add path to allowed list + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createValidatePathHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath } = req.body as { filePath: string }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + const resolvedPath = path.resolve(filePath); + + // Validate path against ALLOWED_ROOT_DIRECTORY before checking if it exists + if (!isPathAllowed(resolvedPath)) { + const allowedRoot = getAllowedRootDirectory(); + const errorMessage = allowedRoot + ? `Path not allowed: ${filePath}. Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}` + : `Path not allowed: ${filePath}`; + res.status(403).json({ + success: false, + error: errorMessage, + isAllowed: false, + }); + return; + } + + // Check if path exists + try { + const stats = await secureFs.stat(resolvedPath); + + if (!stats.isDirectory()) { + res.status(400).json({ success: false, error: 'Path is not a directory' }); + return; + } + + res.json({ + success: true, + path: resolvedPath, + isAllowed: true, + }); + } catch { + res.status(400).json({ success: false, error: 'Path does not exist' }); + } + } catch (error) { + logError(error, 'Validate path failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/fs/routes/write.ts b/jules_branch/apps/server/src/routes/fs/routes/write.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5cdce561e94a9c4eec056cfc2c8bc83586483ac --- /dev/null +++ b/jules_branch/apps/server/src/routes/fs/routes/write.ts @@ -0,0 +1,43 @@ +/** + * POST /write endpoint - Write file + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { PathNotAllowedError } from '@automaker/platform'; +import { mkdirSafe } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +export function createWriteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { filePath, content } = req.body as { + filePath: string; + content: string; + }; + + if (!filePath) { + res.status(400).json({ success: false, error: 'filePath is required' }); + return; + } + + // Ensure parent directory exists (symlink-safe) + await mkdirSafe(path.dirname(path.resolve(filePath))); + // Default content to empty string if undefined/null to prevent writing + // "undefined" as literal text (e.g. when content field is missing from request) + await secureFs.writeFile(filePath, content ?? '', 'utf-8'); + + res.json({ success: true }); + } catch (error) { + // Path not allowed - return 403 Forbidden + if (error instanceof PathNotAllowedError) { + res.status(403).json({ success: false, error: getErrorMessage(error) }); + return; + } + + logError(error, 'Write file failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/gemini/index.ts b/jules_branch/apps/server/src/routes/gemini/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f49ef634c1142c2d1303d4fb919cf08eee2e6bd7 --- /dev/null +++ b/jules_branch/apps/server/src/routes/gemini/index.ts @@ -0,0 +1,66 @@ +import { Router, Request, Response } from 'express'; +import { GeminiProvider } from '../../providers/gemini-provider.js'; +import { GeminiUsageService } from '../../services/gemini-usage-service.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../lib/events.js'; + +const logger = createLogger('Gemini'); + +export function createGeminiRoutes( + usageService: GeminiUsageService, + _events: EventEmitter +): Router { + const router = Router(); + + // Get current usage/quota data from Google Cloud API + router.get('/usage', async (_req: Request, res: Response) => { + try { + const usageData = await usageService.fetchUsageData(); + + res.json(usageData); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error fetching Gemini usage:', error); + + // Return error in a format the UI expects + res.status(200).json({ + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch Gemini usage: ${message}`, + }); + } + }); + + // Check if Gemini is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + + // Derive authMethod from typed InstallationStatus fields + const authMethod = status.authenticated + ? status.hasApiKey + ? 'api_key' + : 'cli_login' + : 'none'; + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + authenticated: status.authenticated || false, + authMethod, + hasCredentialsFile: false, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/git/common.ts b/jules_branch/apps/server/src/routes/git/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fd7013e2fc4c9f61186ad9d1809e57d58a47dfe --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for git routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Git'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/git/index.ts b/jules_branch/apps/server/src/routes/git/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..600eb87b71aad50b67b0aac0ad01bb2c0ec467db --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/index.ts @@ -0,0 +1,27 @@ +/** + * Git routes - HTTP API for git operations (non-worktree) + */ + +import { Router } from 'express'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createDiffsHandler } from './routes/diffs.js'; +import { createFileDiffHandler } from './routes/file-diff.js'; +import { createStageFilesHandler } from './routes/stage-files.js'; +import { createDetailsHandler } from './routes/details.js'; +import { createEnhancedStatusHandler } from './routes/enhanced-status.js'; + +export function createGitRoutes(): Router { + const router = Router(); + + router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler()); + router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler()); + router.post( + '/stage-files', + validatePathParams('projectPath', 'files[]'), + createStageFilesHandler() + ); + router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler()); + router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/git/routes/details.ts b/jules_branch/apps/server/src/routes/git/routes/details.ts new file mode 100644 index 0000000000000000000000000000000000000000..0861b89eee62841646a875c74e9766d21da1cf60 --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/routes/details.ts @@ -0,0 +1,248 @@ +/** + * POST /details endpoint - Get detailed git info for a file or project + * Returns branch, last commit info, diff stats, and conflict status + */ + +import type { Request, Response } from 'express'; +import { exec, execFile } from 'child_process'; +import { promisify } from 'util'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +interface GitFileDetails { + branch: string; + lastCommitHash: string; + lastCommitMessage: string; + lastCommitAuthor: string; + lastCommitTimestamp: string; + linesAdded: number; + linesRemoved: number; + isConflicted: boolean; + isStaged: boolean; + isUnstaged: boolean; + statusLabel: string; +} + +export function createDetailsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, filePath } = req.body as { + projectPath: string; + filePath?: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + try { + // Get current branch + const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + }); + const branch = branchRaw.trim(); + + if (!filePath) { + // Project-level details - just return branch info + res.json({ + success: true, + details: { branch }, + }); + return; + } + + // Get last commit info for this file + let lastCommitHash = ''; + let lastCommitMessage = ''; + let lastCommitAuthor = ''; + let lastCommitTimestamp = ''; + + try { + const { stdout: logOutput } = await execFileAsync( + 'git', + ['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath], + { cwd: projectPath } + ); + + if (logOutput.trim()) { + const parts = logOutput.trim().split('|'); + lastCommitHash = parts[0] || ''; + lastCommitMessage = parts[1] || ''; + lastCommitAuthor = parts[2] || ''; + lastCommitTimestamp = parts[3] || ''; + } + } catch { + // File may not have any commits yet + } + + // Get diff stats (lines added/removed) + let linesAdded = 0; + let linesRemoved = 0; + + try { + // Check if file is untracked first + const { stdout: statusLine } = await execFileAsync( + 'git', + ['status', '--porcelain', '--', filePath], + { cwd: projectPath } + ); + + if (statusLine.trim().startsWith('??')) { + // Untracked file - count all lines as added using Node.js instead of shell + try { + const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString(); + const lines = fileContent.split('\n'); + // Don't count trailing empty line from final newline + linesAdded = + lines.length > 0 && lines[lines.length - 1] === '' + ? lines.length - 1 + : lines.length; + } catch { + // Ignore + } + } else { + const { stdout: diffStatRaw } = await execFileAsync( + 'git', + ['diff', '--numstat', 'HEAD', '--', filePath], + { cwd: projectPath } + ); + + if (diffStatRaw.trim()) { + const parts = diffStatRaw.trim().split('\t'); + linesAdded = parseInt(parts[0], 10) || 0; + linesRemoved = parseInt(parts[1], 10) || 0; + } + + // Also check staged diff stats + const { stdout: stagedDiffStatRaw } = await execFileAsync( + 'git', + ['diff', '--numstat', '--cached', '--', filePath], + { cwd: projectPath } + ); + + if (stagedDiffStatRaw.trim()) { + const parts = stagedDiffStatRaw.trim().split('\t'); + linesAdded += parseInt(parts[0], 10) || 0; + linesRemoved += parseInt(parts[1], 10) || 0; + } + } + } catch { + // Diff might not be available + } + + // Get conflict and staging status + let isConflicted = false; + let isStaged = false; + let isUnstaged = false; + let statusLabel = ''; + + try { + const { stdout: statusOutput } = await execFileAsync( + 'git', + ['status', '--porcelain', '--', filePath], + { cwd: projectPath } + ); + + if (statusOutput.trim()) { + const indexStatus = statusOutput[0]; + const workTreeStatus = statusOutput[1]; + + // Check for conflicts (both modified, unmerged states) + if ( + indexStatus === 'U' || + workTreeStatus === 'U' || + (indexStatus === 'A' && workTreeStatus === 'A') || + (indexStatus === 'D' && workTreeStatus === 'D') + ) { + isConflicted = true; + statusLabel = 'Conflicted'; + } else { + // Staged changes (index has a status) + if (indexStatus !== ' ' && indexStatus !== '?') { + isStaged = true; + } + // Unstaged changes (work tree has a status) + if (workTreeStatus !== ' ' && workTreeStatus !== '?') { + isUnstaged = true; + } + + // Build status label + if (isStaged && isUnstaged) { + statusLabel = 'Staged + Modified'; + } else if (isStaged) { + statusLabel = 'Staged'; + } else { + const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus; + switch (statusChar) { + case 'M': + statusLabel = 'Modified'; + break; + case 'A': + statusLabel = 'Added'; + break; + case 'D': + statusLabel = 'Deleted'; + break; + case 'R': + statusLabel = 'Renamed'; + break; + case 'C': + statusLabel = 'Copied'; + break; + case '?': + statusLabel = 'Untracked'; + break; + default: + statusLabel = statusChar || ''; + } + } + } + } + } catch { + // Status might not be available + } + + const details: GitFileDetails = { + branch, + lastCommitHash, + lastCommitMessage, + lastCommitAuthor, + lastCommitTimestamp, + linesAdded, + linesRemoved, + isConflicted, + isStaged, + isUnstaged, + statusLabel, + }; + + res.json({ success: true, details }); + } catch (innerError) { + logError(innerError, 'Git details failed'); + res.json({ + success: true, + details: { + branch: '', + lastCommitHash: '', + lastCommitMessage: '', + lastCommitAuthor: '', + lastCommitTimestamp: '', + linesAdded: 0, + linesRemoved: 0, + isConflicted: false, + isStaged: false, + isUnstaged: false, + statusLabel: '', + }, + }); + } + } catch (error) { + logError(error, 'Get git details failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/git/routes/diffs.ts b/jules_branch/apps/server/src/routes/git/routes/diffs.ts new file mode 100644 index 0000000000000000000000000000000000000000..02ce2028c161d4acf72a7055cac1a320d518b089 --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/routes/diffs.ts @@ -0,0 +1,37 @@ +/** + * POST /diffs endpoint - Get diffs for the main project + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { getGitRepositoryDiffs } from '../../common.js'; + +export function createDiffsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + try { + const result = await getGitRepositoryDiffs(projectPath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), + }); + } catch (innerError) { + logError(innerError, 'Git diff failed'); + res.json({ success: true, diff: '', files: [], hasChanges: false }); + } + } catch (error) { + logError(error, 'Get diffs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/git/routes/enhanced-status.ts b/jules_branch/apps/server/src/routes/git/routes/enhanced-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d7d2e3d35e4d9c719592ea3179ec81a051e3888 --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/routes/enhanced-status.ts @@ -0,0 +1,176 @@ +/** + * POST /enhanced-status endpoint - Get enhanced git status with diff stats per file + * Returns per-file status with lines added/removed and staged/unstaged differentiation + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +interface EnhancedFileStatus { + path: string; + indexStatus: string; + workTreeStatus: string; + isConflicted: boolean; + isStaged: boolean; + isUnstaged: boolean; + linesAdded: number; + linesRemoved: number; + statusLabel: string; +} + +function getStatusLabel(indexStatus: string, workTreeStatus: string): string { + // Check for conflicts + if ( + indexStatus === 'U' || + workTreeStatus === 'U' || + (indexStatus === 'A' && workTreeStatus === 'A') || + (indexStatus === 'D' && workTreeStatus === 'D') + ) { + return 'Conflicted'; + } + + const hasStaged = indexStatus !== ' ' && indexStatus !== '?'; + const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?'; + + if (hasStaged && hasUnstaged) return 'Staged + Modified'; + if (hasStaged) return 'Staged'; + + const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus; + switch (statusChar) { + case 'M': + return 'Modified'; + case 'A': + return 'Added'; + case 'D': + return 'Deleted'; + case 'R': + return 'Renamed'; + case 'C': + return 'Copied'; + case '?': + return 'Untracked'; + default: + return statusChar || ''; + } +} + +export function createEnhancedStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + try { + // Get current branch + const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: projectPath, + }); + const branch = branchRaw.trim(); + + // Get porcelain status for all files + const { stdout: statusOutput } = await execAsync('git status --porcelain', { + cwd: projectPath, + }); + + // Get diff numstat for working tree changes + let workTreeStats: Record = {}; + try { + const { stdout: numstatRaw } = await execAsync('git diff --numstat', { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); + for (const line of numstatRaw.trim().split('\n').filter(Boolean)) { + const parts = line.split('\t'); + if (parts.length >= 3) { + const added = parseInt(parts[0], 10) || 0; + const removed = parseInt(parts[1], 10) || 0; + workTreeStats[parts[2]] = { added, removed }; + } + } + } catch { + // Ignore + } + + // Get diff numstat for staged changes + let stagedStats: Record = {}; + try { + const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); + for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) { + const parts = line.split('\t'); + if (parts.length >= 3) { + const added = parseInt(parts[0], 10) || 0; + const removed = parseInt(parts[1], 10) || 0; + stagedStats[parts[2]] = { added, removed }; + } + } + } catch { + // Ignore + } + + // Parse status and build enhanced file list + const files: EnhancedFileStatus[] = []; + + for (const line of statusOutput.split('\n').filter(Boolean)) { + if (line.length < 4) continue; + + const indexStatus = line[0]; + const workTreeStatus = line[1]; + const filePath = line.substring(3).trim(); + + // Handle renamed files (format: "R old -> new") + const actualPath = filePath.includes(' -> ') + ? filePath.split(' -> ')[1].trim() + : filePath; + + const isConflicted = + indexStatus === 'U' || + workTreeStatus === 'U' || + (indexStatus === 'A' && workTreeStatus === 'A') || + (indexStatus === 'D' && workTreeStatus === 'D'); + + const isStaged = indexStatus !== ' ' && indexStatus !== '?'; + const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?'; + + // Combine diff stats from both working tree and staged + const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 }; + const stStats = stagedStats[actualPath] || { added: 0, removed: 0 }; + + files.push({ + path: actualPath, + indexStatus, + workTreeStatus, + isConflicted, + isStaged, + isUnstaged, + linesAdded: wtStats.added + stStats.added, + linesRemoved: wtStats.removed + stStats.removed, + statusLabel: getStatusLabel(indexStatus, workTreeStatus), + }); + } + + res.json({ + success: true, + branch, + files, + }); + } catch (innerError) { + logError(innerError, 'Git enhanced status failed'); + res.json({ success: true, branch: '', files: [] }); + } + } catch (error) { + logError(error, 'Get enhanced status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/git/routes/file-diff.ts b/jules_branch/apps/server/src/routes/git/routes/file-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..6203ecc47b47549d79a982bea0e8551355886650 --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/routes/file-diff.ts @@ -0,0 +1,57 @@ +/** + * POST /file-diff endpoint - Get diff for a specific file + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; +import { generateSyntheticDiffForNewFile } from '../../common.js'; + +const execAsync = promisify(exec); + +export function createFileDiffHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, filePath } = req.body as { + projectPath: string; + filePath: string; + }; + + if (!projectPath || !filePath) { + res.status(400).json({ success: false, error: 'projectPath and filePath required' }); + return; + } + + try { + // First check if the file is untracked + const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, { + cwd: projectPath, + }); + + const isUntracked = status.trim().startsWith('??'); + + let diff: string; + if (isUntracked) { + // Generate synthetic diff for untracked file + diff = await generateSyntheticDiffForNewFile(projectPath, filePath); + } else { + // Use regular git diff for tracked files + const result = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: projectPath, + maxBuffer: 10 * 1024 * 1024, + }); + diff = result.stdout; + } + + res.json({ success: true, diff, filePath }); + } catch (innerError) { + logError(innerError, 'Git file diff failed'); + res.json({ success: true, diff: '', filePath }); + } + } catch (error) { + logError(error, 'Get file diff failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/git/routes/stage-files.ts b/jules_branch/apps/server/src/routes/git/routes/stage-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..98ca44c1a5246408a782112da89fae410deeb8d2 --- /dev/null +++ b/jules_branch/apps/server/src/routes/git/routes/stage-files.ts @@ -0,0 +1,67 @@ +/** + * POST /stage-files endpoint - Stage or unstage files in the main project + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js'; + +export function createStageFilesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, files, operation } = req.body as { + projectPath: string; + files: string[]; + operation: 'stage' | 'unstage'; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath required', + }); + return; + } + + if (!Array.isArray(files) || files.length === 0) { + res.status(400).json({ + success: false, + error: 'files array required and must not be empty', + }); + return; + } + + for (const file of files) { + if (typeof file !== 'string' || file.trim() === '') { + res.status(400).json({ + success: false, + error: 'Each element of files must be a non-empty string', + }); + return; + } + } + + if (operation !== 'stage' && operation !== 'unstage') { + res.status(400).json({ + success: false, + error: 'operation must be "stage" or "unstage"', + }); + return; + } + + const result = await stageFiles(projectPath, files, operation); + + res.json({ + success: true, + result, + }); + } catch (error) { + if (error instanceof StageFilesValidationError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/index.ts b/jules_branch/apps/server/src/routes/github/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f315c90a2b029d7737d6a8e7003ae361dac7fb5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/index.ts @@ -0,0 +1,70 @@ +/** + * GitHub routes - HTTP API for GitHub integration + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; +import { createListIssuesHandler } from './routes/list-issues.js'; +import { createListPRsHandler } from './routes/list-prs.js'; +import { createListCommentsHandler } from './routes/list-comments.js'; +import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js'; +import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js'; +import { createValidateIssueHandler } from './routes/validate-issue.js'; +import { + createValidationStatusHandler, + createValidationStopHandler, + createGetValidationsHandler, + createDeleteValidationHandler, + createMarkViewedHandler, +} from './routes/validation-endpoints.js'; +import type { SettingsService } from '../../services/settings-service.js'; + +export function createGitHubRoutes( + events: EventEmitter, + settingsService?: SettingsService +): Router { + const router = Router(); + + router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); + router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); + router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); + router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler()); + router.post( + '/pr-review-comments', + validatePathParams('projectPath'), + createListPRReviewCommentsHandler() + ); + router.post( + '/resolve-pr-comment', + validatePathParams('projectPath'), + createResolvePRCommentHandler() + ); + router.post( + '/validate-issue', + validatePathParams('projectPath'), + createValidateIssueHandler(events, settingsService) + ); + + // Validation management endpoints + router.post( + '/validation-status', + validatePathParams('projectPath'), + createValidationStatusHandler() + ); + router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler()); + router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler()); + router.post( + '/validation-delete', + validatePathParams('projectPath'), + createDeleteValidationHandler() + ); + router.post( + '/validation-mark-viewed', + validatePathParams('projectPath'), + createMarkViewedHandler(events) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/check-github-remote.ts b/jules_branch/apps/server/src/routes/github/routes/check-github-remote.ts new file mode 100644 index 0000000000000000000000000000000000000000..5efdb172e684484fe6434c242fa3919801263015 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/check-github-remote.ts @@ -0,0 +1,127 @@ +/** + * GET /check-github-remote endpoint - Check if project has a GitHub remote + */ + +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; + +const GIT_REMOTE_ORIGIN_COMMAND = 'git remote get-url origin'; +const GH_REPO_VIEW_COMMAND = 'gh repo view --json name,owner'; +const GITHUB_REPO_URL_PREFIX = 'https://github.com/'; +const GITHUB_HTTPS_REMOTE_REGEX = /https:\/\/github\.com\/([^/]+)\/([^/.]+)/; +const GITHUB_SSH_REMOTE_REGEX = /git@github\.com:([^/]+)\/([^/.]+)/; + +interface GhRepoViewResponse { + name?: string; + owner?: { + login?: string; + }; +} + +async function resolveRepoFromGh(projectPath: string): Promise<{ + owner: string; + repo: string; +} | null> { + try { + const { stdout } = await execAsync(GH_REPO_VIEW_COMMAND, { + cwd: projectPath, + env: execEnv, + }); + + const data = JSON.parse(stdout) as GhRepoViewResponse; + const owner = typeof data.owner?.login === 'string' ? data.owner.login : null; + const repo = typeof data.name === 'string' ? data.name : null; + + if (!owner || !repo) { + return null; + } + + return { owner, repo }; + } catch { + return null; + } +} + +export interface GitHubRemoteStatus { + hasGitHubRemote: boolean; + remoteUrl: string | null; + owner: string | null; + repo: string | null; +} + +export async function checkGitHubRemote(projectPath: string): Promise { + const status: GitHubRemoteStatus = { + hasGitHubRemote: false, + remoteUrl: null, + owner: null, + repo: null, + }; + + try { + let remoteUrl = ''; + try { + // Get the remote URL (origin by default) + const { stdout } = await execAsync(GIT_REMOTE_ORIGIN_COMMAND, { + cwd: projectPath, + env: execEnv, + }); + remoteUrl = stdout.trim(); + status.remoteUrl = remoteUrl || null; + } catch { + // Ignore missing origin remote + } + + const ghRepo = await resolveRepoFromGh(projectPath); + if (ghRepo) { + status.hasGitHubRemote = true; + status.owner = ghRepo.owner; + status.repo = ghRepo.repo; + if (!status.remoteUrl) { + status.remoteUrl = `${GITHUB_REPO_URL_PREFIX}${ghRepo.owner}/${ghRepo.repo}`; + } + return status; + } + + // Check if it's a GitHub URL + // Formats: https://github.com/owner/repo.git, git@github.com:owner/repo.git + if (!remoteUrl) { + return status; + } + + const httpsMatch = remoteUrl.match(GITHUB_HTTPS_REMOTE_REGEX); + const sshMatch = remoteUrl.match(GITHUB_SSH_REMOTE_REGEX); + + const match = httpsMatch || sshMatch; + if (match) { + status.hasGitHubRemote = true; + status.owner = match[1]; + status.repo = match[2].replace(/\.git$/, ''); + } + } catch { + // No remote or not a git repo - that's okay + } + + return status; +} + +export function createCheckGitHubRemoteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const status = await checkGitHubRemote(projectPath); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, 'Check GitHub remote failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/common.ts b/jules_branch/apps/server/src/routes/github/routes/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..52ce2e3d05d20bdc6d7611987ee9d49fc0c2023e --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/common.ts @@ -0,0 +1,14 @@ +/** + * Common utilities for GitHub routes + * + * Re-exports shared utilities from lib/exec-utils so route consumers + * can continue importing from this module unchanged. + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +export const execAsync = promisify(exec); + +// Re-export shared utilities from the canonical location +export { extendedPath, execEnv, getErrorMessage, logError } from '../../../lib/exec-utils.js'; diff --git a/jules_branch/apps/server/src/routes/github/routes/list-comments.ts b/jules_branch/apps/server/src/routes/github/routes/list-comments.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c2216967b0a7ac6f57c4684252ae559eaa018b4 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/list-comments.ts @@ -0,0 +1,247 @@ +/** + * POST /issue-comments endpoint - Fetch comments for a GitHub issue + */ + +import { spawn } from 'child_process'; +import type { Request, Response } from 'express'; +import type { GitHubComment, IssueCommentsResult } from '@automaker/types'; +import { execEnv, getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; + +interface ListCommentsRequest { + projectPath: string; + issueNumber: number; + cursor?: string; +} + +interface GraphQLComment { + id: string; + author: { + login: string; + avatarUrl?: string; + } | null; + body: string; + createdAt: string; + updatedAt: string; +} + +interface GraphQLCommentConnection { + totalCount: number; + pageInfo: { + hasNextPage: boolean; + endCursor: string | null; + }; + nodes: GraphQLComment[]; +} + +interface GraphQLIssueOrPullRequest { + __typename: 'Issue' | 'PullRequest'; + comments: GraphQLCommentConnection; +} + +interface GraphQLResponse { + data?: { + repository?: { + issueOrPullRequest?: GraphQLIssueOrPullRequest | null; + }; + }; + errors?: Array<{ message: string }>; +} + +/** Timeout for GitHub API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; +const COMMENTS_PAGE_SIZE = 50; + +/** + * Validate cursor format (GraphQL cursors are typically base64 strings) + */ +function isValidCursor(cursor: string): boolean { + return /^[A-Za-z0-9+/=]+$/.test(cursor); +} + +/** + * Fetch comments for a specific issue or pull request using GitHub GraphQL API + */ +async function fetchIssueComments( + projectPath: string, + owner: string, + repo: string, + issueNumber: number, + cursor?: string +): Promise { + // Validate cursor format to prevent potential injection + if (cursor && !isValidCursor(cursor)) { + throw new Error('Invalid cursor format'); + } + + // Use GraphQL variables instead of string interpolation for safety + const query = ` + query GetIssueComments( + $owner: String! + $repo: String! + $issueNumber: Int! + $cursor: String + $pageSize: Int! + ) { + repository(owner: $owner, name: $repo) { + issueOrPullRequest(number: $issueNumber) { + __typename + ... on Issue { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt + } + } + } + ... on PullRequest { + comments(first: $pageSize, after: $cursor) { + totalCount + pageInfo { + hasNextPage + endCursor + } + nodes { + id + author { + login + avatarUrl + } + body + createdAt + updatedAt + } + } + } + } + } + }`; + + const variables = { + owner, + repo, + issueNumber, + cursor: cursor || null, + pageSize: COMMENTS_PAGE_SIZE, + }; + + const requestBody = JSON.stringify({ query, variables }); + + const response = await new Promise((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + // Add timeout to prevent hanging indefinitely + const timeoutId = setTimeout(() => { + gh.kill(); + reject(new Error('GitHub API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + const commentsData = response.data?.repository?.issueOrPullRequest?.comments; + + if (!commentsData) { + throw new Error('Issue or pull request not found or no comments data available'); + } + + const comments: GitHubComment[] = commentsData.nodes.map((node) => ({ + id: node.id, + author: { + login: node.author?.login || 'ghost', + avatarUrl: node.author?.avatarUrl, + }, + body: node.body, + createdAt: node.createdAt, + updatedAt: node.updatedAt, + })); + + return { + comments, + totalCount: commentsData.totalCount, + hasNextPage: commentsData.pageInfo.hasNextPage, + endCursor: commentsData.pageInfo.endCursor || undefined, + }; +} + +export function createListCommentsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + // First check if this is a GitHub repo and get owner/repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const result = await fetchIssueComments( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + issueNumber, + cursor + ); + + res.json({ + success: true, + ...result, + }); + } catch (error) { + logError(error, `Fetch comments for issue failed`); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/list-issues.ts b/jules_branch/apps/server/src/routes/github/routes/list-issues.ts new file mode 100644 index 0000000000000000000000000000000000000000..96c3c202ea853b553f8e2aab5ad5d5dfa0b97593 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/list-issues.ts @@ -0,0 +1,331 @@ +/** + * POST /list-issues endpoint - List GitHub issues for a project + */ + +import { spawn } from 'child_process'; +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('ListIssues'); +const OPEN_ISSUES_LIMIT = 100; +const CLOSED_ISSUES_LIMIT = 50; +const ISSUE_LIST_FIELDS = 'number,title,state,author,createdAt,labels,url,body,assignees'; +const ISSUE_STATE_OPEN = 'open'; +const ISSUE_STATE_CLOSED = 'closed'; +const GH_ISSUE_LIST_COMMAND = 'gh issue list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; +const LINKED_PRS_BATCH_SIZE = 20; +const LINKED_PRS_TIMELINE_ITEMS = 10; + +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; + avatarUrl?: string; +} + +export interface GitHubAssignee { + login: string; + avatarUrl?: string; +} + +export interface LinkedPullRequest { + number: number; + title: string; + state: string; + url: string; +} + +export interface GitHubIssue { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + body: string; + assignees: GitHubAssignee[]; + linkedPRs?: LinkedPullRequest[]; +} + +export interface ListIssuesResult { + success: boolean; + openIssues?: GitHubIssue[]; + closedIssues?: GitHubIssue[]; + error?: string; +} + +/** + * Fetch linked PRs for a list of issues using GitHub GraphQL API + */ +async function fetchLinkedPRs( + projectPath: string, + owner: string, + repo: string, + issueNumbers: number[] +): Promise> { + const linkedPRsMap = new Map(); + + if (issueNumbers.length === 0) { + return linkedPRsMap; + } + + // Build GraphQL query for batch fetching linked PRs + // We fetch up to 20 issues at a time to avoid query limits + for (let i = 0; i < issueNumbers.length; i += LINKED_PRS_BATCH_SIZE) { + const batch = issueNumbers.slice(i, i + LINKED_PRS_BATCH_SIZE); + + const issueQueries = batch + .map( + (num, idx) => ` + issue${idx}: issueOrPullRequest(number: ${num}) { + ... on Issue { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } + } + } + } + } + } + ... on PullRequest { + number + timelineItems( + first: ${LINKED_PRS_TIMELINE_ITEMS} + itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT] + ) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } + } + } + } + } + } + }` + ) + .join('\n'); + + const query = `{ + repository(owner: "${owner}", name: "${repo}") { + ${issueQueries} + } + }`; + + try { + // Use spawn with stdin to avoid shell injection vulnerabilities + // --input - reads the JSON request body from stdin + const requestBody = JSON.stringify({ query }); + const response = await new Promise>((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + const repoData = (response?.data as Record)?.repository as Record< + string, + unknown + > | null; + + if (repoData) { + batch.forEach((issueNum, idx) => { + const issueData = repoData[`issue${idx}`] as { + timelineItems?: { + nodes?: Array<{ + source?: { number?: number; title?: string; state?: string; url?: string }; + subject?: { number?: number; title?: string; state?: string; url?: string }; + }>; + }; + } | null; + if (issueData?.timelineItems?.nodes) { + const linkedPRs: LinkedPullRequest[] = []; + const seenPRs = new Set(); + + for (const node of issueData.timelineItems.nodes) { + const pr = node?.source || node?.subject; + if (pr?.number && !seenPRs.has(pr.number)) { + seenPRs.add(pr.number); + linkedPRs.push({ + number: pr.number, + title: pr.title || '', + state: (pr.state || '').toLowerCase(), + url: pr.url || '', + }); + } + } + + if (linkedPRs.length > 0) { + linkedPRsMap.set(issueNum, linkedPRs); + } + } + }); + } + } catch (error) { + // If GraphQL fails, continue without linked PRs + logger.warn( + 'Failed to fetch linked PRs via GraphQL:', + error instanceof Error ? error.message : error + ); + } + } + + return linkedPRsMap; +} + +export function createListIssuesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // First check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + // Fetch open and closed issues in parallel (now including assignees) + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; + const [openResult, closedResult] = await Promise.all([ + execAsync( + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_OPEN}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), + { + cwd: projectPath, + env: execEnv, + } + ), + execAsync( + [ + GH_ISSUE_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${ISSUE_STATE_CLOSED}`, + `${GH_JSON_FLAG} ${ISSUE_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${CLOSED_ISSUES_LIMIT}`, + ] + .filter(Boolean) + .join(' '), + { + cwd: projectPath, + env: execEnv, + } + ), + ]); + + const { stdout: openStdout } = openResult; + const { stdout: closedStdout } = closedResult; + + const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); + const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); + + // Fetch linked PRs for open issues (more relevant for active work) + if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) { + const linkedPRsMap = await fetchLinkedPRs( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + openIssues.map((i) => i.number) + ); + + // Attach linked PRs to issues + for (const issue of openIssues) { + const linkedPRs = linkedPRsMap.get(issue.number); + if (linkedPRs) { + issue.linkedPRs = linkedPRs; + } + } + } + + res.json({ + success: true, + openIssues, + closedIssues, + }); + } catch (error) { + logError(error, 'List GitHub issues failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/list-pr-review-comments.ts b/jules_branch/apps/server/src/routes/github/routes/list-pr-review-comments.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff16f6e99f81cb47c4675f53e32cc7c403563d2a --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/list-pr-review-comments.ts @@ -0,0 +1,72 @@ +/** + * POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR + * + * Fetches both regular PR comments and inline code review comments + * for a specific pull request, providing file path and line context. + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; +import { + fetchPRReviewComments, + fetchReviewThreadResolvedStatus, + type PRReviewComment, + type ListPRReviewCommentsResult, +} from '../../../services/pr-review-comments.service.js'; + +// Re-export types so existing callers continue to work +export type { PRReviewComment, ListPRReviewCommentsResult }; +// Re-export service functions so existing callers continue to work +export { fetchPRReviewComments, fetchReviewThreadResolvedStatus }; + +interface ListPRReviewCommentsRequest { + projectPath: string; + prNumber: number; +} + +export function createListPRReviewCommentsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!prNumber || typeof prNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'prNumber is required and must be a number' }); + return; + } + + // Check if this is a GitHub repo and get owner/repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const comments = await fetchPRReviewComments( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + prNumber + ); + + res.json({ + success: true, + comments, + totalCount: comments.length, + }); + } catch (error) { + logError(error, 'Fetch PR review comments failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/list-prs.ts b/jules_branch/apps/server/src/routes/github/routes/list-prs.ts new file mode 100644 index 0000000000000000000000000000000000000000..b273fc0abef74b547f3abb9bd1b5dbb6a628f6e8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/list-prs.ts @@ -0,0 +1,123 @@ +/** + * POST /list-prs endpoint - List GitHub pull requests for a project + */ + +import type { Request, Response } from 'express'; +import { execAsync, execEnv, getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; + +const OPEN_PRS_LIMIT = 100; +const MERGED_PRS_LIMIT = 50; +const PR_LIST_FIELDS = + 'number,title,state,author,createdAt,labels,url,isDraft,headRefName,reviewDecision,mergeable,body'; +const PR_STATE_OPEN = 'open'; +const PR_STATE_MERGED = 'merged'; +const GH_PR_LIST_COMMAND = 'gh pr list'; +const GH_STATE_FLAG = '--state'; +const GH_JSON_FLAG = '--json'; +const GH_LIMIT_FLAG = '--limit'; + +export interface GitHubLabel { + name: string; + color: string; +} + +export interface GitHubAuthor { + login: string; +} + +export interface GitHubPR { + number: number; + title: string; + state: string; + author: GitHubAuthor; + createdAt: string; + labels: GitHubLabel[]; + url: string; + isDraft: boolean; + headRefName: string; + reviewDecision: string | null; + mergeable: string; + body: string; +} + +export interface ListPRsResult { + success: boolean; + openPRs?: GitHubPR[]; + mergedPRs?: GitHubPR[]; + error?: string; +} + +export function createListPRsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // First check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const repoQualifier = + remoteStatus.owner && remoteStatus.repo ? `${remoteStatus.owner}/${remoteStatus.repo}` : ''; + const repoFlag = repoQualifier ? `-R ${repoQualifier}` : ''; + + const [openResult, mergedResult] = await Promise.all([ + execAsync( + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_OPEN}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${OPEN_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), + { + cwd: projectPath, + env: execEnv, + } + ), + execAsync( + [ + GH_PR_LIST_COMMAND, + repoFlag, + `${GH_STATE_FLAG} ${PR_STATE_MERGED}`, + `${GH_JSON_FLAG} ${PR_LIST_FIELDS}`, + `${GH_LIMIT_FLAG} ${MERGED_PRS_LIMIT}`, + ] + .filter(Boolean) + .join(' '), + { + cwd: projectPath, + env: execEnv, + } + ), + ]); + const { stdout: openStdout } = openResult; + const { stdout: mergedStdout } = mergedResult; + + const openPRs: GitHubPR[] = JSON.parse(openStdout || '[]'); + const mergedPRs: GitHubPR[] = JSON.parse(mergedStdout || '[]'); + + res.json({ + success: true, + openPRs, + mergedPRs, + }); + } catch (error) { + logError(error, 'List GitHub PRs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/resolve-pr-comment.ts b/jules_branch/apps/server/src/routes/github/routes/resolve-pr-comment.ts new file mode 100644 index 0000000000000000000000000000000000000000..39855c0442b589098e0be6355b58c3d8dc3f602a --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/resolve-pr-comment.ts @@ -0,0 +1,66 @@ +/** + * POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread + * + * Uses the GitHub GraphQL API to resolve or unresolve a review thread + * identified by its GraphQL node ID (threadId). + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from './common.js'; +import { checkGitHubRemote } from './check-github-remote.js'; +import { executeReviewThreadMutation } from '../../../services/github-pr-comment.service.js'; + +export interface ResolvePRCommentResult { + success: boolean; + isResolved?: boolean; + error?: string; +} + +interface ResolvePRCommentRequest { + projectPath: string; + threadId: string; + resolve: boolean; +} + +export function createResolvePRCommentHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!threadId) { + res.status(400).json({ success: false, error: 'threadId is required' }); + return; + } + + if (typeof resolve !== 'boolean') { + res.status(400).json({ success: false, error: 'resolve must be a boolean' }); + return; + } + + // Check if this is a GitHub repo + const remoteStatus = await checkGitHubRemote(projectPath); + if (!remoteStatus.hasGitHubRemote) { + res.status(400).json({ + success: false, + error: 'Project does not have a GitHub remote', + }); + return; + } + + const result = await executeReviewThreadMutation(projectPath, threadId, resolve); + + res.json({ + success: true, + isResolved: result.isResolved, + }); + } catch (error) { + logError(error, 'Resolve PR comment failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/validate-issue.ts b/jules_branch/apps/server/src/routes/github/routes/validate-issue.ts new file mode 100644 index 0000000000000000000000000000000000000000..7707f2b7d5f07510c782a79fb4a97d29bff22363 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/validate-issue.ts @@ -0,0 +1,448 @@ +/** + * POST /validate-issue endpoint - Validate a GitHub issue using provider abstraction (async) + * + * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. + * Runs asynchronously and emits events for progress and completion. + * Supports Claude, Codex, Cursor, and OpenCode models. + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { + IssueValidationResult, + IssueValidationEvent, + ModelId, + GitHubComment, + LinkedPRInfo, + ThinkingLevel, + ReasoningEffort, +} from '@automaker/types'; +import { + DEFAULT_PHASE_MODELS, + isClaudeModel, + isCodexModel, + isCursorModel, + isOpencodeModel, + supportsStructuredOutput, +} from '@automaker/types'; +import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver'; +import { extractJson } from '../../../lib/json-extractor.js'; +import { writeValidation } from '../../../lib/validation-storage.js'; +import { streamingQuery } from '../../../providers/simple-query-service.js'; +import { + issueValidationSchema, + buildValidationPrompt, + ValidationComment, + ValidationLinkedPR, +} from './validation-schema.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + resolveProviderContext, +} from '../../../lib/settings-helpers.js'; +import { + trySetValidationRunning, + clearValidationStatus, + getErrorMessage, + logError, + logger, +} from './validation-common.js'; +import type { SettingsService } from '../../../services/settings-service.js'; + +/** + * Request body for issue validation + */ +interface ValidateIssueRequestBody { + projectPath: string; + issueNumber: number; + issueTitle: string; + issueBody: string; + issueLabels?: string[]; + /** Model to use for validation (Claude alias or provider model ID) */ + model?: ModelId; + /** Thinking level for Claude models (ignored for non-Claude models) */ + thinkingLevel?: ThinkingLevel; + /** Reasoning effort for Codex models (ignored for non-Codex models) */ + reasoningEffort?: ReasoningEffort; + /** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */ + providerId?: string; + /** Comments to include in validation analysis */ + comments?: GitHubComment[]; + /** Linked pull requests for this issue */ + linkedPRs?: LinkedPRInfo[]; +} + +/** + * Run the validation asynchronously + * + * Emits events for start, progress, complete, and error. + * Stores result on completion. + * Supports Claude/Codex models (structured output) and Cursor/OpenCode models (JSON parsing). + */ +async function runValidation( + projectPath: string, + issueNumber: number, + issueTitle: string, + issueBody: string, + issueLabels: string[] | undefined, + model: ModelId, + events: EventEmitter, + abortController: AbortController, + settingsService?: SettingsService, + providerId?: string, + comments?: ValidationComment[], + linkedPRs?: ValidationLinkedPR[], + thinkingLevel?: ThinkingLevel, + reasoningEffort?: ReasoningEffort +): Promise { + // Emit start event + const startEvent: IssueValidationEvent = { + type: 'issue_validation_start', + issueNumber, + issueTitle, + projectPath, + }; + events.emit('issue-validation:event', startEvent); + + // Set up timeout (6 minutes) + const VALIDATION_TIMEOUT_MS = 360000; + const timeoutId = setTimeout(() => { + logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); + abortController.abort(); + }, VALIDATION_TIMEOUT_MS); + + try { + // Build the prompt (include comments and linked PRs if provided) + const basePrompt = buildValidationPrompt( + issueNumber, + issueTitle, + issueBody, + issueLabels, + comments, + linkedPRs + ); + + let responseText = ''; + + // Get customized prompts from settings + const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]'); + const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt; + + // Determine if we should use structured output based on model type + // Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't + const useStructuredOutput = supportsStructuredOutput(model); + + // Build the final prompt - for Cursor, include system prompt and JSON schema instructions + let finalPrompt = basePrompt; + if (!useStructuredOutput) { + finalPrompt = `${issueValidationSystemPrompt} + +CRITICAL INSTRUCTIONS: +1. DO NOT write any files. Return the JSON in your response only. +2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. +3. The JSON must match this exact schema: + +${JSON.stringify(issueValidationSchema, null, 2)} + +Your entire response should be valid JSON starting with { and ending with }. No text before or after. + +${basePrompt}`; + } + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + settingsService, + '[ValidateIssue]' + ); + + // Use request overrides if provided, otherwise fall back to settings + let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; + let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort; + if (!effectiveThinkingLevel || !effectiveReasoningEffort) { + const settings = await settingsService?.getGlobalSettings(); + const phaseModelEntry = + settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; + const resolved = resolvePhaseModel(phaseModelEntry); + if (!effectiveThinkingLevel) { + effectiveThinkingLevel = resolved.thinkingLevel; + } + if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') { + effectiveReasoningEffort = phaseModelEntry.reasoningEffort; + } + } + + // Check if the model is a provider model (like "GLM-4.5-Air") + // If so, get the provider config and resolved Claude model + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + let credentials = await settingsService?.getCredentials(); + + if (settingsService) { + const providerResult = await resolveProviderContext( + settingsService, + model, + providerId, + '[ValidateIssue]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + credentials = providerResult.credentials; + logger.info( + `Using provider "${providerResult.provider.name}" for model "${model}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } + + // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") + // to the API, NOT the resolved Claude model - otherwise we get "model not found" + // For standard Claude models, resolve aliases (e.g., 'opus' -> 'claude-opus-4-20250514') + const effectiveModel = claudeCompatibleProvider + ? (model as string) + : providerResolvedModel || resolveModelString(model as string); + logger.info(`Using model: ${effectiveModel}`); + + // Use streamingQuery with event callbacks + const result = await streamingQuery({ + prompt: finalPrompt, + model: effectiveModel, + cwd: projectPath, + systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined, + abortController, + thinkingLevel: effectiveThinkingLevel, + reasoningEffort: effectiveReasoningEffort, + readOnly: true, // Issue validation only reads code, doesn't write + settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + outputFormat: useStructuredOutput + ? { + type: 'json_schema', + schema: issueValidationSchema as Record, + } + : undefined, + onText: (text) => { + responseText += text; + // Emit progress event + const progressEvent: IssueValidationEvent = { + type: 'issue_validation_progress', + issueNumber, + content: text, + projectPath, + }; + events.emit('issue-validation:event', progressEvent); + }, + }); + + // Clear timeout + clearTimeout(timeoutId); + + // Get validation result from structured output or parse from text + let validationResult: IssueValidationResult | null = null; + + if (result.structured_output) { + validationResult = result.structured_output as unknown as IssueValidationResult; + logger.debug('Received structured output:', validationResult); + } else if (responseText) { + // Parse JSON from response text + validationResult = extractJson(responseText, { logger }); + } + + // Require validation result + if (!validationResult) { + logger.error('No validation result received from AI provider'); + throw new Error('Validation failed: no valid result received'); + } + + logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`); + + // Store the result + await writeValidation(projectPath, issueNumber, { + issueNumber, + issueTitle, + validatedAt: new Date().toISOString(), + model, + result: validationResult, + }); + + // Emit completion event + const completeEvent: IssueValidationEvent = { + type: 'issue_validation_complete', + issueNumber, + issueTitle, + result: validationResult, + projectPath, + model, + }; + events.emit('issue-validation:event', completeEvent); + } catch (error) { + clearTimeout(timeoutId); + + const errorMessage = getErrorMessage(error); + logError(error, `Issue #${issueNumber} validation failed`); + + // Emit error event + const errorEvent: IssueValidationEvent = { + type: 'issue_validation_error', + issueNumber, + error: errorMessage, + projectPath, + }; + events.emit('issue-validation:event', errorEvent); + + throw error; + } +} + +/** + * Creates the handler for validating GitHub issues against the codebase. + * + * Uses the provider abstraction with: + * - Read-only tools (Read, Glob, Grep) for codebase analysis + * - JSON schema structured output for reliable parsing + * - System prompt guiding the validation process + * - Async execution with event emission + */ +export function createValidateIssueHandler( + events: EventEmitter, + settingsService?: SettingsService +) { + return async (req: Request, res: Response): Promise => { + try { + const { + projectPath, + issueNumber, + issueTitle, + issueBody, + issueLabels, + model = 'opus', + thinkingLevel, + reasoningEffort, + providerId, + comments: rawComments, + linkedPRs: rawLinkedPRs, + } = req.body as ValidateIssueRequestBody; + + const normalizedProviderId = + typeof providerId === 'string' && providerId.trim().length > 0 + ? providerId.trim() + : undefined; + + // Transform GitHubComment[] to ValidationComment[] if provided + const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({ + author: c.author?.login || 'ghost', + createdAt: c.createdAt, + body: c.body, + })); + + // Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided + const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({ + number: pr.number, + title: pr.title, + state: pr.state, + })); + + logger.info( + `[ValidateIssue] Received validation request for issue #${issueNumber}` + + (rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') + + (rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '') + ); + + // Validate required fields + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + if (!issueTitle || typeof issueTitle !== 'string') { + res.status(400).json({ success: false, error: 'issueTitle is required' }); + return; + } + + if (typeof issueBody !== 'string') { + res.status(400).json({ success: false, error: 'issueBody must be a string' }); + return; + } + + // Validate model parameter at runtime - accept any supported provider model + const isValidModel = + isClaudeModel(model) || + isCursorModel(model) || + isCodexModel(model) || + isOpencodeModel(model) || + !!normalizedProviderId; + + if (!isValidModel) { + res.status(400).json({ + success: false, + error: + 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.', + }); + return; + } + + logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`); + + // Create abort controller and atomically try to claim validation slot + // This prevents TOCTOU race conditions + const abortController = new AbortController(); + if (!trySetValidationRunning(projectPath, issueNumber, abortController)) { + res.json({ + success: false, + error: `Validation is already running for issue #${issueNumber}`, + }); + return; + } + + // Start validation in background (fire-and-forget) + runValidation( + projectPath, + issueNumber, + issueTitle, + issueBody, + issueLabels, + model, + events, + abortController, + settingsService, + normalizedProviderId, + validationComments, + validationLinkedPRs, + thinkingLevel, + reasoningEffort + ) + .catch(() => { + // Error is already handled inside runValidation (event emitted) + }) + .finally(() => { + clearValidationStatus(projectPath, issueNumber); + }); + + // Return immediately + res.json({ + success: true, + message: `Validation started for issue #${issueNumber}`, + issueNumber, + }); + } catch (error) { + logError(error, `Issue validation failed`); + logger.error('Issue validation error:', error); + + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/validation-common.ts b/jules_branch/apps/server/src/routes/github/routes/validation-common.ts new file mode 100644 index 0000000000000000000000000000000000000000..63d34ceb7368cc213eb18d08bc1e03c2215527d6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/validation-common.ts @@ -0,0 +1,174 @@ +/** + * Common utilities and state for issue validation routes + * + * Tracks running validation status per issue to support: + * - Checking if a validation is in progress + * - Cancelling a running validation + * - Preventing duplicate validations for the same issue + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js'; + +const logger = createLogger('IssueValidation'); + +/** + * Status of a validation in progress + */ +interface ValidationStatus { + isRunning: boolean; + abortController: AbortController; + startedAt: Date; +} + +/** + * Map of issue number to validation status + * Key format: `${projectPath}||${issueNumber}` to support multiple projects + * Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\) + */ +const validationStatusMap = new Map(); + +/** Maximum age for stale validation entries before cleanup (1 hour) */ +const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000; + +/** + * Create a unique key for a validation + * Uses `||` as delimiter since `:` appears in Windows paths + */ +function getValidationKey(projectPath: string, issueNumber: number): string { + return `${projectPath}||${issueNumber}`; +} + +/** + * Check if a validation is currently running for an issue + */ +export function isValidationRunning(projectPath: string, issueNumber: number): boolean { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + return status?.isRunning ?? false; +} + +/** + * Get validation status for an issue + */ +export function getValidationStatus( + projectPath: string, + issueNumber: number +): { isRunning: boolean; startedAt?: Date } | null { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + if (!status) { + return null; + } + return { + isRunning: status.isRunning, + startedAt: status.startedAt, + }; +} + +/** + * Get all running validations for a project + */ +export function getRunningValidations(projectPath: string): number[] { + const runningIssues: number[] = []; + const prefix = `${projectPath}||`; + for (const [key, status] of validationStatusMap.entries()) { + if (status.isRunning && key.startsWith(prefix)) { + const issueNumber = parseInt(key.slice(prefix.length), 10); + if (!isNaN(issueNumber)) { + runningIssues.push(issueNumber); + } + } + } + return runningIssues; +} + +/** + * Set a validation as running + */ +export function setValidationRunning( + projectPath: string, + issueNumber: number, + abortController: AbortController +): void { + const key = getValidationKey(projectPath, issueNumber); + validationStatusMap.set(key, { + isRunning: true, + abortController, + startedAt: new Date(), + }); +} + +/** + * Atomically try to set a validation as running (check-and-set) + * Prevents TOCTOU race conditions when starting validations + * + * @returns true if successfully claimed, false if already running + */ +export function trySetValidationRunning( + projectPath: string, + issueNumber: number, + abortController: AbortController +): boolean { + const key = getValidationKey(projectPath, issueNumber); + if (validationStatusMap.has(key)) { + return false; // Already running + } + validationStatusMap.set(key, { + isRunning: true, + abortController, + startedAt: new Date(), + }); + return true; // Successfully claimed +} + +/** + * Cleanup stale validation entries (e.g., from crashed validations) + * Should be called periodically to prevent memory leaks + */ +export function cleanupStaleValidations(): number { + const now = Date.now(); + let cleanedCount = 0; + for (const [key, status] of validationStatusMap.entries()) { + if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) { + status.abortController.abort(); + validationStatusMap.delete(key); + cleanedCount++; + } + } + if (cleanedCount > 0) { + logger.info(`Cleaned up ${cleanedCount} stale validation entries`); + } + return cleanedCount; +} + +/** + * Clear validation status (call when validation completes or errors) + */ +export function clearValidationStatus(projectPath: string, issueNumber: number): void { + const key = getValidationKey(projectPath, issueNumber); + validationStatusMap.delete(key); +} + +/** + * Abort a running validation + * + * @returns true if validation was aborted, false if not running + */ +export function abortValidation(projectPath: string, issueNumber: number): boolean { + const key = getValidationKey(projectPath, issueNumber); + const status = validationStatusMap.get(key); + + if (!status || !status.isRunning) { + return false; + } + + status.abortController.abort(); + validationStatusMap.delete(key); + return true; +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); +export { logger }; diff --git a/jules_branch/apps/server/src/routes/github/routes/validation-endpoints.ts b/jules_branch/apps/server/src/routes/github/routes/validation-endpoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f3c23161e9c4be7835e275ea90ed77565740e0f --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/validation-endpoints.ts @@ -0,0 +1,234 @@ +/** + * Additional validation endpoints for status, stop, and retrieving stored validations + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IssueValidationEvent } from '@automaker/types'; +import { + getValidationStatus, + getRunningValidations, + abortValidation, + getErrorMessage, + logError, + logger, +} from './validation-common.js'; +import { + getAllValidations, + getValidationWithFreshness, + deleteValidation, + markValidationViewed, +} from '../../../lib/validation-storage.js'; + +/** + * POST /validation-status - Check if validation is running for an issue + */ +export function createValidationStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber?: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If issueNumber provided, check specific issue + if (issueNumber !== undefined) { + const status = getValidationStatus(projectPath, issueNumber); + res.json({ + success: true, + isRunning: status?.isRunning ?? false, + startedAt: status?.startedAt?.toISOString(), + }); + return; + } + + // Otherwise, return all running validations for the project + const runningIssues = getRunningValidations(projectPath); + res.json({ + success: true, + runningIssues, + }); + } catch (error) { + logError(error, 'Validation status check failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-stop - Cancel a running validation + */ +export function createValidationStopHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const wasAborted = abortValidation(projectPath, issueNumber); + + if (wasAborted) { + logger.info(`Validation for issue #${issueNumber} was stopped`); + res.json({ + success: true, + message: `Validation for issue #${issueNumber} has been stopped`, + }); + } else { + res.json({ + success: false, + error: `No validation is running for issue #${issueNumber}`, + }); + } + } catch (error) { + logError(error, 'Validation stop failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validations - Get stored validations for a project + */ +export function createGetValidationsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber?: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If issueNumber provided, get specific validation with freshness info + if (issueNumber !== undefined) { + const result = await getValidationWithFreshness(projectPath, issueNumber); + + if (!result) { + res.json({ + success: true, + validation: null, + }); + return; + } + + res.json({ + success: true, + validation: result.validation, + isStale: result.isStale, + }); + return; + } + + // Otherwise, get all validations for the project + const validations = await getAllValidations(projectPath); + + res.json({ + success: true, + validations, + }); + } catch (error) { + logError(error, 'Get validations failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-delete - Delete a stored validation + */ +export function createDeleteValidationHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const deleted = await deleteValidation(projectPath, issueNumber); + + res.json({ + success: true, + deleted, + }); + } catch (error) { + logError(error, 'Delete validation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * POST /validation-mark-viewed - Mark a validation as viewed by the user + */ +export function createMarkViewedHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, issueNumber } = req.body as { + projectPath: string; + issueNumber: number; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!issueNumber || typeof issueNumber !== 'number') { + res + .status(400) + .json({ success: false, error: 'issueNumber is required and must be a number' }); + return; + } + + const success = await markValidationViewed(projectPath, issueNumber); + + if (success) { + // Emit event so UI can update the unviewed count + const viewedEvent: IssueValidationEvent = { + type: 'issue_validation_viewed', + issueNumber, + projectPath, + }; + events.emit('issue-validation:event', viewedEvent); + } + + res.json({ success }); + } catch (error) { + logError(error, 'Mark validation viewed failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/github/routes/validation-schema.ts b/jules_branch/apps/server/src/routes/github/routes/validation-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ba48a2b4b4bc0534c405fdb08bbf6b30cb4911c --- /dev/null +++ b/jules_branch/apps/server/src/routes/github/routes/validation-schema.ts @@ -0,0 +1,172 @@ +/** + * Issue Validation Schema and Prompt Building + * + * Defines the JSON schema for Claude's structured output and + * helper functions for building validation prompts. + * + * Note: The system prompt is now centralized in @automaker/prompts + * and accessed via getPromptCustomization() in validate-issue.ts + */ + +/** + * JSON Schema for issue validation structured output. + * Used with Claude SDK's outputFormat option to ensure reliable parsing. + */ +export const issueValidationSchema = { + type: 'object', + properties: { + verdict: { + type: 'string', + enum: ['valid', 'invalid', 'needs_clarification'], + description: 'The validation verdict for the issue', + }, + confidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'How confident the AI is in its assessment', + }, + reasoning: { + type: 'string', + description: 'Detailed explanation of the verdict', + }, + bugConfirmed: { + type: 'boolean', + description: 'For bug reports: whether the bug was confirmed in the codebase', + }, + relatedFiles: { + type: 'array', + items: { type: 'string' }, + description: 'Files related to the issue found during analysis', + }, + suggestedFix: { + type: 'string', + description: 'Suggested approach to fix or implement the issue', + }, + missingInfo: { + type: 'array', + items: { type: 'string' }, + description: 'Information needed when verdict is needs_clarification', + }, + estimatedComplexity: { + type: 'string', + enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'], + description: 'Estimated effort to address the issue', + }, + prAnalysis: { + type: 'object', + properties: { + hasOpenPR: { + type: 'boolean', + description: 'Whether there is an open PR linked to this issue', + }, + prFixesIssue: { + type: 'boolean', + description: 'Whether the PR appears to fix the issue based on the diff', + }, + prNumber: { + type: 'number', + description: 'The PR number that was analyzed', + }, + prSummary: { + type: 'string', + description: 'Brief summary of what the PR changes', + }, + recommendation: { + type: 'string', + enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'], + description: + 'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR', + }, + }, + description: 'Analysis of linked pull requests if any exist', + }, + }, + required: ['verdict', 'confidence', 'reasoning'], + additionalProperties: false, +} as const; + +/** + * Comment data structure for validation prompt + */ +export interface ValidationComment { + author: string; + createdAt: string; + body: string; +} + +/** + * Linked PR data structure for validation prompt + */ +export interface ValidationLinkedPR { + number: number; + title: string; + state: string; +} + +/** + * Build the user prompt for issue validation. + * + * Creates a structured prompt that includes the issue details for Claude + * to analyze against the codebase. + * + * @param issueNumber - The GitHub issue number + * @param issueTitle - The issue title + * @param issueBody - The issue body/description + * @param issueLabels - Optional array of label names + * @param comments - Optional array of comments to include in analysis + * @param linkedPRs - Optional array of linked pull requests + * @returns Formatted prompt string for the validation request + */ +export function buildValidationPrompt( + issueNumber: number, + issueTitle: string, + issueBody: string, + issueLabels?: string[], + comments?: ValidationComment[], + linkedPRs?: ValidationLinkedPR[] +): string { + const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : ''; + + let linkedPRsSection = ''; + if (linkedPRs && linkedPRs.length > 0) { + const prsText = linkedPRs + .map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`) + .join('\n'); + linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`; + } + + let commentsSection = ''; + if (comments && comments.length > 0) { + // Limit to most recent 10 comments to control prompt size + const recentComments = comments.slice(-10); + const commentsText = recentComments + .map( + (c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}` + ) + .join('\n\n---\n\n'); + + commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`; + } + + const hasWorkInProgress = + linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN'); + const workInProgressNote = hasWorkInProgress + ? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.' + : ''; + + return `Please validate the following GitHub issue by analyzing the codebase: + +## Issue #${issueNumber}: ${issueTitle} +${labelsSection} +${linkedPRsSection} + +### Description + +${issueBody || '(No description provided)'} +${commentsSection} +${workInProgressNote} + +--- + +Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`; +} diff --git a/jules_branch/apps/server/src/routes/health/common.ts b/jules_branch/apps/server/src/routes/health/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac335c3d16fc4bf8176383815aab97a58ff152cc --- /dev/null +++ b/jules_branch/apps/server/src/routes/health/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for health routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Health'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/health/index.ts b/jules_branch/apps/server/src/routes/health/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..083a8703614410223afd82c06babbe66046edaa0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/health/index.ts @@ -0,0 +1,30 @@ +/** + * Health check routes + * + * NOTE: Only the basic health check (/) and environment check are unauthenticated. + * The /detailed endpoint requires authentication. + */ + +import { Router } from 'express'; +import { createIndexHandler } from './routes/index.js'; +import { createEnvironmentHandler } from './routes/environment.js'; + +/** + * Create unauthenticated health routes (basic check only) + * Used by load balancers and container orchestration + */ +export function createHealthRoutes(): Router { + const router = Router(); + + // Basic health check - no sensitive info + router.get('/', createIndexHandler()); + + // Environment info including containerization status + // This is unauthenticated so the UI can check on startup + router.get('/environment', createEnvironmentHandler()); + + return router; +} + +// Re-export detailed handler for use in authenticated routes +export { createDetailedHandler } from './routes/detailed.js'; diff --git a/jules_branch/apps/server/src/routes/health/routes/detailed.ts b/jules_branch/apps/server/src/routes/health/routes/detailed.ts new file mode 100644 index 0000000000000000000000000000000000000000..d51984666b71f1c1e4caf6e6b16b6a0f01cf1b9b --- /dev/null +++ b/jules_branch/apps/server/src/routes/health/routes/detailed.ts @@ -0,0 +1,26 @@ +/** + * GET /detailed endpoint - Detailed health check + */ + +import type { Request, Response } from 'express'; +import { getAuthStatus } from '../../../lib/auth.js'; +import { getVersion } from '../../../lib/version.js'; + +export function createDetailedHandler() { + return (_req: Request, res: Response): void => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: getVersion(), + uptime: process.uptime(), + memory: process.memoryUsage(), + dataDir: process.env.DATA_DIR || './data', + auth: getAuthStatus(), + env: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + }, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/health/routes/environment.ts b/jules_branch/apps/server/src/routes/health/routes/environment.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e5a89c9a38cd08d5a53d97c0bd937124f58e7f8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/health/routes/environment.ts @@ -0,0 +1,22 @@ +/** + * GET /environment endpoint - Environment information including containerization status + * + * This endpoint is unauthenticated so the UI can check it on startup + * before login to determine if sandbox risk warnings should be shown. + */ + +import type { Request, Response } from 'express'; + +export interface EnvironmentResponse { + isContainerized: boolean; + skipSandboxWarning?: boolean; +} + +export function createEnvironmentHandler() { + return (_req: Request, res: Response): void => { + res.json({ + isContainerized: process.env.IS_CONTAINERIZED === 'true', + skipSandboxWarning: process.env.AUTOMAKER_SKIP_SANDBOX_WARNING === 'true', + } satisfies EnvironmentResponse); + }; +} diff --git a/jules_branch/apps/server/src/routes/health/routes/index.ts b/jules_branch/apps/server/src/routes/health/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f956a96f7d1422594f83f7181cec768c8ca83220 --- /dev/null +++ b/jules_branch/apps/server/src/routes/health/routes/index.ts @@ -0,0 +1,16 @@ +/** + * GET / endpoint - Basic health check + */ + +import type { Request, Response } from 'express'; +import { getVersion } from '../../../lib/version.js'; + +export function createIndexHandler() { + return (_req: Request, res: Response): void => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: getVersion(), + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/common.ts b/jules_branch/apps/server/src/routes/ideation/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cca36542f04d26d1fb7dfe3212ac3ae3e43dbba --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for ideation routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Ideation'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/ideation/index.ts b/jules_branch/apps/server/src/routes/ideation/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..95fe128be0c768c371ca884dec326948e31482c5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/index.ts @@ -0,0 +1,109 @@ +/** + * Ideation routes - HTTP API for brainstorming and idea management + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import type { IdeationService } from '../../services/ideation-service.js'; +import type { FeatureLoader } from '../../services/feature-loader.js'; + +// Route handlers +import { createSessionStartHandler } from './routes/session-start.js'; +import { createSessionMessageHandler } from './routes/session-message.js'; +import { createSessionStopHandler } from './routes/session-stop.js'; +import { createSessionGetHandler } from './routes/session-get.js'; +import { createIdeasListHandler } from './routes/ideas-list.js'; +import { createIdeasCreateHandler } from './routes/ideas-create.js'; +import { createIdeasGetHandler } from './routes/ideas-get.js'; +import { createIdeasUpdateHandler } from './routes/ideas-update.js'; +import { createIdeasDeleteHandler } from './routes/ideas-delete.js'; +import { createAnalyzeHandler, createGetAnalysisHandler } from './routes/analyze.js'; +import { createConvertHandler } from './routes/convert.js'; +import { createAddSuggestionHandler } from './routes/add-suggestion.js'; +import { createPromptsHandler, createPromptsByCategoryHandler } from './routes/prompts.js'; +import { createSuggestionsGenerateHandler } from './routes/suggestions-generate.js'; + +export function createIdeationRoutes( + events: EventEmitter, + ideationService: IdeationService, + featureLoader: FeatureLoader +): Router { + const router = Router(); + + // Session management + router.post( + '/session/start', + validatePathParams('projectPath'), + createSessionStartHandler(ideationService) + ); + router.post('/session/message', createSessionMessageHandler(ideationService)); + router.post('/session/stop', createSessionStopHandler(events, ideationService)); + router.post( + '/session/get', + validatePathParams('projectPath'), + createSessionGetHandler(ideationService) + ); + + // Ideas CRUD + router.post( + '/ideas/list', + validatePathParams('projectPath'), + createIdeasListHandler(ideationService) + ); + router.post( + '/ideas/create', + validatePathParams('projectPath'), + createIdeasCreateHandler(events, ideationService) + ); + router.post( + '/ideas/get', + validatePathParams('projectPath'), + createIdeasGetHandler(ideationService) + ); + router.post( + '/ideas/update', + validatePathParams('projectPath'), + createIdeasUpdateHandler(events, ideationService) + ); + router.post( + '/ideas/delete', + validatePathParams('projectPath'), + createIdeasDeleteHandler(events, ideationService) + ); + + // Project analysis + router.post('/analyze', validatePathParams('projectPath'), createAnalyzeHandler(ideationService)); + router.post( + '/analysis', + validatePathParams('projectPath'), + createGetAnalysisHandler(ideationService) + ); + + // Convert to feature + router.post( + '/convert', + validatePathParams('projectPath'), + createConvertHandler(events, ideationService, featureLoader) + ); + + // Add suggestion to board as a feature + router.post( + '/add-suggestion', + validatePathParams('projectPath'), + createAddSuggestionHandler(ideationService, featureLoader) + ); + + // Guided prompts (no validation needed - static data) + router.get('/prompts', createPromptsHandler(ideationService)); + router.get('/prompts/:category', createPromptsByCategoryHandler(ideationService)); + + // Generate suggestions (structured output) + router.post( + '/suggestions/generate', + validatePathParams('projectPath'), + createSuggestionsGenerateHandler(ideationService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/add-suggestion.ts b/jules_branch/apps/server/src/routes/ideation/routes/add-suggestion.ts new file mode 100644 index 0000000000000000000000000000000000000000..3326bfc384dc38cfc4a556935169cc3d0695cec6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/add-suggestion.ts @@ -0,0 +1,70 @@ +/** + * POST /add-suggestion - Add an analysis suggestion to the board as a feature + * + * This endpoint converts an AnalysisSuggestion to a Feature using the + * IdeationService's mapIdeaCategoryToFeatureCategory for consistent category mapping. + * This ensures a single source of truth for the conversion logic. + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AnalysisSuggestion } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAddSuggestionHandler( + ideationService: IdeationService, + featureLoader: FeatureLoader +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, suggestion } = req.body as { + projectPath: string; + suggestion: AnalysisSuggestion; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!suggestion) { + res.status(400).json({ success: false, error: 'suggestion is required' }); + return; + } + + if (!suggestion.title) { + res.status(400).json({ success: false, error: 'suggestion.title is required' }); + return; + } + + if (!suggestion.category) { + res.status(400).json({ success: false, error: 'suggestion.category is required' }); + return; + } + + // Build description with rationale if provided + const description = suggestion.rationale + ? `${suggestion.description}\n\n**Rationale:** ${suggestion.rationale}` + : suggestion.description; + + // Use the service's category mapping for consistency + const featureCategory = ideationService.mapSuggestionCategoryToFeatureCategory( + suggestion.category + ); + + // Create the feature + const feature = await featureLoader.create(projectPath, { + title: suggestion.title, + description, + category: featureCategory, + status: 'backlog', + }); + + res.json({ success: true, featureId: feature.id }); + } catch (error) { + logError(error, 'Add suggestion to board failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/analyze.ts b/jules_branch/apps/server/src/routes/ideation/routes/analyze.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8e0b213e75c38d287bb4c5498dfc855d5f65b71 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/analyze.ts @@ -0,0 +1,49 @@ +/** + * POST /analyze - Analyze project and generate suggestions + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAnalyzeHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // Start analysis - results come via WebSocket events + ideationService.analyzeProject(projectPath).catch((error) => { + logError(error, 'Analyze project failed (async)'); + }); + + res.json({ success: true, message: 'Analysis started' }); + } catch (error) { + logError(error, 'Analyze project failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createGetAnalysisHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const result = await ideationService.getCachedAnalysis(projectPath); + res.json({ success: true, result }); + } catch (error) { + logError(error, 'Get analysis failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/convert.ts b/jules_branch/apps/server/src/routes/ideation/routes/convert.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1939bb40169c960ed842b982a78faef5f5308c5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/convert.ts @@ -0,0 +1,77 @@ +/** + * POST /convert - Convert an idea to a feature + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { ConvertToFeatureOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createConvertHandler( + events: EventEmitter, + ideationService: IdeationService, + featureLoader: FeatureLoader +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId, keepIdea, column, dependencies, tags } = req.body as { + projectPath: string; + ideaId: string; + } & ConvertToFeatureOptions; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + // Convert idea to feature structure + const featureData = await ideationService.convertToFeature(projectPath, ideaId); + + // Apply any options from the request + if (column) { + featureData.status = column; + } + if (dependencies && dependencies.length > 0) { + featureData.dependencies = dependencies; + } + if (tags && tags.length > 0) { + featureData.tags = tags; + } + + // Create the feature using FeatureLoader + const feature = await featureLoader.create(projectPath, featureData); + + // Delete the idea unless keepIdea is explicitly true + if (!keepIdea) { + await ideationService.deleteIdea(projectPath, ideaId); + + // Emit idea deleted event + events.emit('ideation:idea-deleted', { + projectPath, + ideaId, + }); + } + + // Emit idea converted event to notify frontend + events.emit('ideation:idea-converted', { + projectPath, + ideaId, + featureId: feature.id, + keepIdea: !!keepIdea, + }); + + // Return featureId as expected by the frontend API interface + res.json({ success: true, featureId: feature.id }); + } catch (error) { + logError(error, 'Convert to feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/ideas-create.ts b/jules_branch/apps/server/src/routes/ideation/routes/ideas-create.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf368fd9bb771354690c145a8bd2b73a13193186 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/ideas-create.ts @@ -0,0 +1,51 @@ +/** + * POST /ideas/create - Create a new idea + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { CreateIdeaInput } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasCreateHandler(events: EventEmitter, ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, idea } = req.body as { + projectPath: string; + idea: CreateIdeaInput; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!idea) { + res.status(400).json({ success: false, error: 'idea is required' }); + return; + } + + if (!idea.title || !idea.description || !idea.category) { + res.status(400).json({ + success: false, + error: 'idea must have title, description, and category', + }); + return; + } + + const created = await ideationService.createIdea(projectPath, idea); + + // Emit idea created event for frontend notification + events.emit('ideation:idea-created', { + projectPath, + idea: created, + }); + + res.json({ success: true, idea: created }); + } catch (error) { + logError(error, 'Create idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/ideas-delete.ts b/jules_branch/apps/server/src/routes/ideation/routes/ideas-delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1bcf00640457149595a99102cd95c0fb9aba807 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/ideas-delete.ts @@ -0,0 +1,42 @@ +/** + * POST /ideas/delete - Delete an idea + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasDeleteHandler(events: EventEmitter, ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId } = req.body as { + projectPath: string; + ideaId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + await ideationService.deleteIdea(projectPath, ideaId); + + // Emit idea deleted event for frontend notification + events.emit('ideation:idea-deleted', { + projectPath, + ideaId, + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/ideas-get.ts b/jules_branch/apps/server/src/routes/ideation/routes/ideas-get.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4865b461d711bfc45dff858aaa8ea1e44e02eab --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/ideas-get.ts @@ -0,0 +1,39 @@ +/** + * POST /ideas/get - Get a single idea + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasGetHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId } = req.body as { + projectPath: string; + ideaId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + const idea = await ideationService.getIdea(projectPath, ideaId); + if (!idea) { + res.status(404).json({ success: false, error: 'Idea not found' }); + return; + } + + res.json({ success: true, idea }); + } catch (error) { + logError(error, 'Get idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/ideas-list.ts b/jules_branch/apps/server/src/routes/ideation/routes/ideas-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f6b4504a0b75a7a59fdc1cd6bfbce1960d6d7a5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/ideas-list.ts @@ -0,0 +1,26 @@ +/** + * POST /ideas/list - List all ideas for a project + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasListHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const ideas = await ideationService.getIdeas(projectPath); + res.json({ success: true, ideas }); + } catch (error) { + logError(error, 'List ideas failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/ideas-update.ts b/jules_branch/apps/server/src/routes/ideation/routes/ideas-update.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbf0d8b6049f144623cf9cae43ca670d314a8c90 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/ideas-update.ts @@ -0,0 +1,54 @@ +/** + * POST /ideas/update - Update an idea + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { UpdateIdeaInput } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIdeasUpdateHandler(events: EventEmitter, ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, ideaId, updates } = req.body as { + projectPath: string; + ideaId: string; + updates: UpdateIdeaInput; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!ideaId) { + res.status(400).json({ success: false, error: 'ideaId is required' }); + return; + } + + if (!updates) { + res.status(400).json({ success: false, error: 'updates is required' }); + return; + } + + const idea = await ideationService.updateIdea(projectPath, ideaId, updates); + if (!idea) { + res.status(404).json({ success: false, error: 'Idea not found' }); + return; + } + + // Emit idea updated event for frontend notification + events.emit('ideation:idea-updated', { + projectPath, + ideaId, + idea, + }); + + res.json({ success: true, idea }); + } catch (error) { + logError(error, 'Update idea failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/prompts.ts b/jules_branch/apps/server/src/routes/ideation/routes/prompts.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d686bbb1457298504de4a7c4455ee722146775f --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/prompts.ts @@ -0,0 +1,42 @@ +/** + * GET /prompts - Get all guided prompts + * GET /prompts/:category - Get prompts for a specific category + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { IdeaCategory } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createPromptsHandler(ideationService: IdeationService) { + return async (_req: Request, res: Response): Promise => { + try { + const prompts = ideationService.getAllPrompts(); + const categories = ideationService.getPromptCategories(); + res.json({ success: true, prompts, categories }); + } catch (error) { + logError(error, 'Get prompts failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createPromptsByCategoryHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { category } = req.params as { category: string }; + + const validCategories = ideationService.getPromptCategories().map((c) => c.id); + if (!validCategories.includes(category as IdeaCategory)) { + res.status(400).json({ success: false, error: 'Invalid category' }); + return; + } + + const prompts = ideationService.getPromptsByCategory(category as IdeaCategory); + res.json({ success: true, prompts }); + } catch (error) { + logError(error, 'Get prompts by category failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/session-get.ts b/jules_branch/apps/server/src/routes/ideation/routes/session-get.ts new file mode 100644 index 0000000000000000000000000000000000000000..c95bd6cbfa96ece7beb10ffda6dc970734f208be --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/session-get.ts @@ -0,0 +1,45 @@ +/** + * POST /session/get - Get an ideation session with messages + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionGetHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, sessionId } = req.body as { + projectPath: string; + sessionId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + const session = await ideationService.getSession(projectPath, sessionId); + if (!session) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + const isRunning = ideationService.isSessionRunning(sessionId); + + res.json({ + success: true, + session: { ...session, isRunning }, + messages: session.messages, + }); + } catch (error) { + logError(error, 'Get session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/session-message.ts b/jules_branch/apps/server/src/routes/ideation/routes/session-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..0668583e4f35c74983e1382929b5fcf7ff5faa75 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/session-message.ts @@ -0,0 +1,40 @@ +/** + * POST /session/message - Send a message in an ideation session + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { SendMessageOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionMessageHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, message, options } = req.body as { + sessionId: string; + message: string; + options?: SendMessageOptions; + }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + if (!message) { + res.status(400).json({ success: false, error: 'message is required' }); + return; + } + + // This is async but we don't await - responses come via WebSocket + ideationService.sendMessage(sessionId, message, options).catch((error) => { + logError(error, 'Send message failed (async)'); + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Send message failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/session-start.ts b/jules_branch/apps/server/src/routes/ideation/routes/session-start.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d1ae8384ad757e18f23c80cdd8579dfa948cca3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/session-start.ts @@ -0,0 +1,30 @@ +/** + * POST /session/start - Start a new ideation session + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { StartSessionOptions } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionStartHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, options } = req.body as { + projectPath: string; + options?: StartSessionOptions; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const session = await ideationService.startSession(projectPath, options); + res.json({ success: true, session }); + } catch (error) { + logError(error, 'Start session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/session-stop.ts b/jules_branch/apps/server/src/routes/ideation/routes/session-stop.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0d59e3b51e8be11ff5dc0ebb5c1f9f8346e303a --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/session-stop.ts @@ -0,0 +1,39 @@ +/** + * POST /session/stop - Stop an ideation session + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSessionStopHandler(events: EventEmitter, ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId, projectPath } = req.body as { + sessionId: string; + projectPath?: string; + }; + + if (!sessionId) { + res.status(400).json({ success: false, error: 'sessionId is required' }); + return; + } + + await ideationService.stopSession(sessionId); + + // Emit session stopped event for frontend notification + // Note: The service also emits 'ideation:session-ended' internally, + // but we emit here as well for route-level consistency with other routes + events.emit('ideation:session-ended', { + sessionId, + projectPath, + }); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Stop session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/ideation/routes/suggestions-generate.ts b/jules_branch/apps/server/src/routes/ideation/routes/suggestions-generate.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffb4e8ac63a7498c3d3a3ceb047e2db5b62633d5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/ideation/routes/suggestions-generate.ts @@ -0,0 +1,63 @@ +/** + * Generate suggestions route - Returns structured AI suggestions for a prompt + */ + +import type { Request, Response } from 'express'; +import type { IdeationService } from '../../../services/ideation-service.js'; +import type { IdeationContextSources } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('ideation:suggestions-generate'); + +/** + * Creates an Express route handler for generating AI-powered ideation suggestions. + * Accepts a prompt, category, and optional context sources configuration, + * then returns structured suggestions that can be added to the board. + */ +export function createSuggestionsGenerateHandler(ideationService: IdeationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, promptId, category, count, contextSources } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!promptId) { + res.status(400).json({ success: false, error: 'promptId is required' }); + return; + } + + if (!category) { + res.status(400).json({ success: false, error: 'category is required' }); + return; + } + + // Default to 10 suggestions, allow 1-20 + const suggestionCount = Math.min(Math.max(count || 10, 1), 20); + + logger.info(`Generating ${suggestionCount} suggestions for prompt: ${promptId}`); + + const suggestions = await ideationService.generateSuggestions( + projectPath, + promptId, + category, + suggestionCount, + contextSources as IdeationContextSources | undefined + ); + + res.json({ + success: true, + suggestions, + }); + } catch (error) { + logError(error, 'Failed to generate suggestions'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/mcp/common.ts b/jules_branch/apps/server/src/routes/mcp/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..7256ef12e3592cdc0e639b53346902f6165a342b --- /dev/null +++ b/jules_branch/apps/server/src/routes/mcp/common.ts @@ -0,0 +1,24 @@ +/** + * Common utilities for MCP routes + */ + +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('MCP'); + +/** + * Extract error message from unknown error + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Log error with prefix + */ +export function logError(error: unknown, message: string): void { + logger.error(`${message}:`, error); +} diff --git a/jules_branch/apps/server/src/routes/mcp/index.ts b/jules_branch/apps/server/src/routes/mcp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c3a023f40b51a5caee403c664f73d042ba09cff --- /dev/null +++ b/jules_branch/apps/server/src/routes/mcp/index.ts @@ -0,0 +1,36 @@ +/** + * MCP routes - HTTP API for testing MCP servers + * + * Provides endpoints for: + * - Testing MCP server connections + * - Listing available tools from MCP servers + * + * Mounted at /api/mcp in the main server. + */ + +import { Router } from 'express'; +import type { MCPTestService } from '../../services/mcp-test-service.js'; +import { createTestServerHandler } from './routes/test-server.js'; +import { createListToolsHandler } from './routes/list-tools.js'; + +/** + * Create MCP router with all endpoints + * + * Endpoints: + * - POST /test - Test MCP server connection + * - POST /tools - List tools from MCP server + * + * @param mcpTestService - Instance of MCPTestService for testing connections + * @returns Express Router configured with all MCP endpoints + */ +export function createMCPRoutes(mcpTestService: MCPTestService): Router { + const router = Router(); + + // Test MCP server connection + router.post('/test', createTestServerHandler(mcpTestService)); + + // List tools from MCP server + router.post('/tools', createListToolsHandler(mcpTestService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/mcp/routes/list-tools.ts b/jules_branch/apps/server/src/routes/mcp/routes/list-tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..d215180414a0b0de6cf30c8cce6640ac8b27a43c --- /dev/null +++ b/jules_branch/apps/server/src/routes/mcp/routes/list-tools.ts @@ -0,0 +1,57 @@ +/** + * POST /api/mcp/tools - List tools for an MCP server + * + * Lists available tools for an MCP server. + * Similar to test but focused on tool discovery. + * + * SECURITY: Only accepts serverId to look up saved configs. Does NOT accept + * arbitrary serverConfig to prevent drive-by command execution attacks. + * Users must explicitly save a server config through the UI before testing. + * + * Request body: + * { serverId: string } - Get tools by server ID from settings + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface ListToolsRequest { + serverId: string; +} + +/** + * Create handler factory for POST /api/mcp/tools + */ +export function createListToolsHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as ListToolsRequest; + + if (!body.serverId || typeof body.serverId !== 'string') { + res.status(400).json({ + success: false, + error: 'serverId is required', + }); + return; + } + + const result = await mcpTestService.testServerById(body.serverId); + + // Return only tool-related information + res.json({ + success: result.success, + tools: result.tools, + error: result.error, + }); + } catch (error) { + logError(error, 'List tools failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/mcp/routes/test-server.ts b/jules_branch/apps/server/src/routes/mcp/routes/test-server.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb7581cfc066c020d834a9e7fb3961f7754da99f --- /dev/null +++ b/jules_branch/apps/server/src/routes/mcp/routes/test-server.ts @@ -0,0 +1,50 @@ +/** + * POST /api/mcp/test - Test MCP server connection and list tools + * + * Tests connection to an MCP server and returns available tools. + * + * SECURITY: Only accepts serverId to look up saved configs. Does NOT accept + * arbitrary serverConfig to prevent drive-by command execution attacks. + * Users must explicitly save a server config through the UI before testing. + * + * Request body: + * { serverId: string } - Test server by ID from settings + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface TestServerRequest { + serverId: string; +} + +/** + * Create handler factory for POST /api/mcp/test + */ +export function createTestServerHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as TestServerRequest; + + if (!body.serverId || typeof body.serverId !== 'string') { + res.status(400).json({ + success: false, + error: 'serverId is required', + }); + return; + } + + const result = await mcpTestService.testServerById(body.serverId); + res.json(result); + } catch (error) { + logError(error, 'Test server failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/models/common.ts b/jules_branch/apps/server/src/routes/models/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f30c028743fc3d73535d16671650191748dc5bd --- /dev/null +++ b/jules_branch/apps/server/src/routes/models/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for models routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Models'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/models/index.ts b/jules_branch/apps/server/src/routes/models/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..14d0beabbb15aef69fa353e57a8e5fb6f2bb22dc --- /dev/null +++ b/jules_branch/apps/server/src/routes/models/index.ts @@ -0,0 +1,16 @@ +/** + * Models routes - HTTP API for model providers and availability + */ + +import { Router } from 'express'; +import { createAvailableHandler } from './routes/available.js'; +import { createProvidersHandler } from './routes/providers.js'; + +export function createModelsRoutes(): Router { + const router = Router(); + + router.get('/available', createAvailableHandler()); + router.get('/providers', createProvidersHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/models/routes/available.ts b/jules_branch/apps/server/src/routes/models/routes/available.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ebb499295aab664927ebc4319a2c1962ae7c0dd --- /dev/null +++ b/jules_branch/apps/server/src/routes/models/routes/available.ts @@ -0,0 +1,21 @@ +/** + * GET /available endpoint - Get available models from all providers + */ + +import type { Request, Response } from 'express'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAvailableHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Get all models from all registered providers (Claude + Cursor) + const models = ProviderFactory.getAllAvailableModels(); + + res.json({ success: true, models }); + } catch (error) { + logError(error, 'Get available models failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/models/routes/providers.ts b/jules_branch/apps/server/src/routes/models/routes/providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa4d2828dd911ab4e4bbc4987ab6250a5e66b495 --- /dev/null +++ b/jules_branch/apps/server/src/routes/models/routes/providers.ts @@ -0,0 +1,35 @@ +/** + * GET /providers endpoint - Check provider status + */ + +import type { Request, Response } from 'express'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createProvidersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Get installation status from all providers + const statuses = await ProviderFactory.checkAllProviders(); + + const providers: Record> = { + anthropic: { + available: statuses.claude?.installed || false, + hasApiKey: !!process.env.ANTHROPIC_API_KEY, + }, + cursor: { + available: statuses.cursor?.installed || false, + version: statuses.cursor?.version, + path: statuses.cursor?.path, + method: statuses.cursor?.method, + authenticated: statuses.cursor?.authenticated, + }, + }; + + res.json({ success: true, providers }); + } catch (error) { + logError(error, 'Get providers failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/notifications/common.ts b/jules_branch/apps/server/src/routes/notifications/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..707e3a0ddbe57fda7d3c27ff5b35c4c494caeae3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for notification routes + * + * Provides logger and error handling utilities shared across all notification endpoints. + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for notification-related operations */ +export const logger = createLogger('Notifications'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/notifications/index.ts b/jules_branch/apps/server/src/routes/notifications/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2def111a05881fd249bf3458835f5216ab55792a --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/index.ts @@ -0,0 +1,62 @@ +/** + * Notifications routes - HTTP API for project-level notifications + * + * Provides endpoints for: + * - Listing notifications + * - Getting unread count + * - Marking notifications as read + * - Dismissing notifications + * + * All endpoints use handler factories that receive the NotificationService instance. + * Mounted at /api/notifications in the main server. + */ + +import { Router } from 'express'; +import type { NotificationService } from '../../services/notification-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createListHandler } from './routes/list.js'; +import { createUnreadCountHandler } from './routes/unread-count.js'; +import { createMarkReadHandler } from './routes/mark-read.js'; +import { createDismissHandler } from './routes/dismiss.js'; + +/** + * Create notifications router with all endpoints + * + * Endpoints: + * - POST /list - List all notifications for a project + * - POST /unread-count - Get unread notification count + * - POST /mark-read - Mark notification(s) as read + * - POST /dismiss - Dismiss notification(s) + * + * @param notificationService - Instance of NotificationService + * @returns Express Router configured with all notification endpoints + */ +export function createNotificationsRoutes(notificationService: NotificationService): Router { + const router = Router(); + + // List notifications + router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService)); + + // Get unread count + router.post( + '/unread-count', + validatePathParams('projectPath'), + createUnreadCountHandler(notificationService) + ); + + // Mark as read (single or all) + router.post( + '/mark-read', + validatePathParams('projectPath'), + createMarkReadHandler(notificationService) + ); + + // Dismiss (single or all) + router.post( + '/dismiss', + validatePathParams('projectPath'), + createDismissHandler(notificationService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/notifications/routes/dismiss.ts b/jules_branch/apps/server/src/routes/notifications/routes/dismiss.ts new file mode 100644 index 0000000000000000000000000000000000000000..c609f1700bfd236d92b58a204a19459457c7e2f3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/routes/dismiss.ts @@ -0,0 +1,53 @@ +/** + * POST /api/notifications/dismiss - Dismiss notification(s) + * + * Request body: { projectPath: string, notificationId?: string } + * - If notificationId provided: dismisses that notification + * - If notificationId not provided: dismisses all notifications + * + * Response: { success: true, dismissed: boolean | count: number } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/dismiss + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createDismissHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, notificationId } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If notificationId provided, dismiss single notification + if (notificationId) { + const dismissed = await notificationService.dismissNotification( + projectPath, + notificationId + ); + if (!dismissed) { + res.status(404).json({ success: false, error: 'Notification not found' }); + return; + } + res.json({ success: true, dismissed: true }); + return; + } + + // Otherwise dismiss all + const count = await notificationService.dismissAll(projectPath); + res.json({ success: true, count }); + } catch (error) { + logError(error, 'Dismiss failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/notifications/routes/list.ts b/jules_branch/apps/server/src/routes/notifications/routes/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..46197fe91e0d1ccbf798fa192432590e27daa65c --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/routes/list.ts @@ -0,0 +1,39 @@ +/** + * POST /api/notifications/list - List all notifications for a project + * + * Request body: { projectPath: string } + * Response: { success: true, notifications: Notification[] } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/list + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createListHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const notifications = await notificationService.getNotifications(projectPath); + + res.json({ + success: true, + notifications, + }); + } catch (error) { + logError(error, 'List notifications failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/notifications/routes/mark-read.ts b/jules_branch/apps/server/src/routes/notifications/routes/mark-read.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c9bfeb539185181829d58e41269e22fe148c431 --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/routes/mark-read.ts @@ -0,0 +1,50 @@ +/** + * POST /api/notifications/mark-read - Mark notification(s) as read + * + * Request body: { projectPath: string, notificationId?: string } + * - If notificationId provided: marks that notification as read + * - If notificationId not provided: marks all notifications as read + * + * Response: { success: true, count?: number, notification?: Notification } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/mark-read + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createMarkReadHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, notificationId } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + // If notificationId provided, mark single notification + if (notificationId) { + const notification = await notificationService.markAsRead(projectPath, notificationId); + if (!notification) { + res.status(404).json({ success: false, error: 'Notification not found' }); + return; + } + res.json({ success: true, notification }); + return; + } + + // Otherwise mark all as read + const count = await notificationService.markAllAsRead(projectPath); + res.json({ success: true, count }); + } catch (error) { + logError(error, 'Mark read failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/notifications/routes/unread-count.ts b/jules_branch/apps/server/src/routes/notifications/routes/unread-count.ts new file mode 100644 index 0000000000000000000000000000000000000000..98d8e198a2de629bc39107f3f21903d1733c10bb --- /dev/null +++ b/jules_branch/apps/server/src/routes/notifications/routes/unread-count.ts @@ -0,0 +1,39 @@ +/** + * POST /api/notifications/unread-count - Get unread notification count + * + * Request body: { projectPath: string } + * Response: { success: true, count: number } + */ + +import type { Request, Response } from 'express'; +import type { NotificationService } from '../../../services/notification-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler for POST /api/notifications/unread-count + * + * @param notificationService - Instance of NotificationService + * @returns Express request handler + */ +export function createUnreadCountHandler(notificationService: NotificationService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const count = await notificationService.getUnreadCount(projectPath); + + res.json({ + success: true, + count, + }); + } catch (error) { + logError(error, 'Get unread count failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/common.ts b/jules_branch/apps/server/src/routes/pipeline/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..26aa3e10b6f76f8a092c26e82cea56e7d8c93641 --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/common.ts @@ -0,0 +1,21 @@ +/** + * Common utilities for pipeline routes + * + * Provides logger and error handling utilities shared across all pipeline endpoints. + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for pipeline-related operations */ +export const logger = createLogger('Pipeline'); + +/** + * Extract user-friendly error message from error objects + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + */ +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/pipeline/index.ts b/jules_branch/apps/server/src/routes/pipeline/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..86880379b8f13f7f8cb8326294662f4a42a98e5a --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/index.ts @@ -0,0 +1,77 @@ +/** + * Pipeline routes - HTTP API for pipeline configuration management + * + * Provides endpoints for: + * - Getting pipeline configuration + * - Saving pipeline configuration + * - Adding, updating, deleting, and reordering pipeline steps + * + * All endpoints use handler factories that receive the PipelineService instance. + * Mounted at /api/pipeline in the main server. + */ + +import { Router } from 'express'; +import type { PipelineService } from '../../services/pipeline-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createGetConfigHandler } from './routes/get-config.js'; +import { createSaveConfigHandler } from './routes/save-config.js'; +import { createAddStepHandler } from './routes/add-step.js'; +import { createUpdateStepHandler } from './routes/update-step.js'; +import { createDeleteStepHandler } from './routes/delete-step.js'; +import { createReorderStepsHandler } from './routes/reorder-steps.js'; + +/** + * Create pipeline router with all endpoints + * + * Endpoints: + * - POST /config - Get pipeline configuration + * - POST /config/save - Save entire pipeline configuration + * - POST /steps/add - Add a new pipeline step + * - POST /steps/update - Update an existing pipeline step + * - POST /steps/delete - Delete a pipeline step + * - POST /steps/reorder - Reorder pipeline steps + * + * @param pipelineService - Instance of PipelineService for file I/O + * @returns Express Router configured with all pipeline endpoints + */ +export function createPipelineRoutes(pipelineService: PipelineService): Router { + const router = Router(); + + // Get pipeline configuration + router.post( + '/config', + validatePathParams('projectPath'), + createGetConfigHandler(pipelineService) + ); + + // Save entire pipeline configuration + router.post( + '/config/save', + validatePathParams('projectPath'), + createSaveConfigHandler(pipelineService) + ); + + // Pipeline step operations + router.post( + '/steps/add', + validatePathParams('projectPath'), + createAddStepHandler(pipelineService) + ); + router.post( + '/steps/update', + validatePathParams('projectPath'), + createUpdateStepHandler(pipelineService) + ); + router.post( + '/steps/delete', + validatePathParams('projectPath'), + createDeleteStepHandler(pipelineService) + ); + router.post( + '/steps/reorder', + validatePathParams('projectPath'), + createReorderStepsHandler(pipelineService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/add-step.ts b/jules_branch/apps/server/src/routes/pipeline/routes/add-step.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9494e3e9d84ccc2c182f1da247df04811961a6d --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/add-step.ts @@ -0,0 +1,54 @@ +/** + * POST /api/pipeline/steps/add - Add a new pipeline step + * + * Adds a new step to the pipeline configuration. + * + * Request body: { projectPath: string, step: { name, order, instructions, colorClass } } + * Response: { success: true, step: PipelineStep } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineStep } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createAddStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, step } = req.body as { + projectPath: string; + step: Omit; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!step) { + res.status(400).json({ success: false, error: 'step is required' }); + return; + } + + if (!step.name) { + res.status(400).json({ success: false, error: 'step.name is required' }); + return; + } + + if (step.instructions === undefined) { + res.status(400).json({ success: false, error: 'step.instructions is required' }); + return; + } + + const newStep = await pipelineService.addStep(projectPath, step); + + res.json({ + success: true, + step: newStep, + }); + } catch (error) { + logError(error, 'Add pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/delete-step.ts b/jules_branch/apps/server/src/routes/pipeline/routes/delete-step.ts new file mode 100644 index 0000000000000000000000000000000000000000..56b6c753187ed805b4a8f1dd902e2614cf5fd1b8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/delete-step.ts @@ -0,0 +1,42 @@ +/** + * POST /api/pipeline/steps/delete - Delete a pipeline step + * + * Removes a step from the pipeline configuration. + * + * Request body: { projectPath: string, stepId: string } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepId } = req.body as { + projectPath: string; + stepId: string; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepId) { + res.status(400).json({ success: false, error: 'stepId is required' }); + return; + } + + await pipelineService.deleteStep(projectPath, stepId); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Delete pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/get-config.ts b/jules_branch/apps/server/src/routes/pipeline/routes/get-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e37735e4007200be6df22a96b766f7e134bda10 --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/get-config.ts @@ -0,0 +1,35 @@ +/** + * POST /api/pipeline/config - Get pipeline configuration + * + * Returns the pipeline configuration for a project. + * + * Request body: { projectPath: string } + * Response: { success: true, config: PipelineConfig } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetConfigHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + const config = await pipelineService.getPipelineConfig(projectPath); + + res.json({ + success: true, + config, + }); + } catch (error) { + logError(error, 'Get pipeline config failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/reorder-steps.ts b/jules_branch/apps/server/src/routes/pipeline/routes/reorder-steps.ts new file mode 100644 index 0000000000000000000000000000000000000000..d51be11c466c8d2cb372ee97fdedf03cf3d0e8de --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/reorder-steps.ts @@ -0,0 +1,42 @@ +/** + * POST /api/pipeline/steps/reorder - Reorder pipeline steps + * + * Reorders the steps in the pipeline configuration. + * + * Request body: { projectPath: string, stepIds: string[] } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createReorderStepsHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepIds } = req.body as { + projectPath: string; + stepIds: string[]; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepIds || !Array.isArray(stepIds)) { + res.status(400).json({ success: false, error: 'stepIds array is required' }); + return; + } + + await pipelineService.reorderSteps(projectPath, stepIds); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Reorder pipeline steps failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/save-config.ts b/jules_branch/apps/server/src/routes/pipeline/routes/save-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d414e5b0f4a2d5693dea273250f3cad409d1de7e --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/save-config.ts @@ -0,0 +1,43 @@ +/** + * POST /api/pipeline/config/save - Save entire pipeline configuration + * + * Saves the complete pipeline configuration for a project. + * + * Request body: { projectPath: string, config: PipelineConfig } + * Response: { success: true } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineConfig } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSaveConfigHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, config } = req.body as { + projectPath: string; + config: PipelineConfig; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!config) { + res.status(400).json({ success: false, error: 'config is required' }); + return; + } + + await pipelineService.savePipelineConfig(projectPath, config); + + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Save pipeline config failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/pipeline/routes/update-step.ts b/jules_branch/apps/server/src/routes/pipeline/routes/update-step.ts new file mode 100644 index 0000000000000000000000000000000000000000..22ad944d9f07a2800bff54ba8069f5a614f5d51d --- /dev/null +++ b/jules_branch/apps/server/src/routes/pipeline/routes/update-step.ts @@ -0,0 +1,50 @@ +/** + * POST /api/pipeline/steps/update - Update an existing pipeline step + * + * Updates a step in the pipeline configuration. + * + * Request body: { projectPath: string, stepId: string, updates: Partial } + * Response: { success: true, step: PipelineStep } + */ + +import type { Request, Response } from 'express'; +import type { PipelineService } from '../../../services/pipeline-service.js'; +import type { PipelineStep } from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +export function createUpdateStepHandler(pipelineService: PipelineService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, stepId, updates } = req.body as { + projectPath: string; + stepId: string; + updates: Partial>; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!stepId) { + res.status(400).json({ success: false, error: 'stepId is required' }); + return; + } + + if (!updates || Object.keys(updates).length === 0) { + res.status(400).json({ success: false, error: 'updates is required' }); + return; + } + + const updatedStep = await pipelineService.updateStep(projectPath, stepId, updates); + + res.json({ + success: true, + step: updatedStep, + }); + } catch (error) { + logError(error, 'Update pipeline step failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/projects/common.ts b/jules_branch/apps/server/src/routes/projects/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa06248ae930fad6ad517ef4302bb37f643168c5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/projects/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for projects routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Projects'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/projects/index.ts b/jules_branch/apps/server/src/routes/projects/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff58167dd3f8fb8cdda868ac772dd1a84c49156d --- /dev/null +++ b/jules_branch/apps/server/src/routes/projects/index.ts @@ -0,0 +1,27 @@ +/** + * Projects routes - HTTP API for multi-project overview and management + */ + +import { Router } from 'express'; +import type { FeatureLoader } from '../../services/feature-loader.js'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import type { NotificationService } from '../../services/notification-service.js'; +import { createOverviewHandler } from './routes/overview.js'; + +export function createProjectsRoutes( + featureLoader: FeatureLoader, + autoModeService: AutoModeServiceCompat, + settingsService: SettingsService, + notificationService: NotificationService +): Router { + const router = Router(); + + // GET /overview - Get aggregate status for all projects + router.get( + '/overview', + createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService) + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/projects/routes/overview.ts b/jules_branch/apps/server/src/routes/projects/routes/overview.ts new file mode 100644 index 0000000000000000000000000000000000000000..436e683fa212c0503f9577455e4b21dac8cc07ea --- /dev/null +++ b/jules_branch/apps/server/src/routes/projects/routes/overview.ts @@ -0,0 +1,324 @@ +/** + * GET /overview endpoint - Get aggregate status for all projects + * + * Returns a complete overview of all projects including: + * - Individual project status (features, auto-mode state) + * - Aggregate metrics across all projects + * - Recent activity feed (placeholder for future implementation) + */ + +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 { SettingsService } from '../../../services/settings-service.js'; +import type { NotificationService } from '../../../services/notification-service.js'; +import type { + ProjectStatus, + AggregateStatus, + MultiProjectOverview, + FeatureStatusCounts, + AggregateFeatureCounts, + AggregateProjectCounts, + ProjectHealthStatus, + Feature, + ProjectRef, +} from '@automaker/types'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Compute feature status counts from a list of features + */ +function computeFeatureCounts(features: Feature[]): FeatureStatusCounts { + const counts: FeatureStatusCounts = { + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }; + + for (const feature of features) { + switch (feature.status) { + case 'pending': + case 'ready': + counts.pending++; + break; + case 'running': + case 'generating_spec': + case 'in_progress': + counts.running++; + break; + case 'waiting_approval': + // waiting_approval means agent finished, needs human review - count as pending + counts.pending++; + break; + case 'completed': + counts.completed++; + break; + case 'failed': + counts.failed++; + break; + case 'verified': + counts.verified++; + break; + default: + // Unknown status, treat as pending + counts.pending++; + } + } + + return counts; +} + +/** + * Determine the overall health status of a project based on its feature statuses + */ +function computeHealthStatus( + featureCounts: FeatureStatusCounts, + isAutoModeRunning: boolean +): ProjectHealthStatus { + const totalFeatures = + featureCounts.pending + + featureCounts.running + + featureCounts.completed + + featureCounts.failed + + featureCounts.verified; + + // If there are failed features, the project has errors + if (featureCounts.failed > 0) { + return 'error'; + } + + // If there are running features or auto mode is running with pending work + if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) { + return 'active'; + } + + // Pending work but no active execution + if (featureCounts.pending > 0) { + return 'waiting'; + } + + // If all features are completed or verified + if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) { + return 'completed'; + } + + // Default to idle + return 'idle'; +} + +/** + * Get the most recent activity timestamp from features + */ +function getLastActivityAt(features: Feature[]): string | undefined { + if (features.length === 0) { + return undefined; + } + + let latestTimestamp: number = 0; + + for (const feature of features) { + // Check startedAt timestamp (the main timestamp available on Feature) + if (feature.startedAt) { + const timestamp = new Date(feature.startedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + + // Also check planSpec timestamps if available + if (feature.planSpec?.generatedAt) { + const timestamp = new Date(feature.planSpec.generatedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + if (feature.planSpec?.approvedAt) { + const timestamp = new Date(feature.planSpec.approvedAt).getTime(); + if (!isNaN(timestamp) && timestamp > latestTimestamp) { + latestTimestamp = timestamp; + } + } + } + + return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined; +} + +export function createOverviewHandler( + featureLoader: FeatureLoader, + autoModeService: AutoModeServiceCompat, + settingsService: SettingsService, + notificationService: NotificationService +) { + return async (_req: Request, res: Response): Promise => { + try { + // Get all projects from settings + const settings = await settingsService.getGlobalSettings(); + const projectRefs: ProjectRef[] = settings.projects || []; + + // Get all running agents once to count live running features per project + const allRunningAgents: RunningAgentInfo[] = await autoModeService.getRunningAgents(); + + // Collect project statuses in parallel + const projectStatusPromises = projectRefs.map(async (projectRef): Promise => { + try { + // Load features for this project + const features = await featureLoader.getAll(projectRef.path); + const featureCounts = computeFeatureCounts(features); + const totalFeatures = features.length; + + // Get auto-mode status for this project (main worktree, branchName = null) + const autoModeStatus: ProjectAutoModeStatus = await autoModeService.getStatusForProject( + projectRef.path, + null + ); + const isAutoModeRunning = autoModeStatus.isAutoLoopRunning; + + // Count live running features for this project (across all branches) + // This ensures we only count features that are actually running in memory + const liveRunningCount = allRunningAgents.filter( + (agent) => agent.projectPath === projectRef.path + ).length; + featureCounts.running = liveRunningCount; + + // Get notification count for this project + let unreadNotificationCount = 0; + try { + const notifications = await notificationService.getNotifications(projectRef.path); + unreadNotificationCount = notifications.filter((n) => !n.read).length; + } catch { + // Ignore notification errors - project may not have any notifications yet + } + + // Compute health status + const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning); + + // Get last activity timestamp + const lastActivityAt = getLastActivityAt(features); + + return { + projectId: projectRef.id, + projectName: projectRef.name, + projectPath: projectRef.path, + healthStatus, + featureCounts, + totalFeatures, + lastActivityAt, + isAutoModeRunning, + activeBranch: autoModeStatus.branchName ?? undefined, + unreadNotificationCount, + }; + } catch (error) { + logError(error, `Failed to load project status: ${projectRef.name}`); + // Return a minimal status for projects that fail to load + return { + projectId: projectRef.id, + projectName: projectRef.name, + projectPath: projectRef.path, + healthStatus: 'error' as ProjectHealthStatus, + featureCounts: { + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }, + totalFeatures: 0, + isAutoModeRunning: false, + unreadNotificationCount: 0, + }; + } + }); + + const projectStatuses = await Promise.all(projectStatusPromises); + + // Compute aggregate metrics + const aggregateFeatureCounts: AggregateFeatureCounts = { + total: 0, + pending: 0, + running: 0, + completed: 0, + failed: 0, + verified: 0, + }; + + const aggregateProjectCounts: AggregateProjectCounts = { + total: projectStatuses.length, + active: 0, + idle: 0, + waiting: 0, + withErrors: 0, + allCompleted: 0, + }; + + let totalUnreadNotifications = 0; + let projectsWithAutoModeRunning = 0; + + for (const status of projectStatuses) { + // Aggregate feature counts + aggregateFeatureCounts.total += status.totalFeatures; + aggregateFeatureCounts.pending += status.featureCounts.pending; + aggregateFeatureCounts.running += status.featureCounts.running; + aggregateFeatureCounts.completed += status.featureCounts.completed; + aggregateFeatureCounts.failed += status.featureCounts.failed; + aggregateFeatureCounts.verified += status.featureCounts.verified; + + // Aggregate project counts by health status + switch (status.healthStatus) { + case 'active': + aggregateProjectCounts.active++; + break; + case 'idle': + aggregateProjectCounts.idle++; + break; + case 'waiting': + aggregateProjectCounts.waiting++; + break; + case 'error': + aggregateProjectCounts.withErrors++; + break; + case 'completed': + aggregateProjectCounts.allCompleted++; + break; + } + + // Aggregate notifications + totalUnreadNotifications += status.unreadNotificationCount; + + // Count projects with auto-mode running + if (status.isAutoModeRunning) { + projectsWithAutoModeRunning++; + } + } + + const aggregateStatus: AggregateStatus = { + projectCounts: aggregateProjectCounts, + featureCounts: aggregateFeatureCounts, + totalUnreadNotifications, + projectsWithAutoModeRunning, + computedAt: new Date().toISOString(), + }; + + // Build the response (recentActivity is empty for now - can be populated later) + const overview: MultiProjectOverview = { + projects: projectStatuses, + aggregate: aggregateStatus, + recentActivity: [], // Placeholder for future activity feed implementation + generatedAt: new Date().toISOString(), + }; + + res.json({ + success: true, + ...overview, + }); + } catch (error) { + logError(error, 'Get project overview failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/running-agents/common.ts b/jules_branch/apps/server/src/routes/running-agents/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2d16a185a16dc23bbdd7d80aa8575e7859daa06 --- /dev/null +++ b/jules_branch/apps/server/src/routes/running-agents/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for running-agents routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('RunningAgents'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/running-agents/index.ts b/jules_branch/apps/server/src/routes/running-agents/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b94e54f30f483448f750874eea8b71eba8a02b79 --- /dev/null +++ b/jules_branch/apps/server/src/routes/running-agents/index.ts @@ -0,0 +1,15 @@ +/** + * Running Agents routes - HTTP API for tracking active agent executions + */ + +import { Router } from 'express'; +import type { AutoModeServiceCompat } from '../../services/auto-mode/index.js'; +import { createIndexHandler } from './routes/index.js'; + +export function createRunningAgentsRoutes(autoModeService: AutoModeServiceCompat): Router { + const router = Router(); + + router.get('/', createIndexHandler(autoModeService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/running-agents/routes/index.ts b/jules_branch/apps/server/src/routes/running-agents/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c18be55b394745ddf40b795ef068053c8bfd087a --- /dev/null +++ b/jules_branch/apps/server/src/routes/running-agents/routes/index.ts @@ -0,0 +1,71 @@ +/** + * GET / endpoint - Get all running agents + */ + +import type { Request, Response } from 'express'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getBacklogPlanStatus, getRunningDetails } from '../../backlog-plan/common.js'; +import { getAllRunningGenerations } from '../../app-spec/common.js'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIndexHandler(autoModeService: AutoModeServiceCompat) { + return async (_req: Request, res: Response): Promise => { + try { + const runningAgents = [...(await autoModeService.getRunningAgents())]; + + const backlogPlanStatus = getBacklogPlanStatus(); + const backlogPlanDetails = getRunningDetails(); + + if (backlogPlanStatus.isRunning && backlogPlanDetails) { + runningAgents.push({ + featureId: `backlog-plan:${backlogPlanDetails.projectPath}`, + projectPath: backlogPlanDetails.projectPath, + projectName: path.basename(backlogPlanDetails.projectPath), + isAutoMode: false, + title: 'Backlog plan', + description: backlogPlanDetails.prompt, + }); + } + + // Add spec/feature generation tasks + const specGenerations = getAllRunningGenerations(); + for (const generation of specGenerations) { + let title: string; + let description: string; + + switch (generation.type) { + case 'feature_generation': + title = 'Generating features from spec'; + description = 'Creating features from the project specification'; + break; + case 'sync': + title = 'Syncing spec with code'; + description = 'Updating spec from codebase and completed features'; + break; + default: + title = 'Regenerating spec'; + description = 'Analyzing project and generating specification'; + } + + runningAgents.push({ + featureId: `spec-generation:${generation.projectPath}`, + projectPath: generation.projectPath, + projectName: path.basename(generation.projectPath), + isAutoMode: false, + title, + description, + }); + } + + res.json({ + success: true, + runningAgents, + totalCount: runningAgents.length, + }); + } catch (error) { + logError(error, 'Get running agents failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/common.ts b/jules_branch/apps/server/src/routes/sessions/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d1df9b68e6c5b8daafa47af2d2ac64a1ae0c1f1 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for sessions routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Sessions'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/sessions/index.ts b/jules_branch/apps/server/src/routes/sessions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e625671f3a963a7b18bbc20847f01043193952b8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/index.ts @@ -0,0 +1,25 @@ +/** + * Sessions routes - HTTP API for session management + */ + +import { Router } from 'express'; +import { AgentService } from '../../services/agent-service.js'; +import { createIndexHandler } from './routes/index.js'; +import { createCreateHandler } from './routes/create.js'; +import { createUpdateHandler } from './routes/update.js'; +import { createArchiveHandler } from './routes/archive.js'; +import { createUnarchiveHandler } from './routes/unarchive.js'; +import { createDeleteHandler } from './routes/delete.js'; + +export function createSessionsRoutes(agentService: AgentService): Router { + const router = Router(); + + router.get('/', createIndexHandler(agentService)); + router.post('/', createCreateHandler(agentService)); + router.put('/:sessionId', createUpdateHandler(agentService)); + router.post('/:sessionId/archive', createArchiveHandler(agentService)); + router.post('/:sessionId/unarchive', createUnarchiveHandler(agentService)); + router.delete('/:sessionId', createDeleteHandler(agentService)); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/archive.ts b/jules_branch/apps/server/src/routes/sessions/routes/archive.ts new file mode 100644 index 0000000000000000000000000000000000000000..3407e5cd3386a2af4dbf397f4bc9792940bf3d94 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/archive.ts @@ -0,0 +1,26 @@ +/** + * POST /:sessionId/archive endpoint - Archive a session + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createArchiveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + const success = await agentService.archiveSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Archive session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/create.ts b/jules_branch/apps/server/src/routes/sessions/routes/create.ts new file mode 100644 index 0000000000000000000000000000000000000000..2917168cae3d462df8c8bd728526f57dfbc53de6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/create.ts @@ -0,0 +1,31 @@ +/** + * POST / endpoint - Create a new session + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createCreateHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { name, projectPath, workingDirectory, model } = req.body as { + name: string; + projectPath?: string; + workingDirectory?: string; + model?: string; + }; + + if (!name) { + res.status(400).json({ success: false, error: 'name is required' }); + return; + } + + const session = await agentService.createSession(name, projectPath, workingDirectory, model); + res.json({ success: true, session }); + } catch (error) { + logError(error, 'Create session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/delete.ts b/jules_branch/apps/server/src/routes/sessions/routes/delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..91bbc39d2182504f97696afcca19999ec082d0c0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/delete.ts @@ -0,0 +1,26 @@ +/** + * DELETE /:sessionId endpoint - Delete a session + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDeleteHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + const success = await agentService.deleteSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Delete session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/index.ts b/jules_branch/apps/server/src/routes/sessions/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f82bcabfde57de292c39e512be6198e5a30eafb --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/index.ts @@ -0,0 +1,43 @@ +/** + * GET / endpoint - List all sessions + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createIndexHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const includeArchived = req.query.includeArchived === 'true'; + const sessionsRaw = await agentService.listSessions(includeArchived); + + // Transform to match frontend SessionListItem interface + const sessions = await Promise.all( + sessionsRaw.map(async (s) => { + const messages = await agentService.loadSession(s.id); + const lastMessage = messages[messages.length - 1]; + const preview = lastMessage?.content?.slice(0, 100) || ''; + + return { + id: s.id, + name: s.name, + projectPath: s.projectPath || s.workingDirectory, + workingDirectory: s.workingDirectory, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + isArchived: s.archived || false, + tags: s.tags || [], + messageCount: messages.length, + preview, + }; + }) + ); + + res.json({ success: true, sessions }); + } catch (error) { + logError(error, 'List sessions failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/unarchive.ts b/jules_branch/apps/server/src/routes/sessions/routes/unarchive.ts new file mode 100644 index 0000000000000000000000000000000000000000..638d315097875d757f2b58753362ffe267bd7287 --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/unarchive.ts @@ -0,0 +1,26 @@ +/** + * POST /:sessionId/unarchive endpoint - Unarchive a session + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createUnarchiveHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + const success = await agentService.unarchiveSession(sessionId); + + if (!success) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + res.json({ success: true }); + } catch (error) { + logError(error, 'Unarchive session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/sessions/routes/update.ts b/jules_branch/apps/server/src/routes/sessions/routes/update.ts new file mode 100644 index 0000000000000000000000000000000000000000..7705fa221e9c8666c497768405d3f4c74ca37eff --- /dev/null +++ b/jules_branch/apps/server/src/routes/sessions/routes/update.ts @@ -0,0 +1,35 @@ +/** + * PUT /:sessionId endpoint - Update a session + */ + +import type { Request, Response } from 'express'; +import { AgentService } from '../../../services/agent-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createUpdateHandler(agentService: AgentService) { + return async (req: Request, res: Response): Promise => { + try { + const { sessionId } = req.params; + const { name, tags, model } = req.body as { + name?: string; + tags?: string[]; + model?: string; + }; + + const session = await agentService.updateSession(sessionId, { + name, + tags, + model, + }); + if (!session) { + res.status(404).json({ success: false, error: 'Session not found' }); + return; + } + + res.json({ success: true, session }); + } catch (error) { + logError(error, 'Update session failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/common.ts b/jules_branch/apps/server/src/routes/settings/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8201bfd0495c2c16d37631c064086eb89449408 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/common.ts @@ -0,0 +1,26 @@ +/** + * Common utilities for settings routes + * + * Provides logger and error handling utilities shared across all settings endpoints. + * Re-exports error handling helpers from the parent routes module. + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +/** Logger instance for settings-related operations */ +export const logger = createLogger('Settings'); + +/** + * Extract user-friendly error message from error objects + * + * Re-exported from parent routes common module for consistency. + */ +export { getErrorMessageShared as getErrorMessage }; + +/** + * Log error with automatic logger binding + * + * Convenience function for logging errors with the Settings logger. + */ +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/settings/index.ts b/jules_branch/apps/server/src/routes/settings/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f6f6d408357b698ee4993ccb2ce12bae7ca4f1e --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/index.ts @@ -0,0 +1,81 @@ +/** + * Settings routes - HTTP API for persistent file-based settings + * + * Provides endpoints for: + * - Status checking (migration readiness) + * - Global settings CRUD + * - Credentials management + * - Project-specific settings + * - localStorage to file migration + * + * All endpoints use handler factories that receive the SettingsService instance. + * Mounted at /api/settings in the main server. + */ + +import { Router } from 'express'; +import type { SettingsService } from '../../services/settings-service.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { createGetGlobalHandler } from './routes/get-global.js'; +import { createUpdateGlobalHandler } from './routes/update-global.js'; +import { createGetCredentialsHandler } from './routes/get-credentials.js'; +import { createUpdateCredentialsHandler } from './routes/update-credentials.js'; +import { createGetProjectHandler } from './routes/get-project.js'; +import { createUpdateProjectHandler } from './routes/update-project.js'; +import { createMigrateHandler } from './routes/migrate.js'; +import { createStatusHandler } from './routes/status.js'; +import { createDiscoverAgentsHandler } from './routes/discover-agents.js'; + +/** + * Create settings router with all endpoints + * + * Registers handlers for all settings-related HTTP endpoints. + * Each handler is created with the provided SettingsService instance. + * + * Endpoints: + * - GET /status - Check migration status and data availability + * - GET /global - Get global settings + * - PUT /global - Update global settings + * - GET /credentials - Get masked credentials (safe for UI) + * - PUT /credentials - Update API keys + * - POST /project - Get project settings (requires projectPath in body) + * - PUT /project - Update project settings + * - POST /migrate - Migrate settings from localStorage + * - POST /agents/discover - Discover filesystem agents from .claude/agents/ (read-only) + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express Router configured with all settings endpoints + */ +export function createSettingsRoutes(settingsService: SettingsService): Router { + const router = Router(); + + // Status endpoint (check if migration needed) + router.get('/status', createStatusHandler(settingsService)); + + // Global settings + router.get('/global', createGetGlobalHandler(settingsService)); + router.put('/global', createUpdateGlobalHandler(settingsService)); + + // Credentials (separate for security) + router.get('/credentials', createGetCredentialsHandler(settingsService)); + router.put('/credentials', createUpdateCredentialsHandler(settingsService)); + + // Project settings + router.post( + '/project', + validatePathParams('projectPath'), + createGetProjectHandler(settingsService) + ); + router.put( + '/project', + validatePathParams('projectPath'), + createUpdateProjectHandler(settingsService) + ); + + // Migration from localStorage + router.post('/migrate', createMigrateHandler(settingsService)); + + // Filesystem agents discovery (read-only) + router.post('/agents/discover', createDiscoverAgentsHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/discover-agents.ts b/jules_branch/apps/server/src/routes/settings/routes/discover-agents.ts new file mode 100644 index 0000000000000000000000000000000000000000..aee4a2a2f28315ae76d3d86a4c8339fd6c764a4b --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/discover-agents.ts @@ -0,0 +1,61 @@ +/** + * Discover Agents Route - Returns filesystem-based agents from .claude/agents/ + * + * Scans both user-level (~/.claude/agents/) and project-level (.claude/agents/) + * directories for AGENT.md files and returns parsed agent definitions. + */ + +import type { Request, Response } from 'express'; +import { discoverFilesystemAgents } from '../../../lib/agent-discovery.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('DiscoverAgentsRoute'); + +interface DiscoverAgentsRequest { + projectPath?: string; + sources?: Array<'user' | 'project'>; +} + +/** + * Create handler for discovering filesystem agents + * + * POST /api/settings/agents/discover + * Body: { projectPath?: string, sources?: ['user', 'project'] } + * + * Returns: + * { + * success: true, + * agents: Array<{ + * name: string, + * definition: AgentDefinition, + * source: 'user' | 'project', + * filePath: string + * }> + * } + */ +export function createDiscoverAgentsHandler() { + return async (req: Request, res: Response) => { + try { + const { projectPath, sources = ['user', 'project'] } = req.body as DiscoverAgentsRequest; + + logger.info( + `Discovering agents from sources: ${sources.join(', ')}${projectPath ? ` (project: ${projectPath})` : ''}` + ); + + const agents = await discoverFilesystemAgents(projectPath, sources); + + logger.info(`Discovered ${agents.length} filesystem agents`); + + res.json({ + success: true, + agents, + }); + } catch (error) { + logger.error('Failed to discover agents:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to discover agents', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/get-credentials.ts b/jules_branch/apps/server/src/routes/settings/routes/get-credentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..140ccce497fe360ddebd392eb3ad1c7379f34974 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/get-credentials.ts @@ -0,0 +1,35 @@ +/** + * GET /api/settings/credentials - Get API key status (masked for security) + * + * Returns masked credentials showing which providers have keys configured. + * Each provider shows: `{ configured: boolean, masked: string }` + * Masked shows first 4 and last 4 characters for verification. + * + * Response: `{ "success": true, "credentials": { anthropic, google, openai } }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for GET /api/settings/credentials + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createGetCredentialsHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const credentials = await settingsService.getMaskedCredentials(); + + res.json({ + success: true, + credentials, + }); + } catch (error) { + logError(error, 'Get credentials failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/get-global.ts b/jules_branch/apps/server/src/routes/settings/routes/get-global.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa432b25218a954eb6d508c73d134209cd2e485a --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/get-global.ts @@ -0,0 +1,34 @@ +/** + * GET /api/settings/global - Retrieve global user settings + * + * Returns the complete GlobalSettings object with all user preferences, + * keyboard shortcuts, AI profiles, and project history. + * + * Response: `{ "success": true, "settings": GlobalSettings }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for GET /api/settings/global + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createGetGlobalHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const settings = await settingsService.getGlobalSettings(); + + res.json({ + success: true, + settings, + }); + } catch (error) { + logError(error, 'Get global settings failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/get-project.ts b/jules_branch/apps/server/src/routes/settings/routes/get-project.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cd449a2aa299181df7379eed87fa3c26d9d97d1 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/get-project.ts @@ -0,0 +1,45 @@ +/** + * POST /api/settings/project - Get project-specific settings + * + * Retrieves settings overrides for a specific project. Uses POST because + * projectPath may contain special characters that don't work well in URLs. + * + * Request body: `{ projectPath: string }` + * Response: `{ "success": true, "settings": ProjectSettings }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for POST /api/settings/project + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createGetProjectHandler(settingsService: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath?: string }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + const settings = await settingsService.getProjectSettings(projectPath); + + res.json({ + success: true, + settings, + }); + } catch (error) { + logError(error, 'Get project settings failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/migrate.ts b/jules_branch/apps/server/src/routes/settings/routes/migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..02145d6677b02052dc1d6f1d0a24eff37adc5f15 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/migrate.ts @@ -0,0 +1,86 @@ +/** + * POST /api/settings/migrate - Migrate settings from localStorage to file storage + * + * Called during onboarding when UI detects localStorage data but no settings files. + * Extracts settings from various localStorage keys and writes to new file structure. + * Collects errors but continues on partial failures (graceful degradation). + * + * Request body: + * ```json + * { + * "data": { + * "automaker-storage"?: string, + * "automaker-setup"?: string, + * "worktree-panel-collapsed"?: string, + * "file-browser-recent-folders"?: string, + * "automaker:lastProjectDir"?: string + * } + * } + * ``` + * + * Response: + * ```json + * { + * "success": boolean, + * "migratedGlobalSettings": boolean, + * "migratedCredentials": boolean, + * "migratedProjectCount": number, + * "errors": string[] + * } + * ``` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError, logger } from '../common.js'; + +/** + * Create handler factory for POST /api/settings/migrate + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createMigrateHandler(settingsService: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { data } = req.body as { + data?: { + 'automaker-storage'?: string; + 'automaker-setup'?: string; + 'worktree-panel-collapsed'?: string; + 'file-browser-recent-folders'?: string; + 'automaker:lastProjectDir'?: string; + }; + }; + + if (!data || typeof data !== 'object') { + res.status(400).json({ + success: false, + error: 'data object is required containing localStorage data', + }); + return; + } + + logger.info('Starting settings migration from localStorage'); + + const result = await settingsService.migrateFromLocalStorage(data); + + if (result.success) { + logger.info(`Migration successful: ${result.migratedProjectCount} projects migrated`); + } else { + logger.warn(`Migration completed with errors: ${result.errors.join(', ')}`); + } + + res.json({ + success: result.success, + migratedGlobalSettings: result.migratedGlobalSettings, + migratedCredentials: result.migratedCredentials, + migratedProjectCount: result.migratedProjectCount, + errors: result.errors, + }); + } catch (error) { + logError(error, 'Migration failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/status.ts b/jules_branch/apps/server/src/routes/settings/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..04f016437317d65f05fe3351083abc886c9318c3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/status.ts @@ -0,0 +1,47 @@ +/** + * GET /api/settings/status - Get settings migration and availability status + * + * Checks which settings files exist to determine if migration from localStorage + * is needed. Used by UI during onboarding to decide whether to show migration flow. + * + * Response: + * ```json + * { + * "success": true, + * "hasGlobalSettings": boolean, + * "hasCredentials": boolean, + * "dataDir": string, + * "needsMigration": boolean + * } + * ``` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for GET /api/settings/status + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createStatusHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const hasGlobalSettings = await settingsService.hasGlobalSettings(); + const hasCredentials = await settingsService.hasCredentials(); + + res.json({ + success: true, + hasGlobalSettings, + hasCredentials, + dataDir: settingsService.getDataDir(), + needsMigration: !hasGlobalSettings, + }); + } catch (error) { + logError(error, 'Get settings status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/update-credentials.ts b/jules_branch/apps/server/src/routes/settings/routes/update-credentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b4158306fa02d8cb26d910ebe2b8800b91b13f9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/update-credentials.ts @@ -0,0 +1,49 @@ +/** + * PUT /api/settings/credentials - Update API credentials + * + * Updates API keys for supported providers. Partial updates supported. + * Returns masked credentials for verification without exposing full keys. + * + * Request body: `Partial` (usually just apiKeys) + * Response: `{ "success": true, "credentials": { anthropic } }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import type { Credentials } from '../../../types/settings.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for PUT /api/settings/credentials + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createUpdateCredentialsHandler(settingsService: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const updates = req.body as Partial; + + if (!updates || typeof updates !== 'object') { + res.status(400).json({ + success: false, + error: 'Invalid request body - expected credentials object', + }); + return; + } + + await settingsService.updateCredentials(updates); + + // Return masked credentials for confirmation + const masked = await settingsService.getMaskedCredentials(); + + res.json({ + success: true, + credentials: masked, + }); + } catch (error) { + logError(error, 'Update credentials failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/update-global.ts b/jules_branch/apps/server/src/routes/settings/routes/update-global.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bc1c2fa2f3778eb0ee09caec7c255e12fad0a48 --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/update-global.ts @@ -0,0 +1,127 @@ +/** + * PUT /api/settings/global - Update global user settings + * + * Accepts partial GlobalSettings update. Fields provided are merged into + * existing settings (not replaced). Returns updated settings. + * + * Request body: `Partial` + * Response: `{ "success": true, "settings": GlobalSettings }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +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 + */ +const LOG_LEVEL_MAP: Record = { + error: LogLevel.ERROR, + warn: LogLevel.WARN, + info: LogLevel.INFO, + debug: LogLevel.DEBUG, +}; + +/** + * Create handler factory for PUT /api/settings/global + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createUpdateGlobalHandler(settingsService: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const updates = req.body as Partial; + + if (!updates || typeof updates !== 'object') { + res.status(400).json({ + success: false, + error: 'Invalid request body - expected settings object', + }); + return; + } + + // Minimal debug logging to help diagnose accidental wipes. + const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined; + const trashedLen = Array.isArray(updates.trashedProjects) + ? updates.trashedProjects.length + : undefined; + logger.info( + `[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${ + updates.theme ?? 'n/a' + }, localStorageMigrated=${updates.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( + '[SERVER_SETTINGS_UPDATE] Update complete, projects count:', + 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]; + if (level !== undefined) { + setLogLevel(level); + logger.info(`Server log level changed to: ${updates.serverLogLevel}`); + } + } + + // Apply request logging setting if it was updated + if ('enableRequestLogging' in updates && typeof updates.enableRequestLogging === 'boolean') { + setRequestLoggingEnabled(updates.enableRequestLogging); + logger.info( + `HTTP request logging ${updates.enableRequestLogging ? 'enabled' : 'disabled'}` + ); + } + + res.json({ + success: true, + settings, + }); + } catch (error) { + logError(error, 'Update global settings failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/settings/routes/update-project.ts b/jules_branch/apps/server/src/routes/settings/routes/update-project.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5f639f473a8568b8f4c073ad99fb6c2725e1d1a --- /dev/null +++ b/jules_branch/apps/server/src/routes/settings/routes/update-project.ts @@ -0,0 +1,57 @@ +/** + * PUT /api/settings/project - Update project-specific settings + * + * Updates settings for a specific project. Partial updates supported. + * Project settings override global settings when present. + * + * Request body: `{ projectPath: string, updates: Partial }` + * Response: `{ "success": true, "settings": ProjectSettings }` + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import type { ProjectSettings } from '../../../types/settings.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Create handler factory for PUT /api/settings/project + * + * @param settingsService - Instance of SettingsService for file I/O + * @returns Express request handler + */ +export function createUpdateProjectHandler(settingsService: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, updates } = req.body as { + projectPath?: string; + updates?: Partial; + }; + + if (!projectPath || typeof projectPath !== 'string') { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!updates || typeof updates !== 'object') { + res.status(400).json({ + success: false, + error: 'updates object is required', + }); + return; + } + + const settings = await settingsService.updateProjectSettings(projectPath, updates); + + res.json({ + success: true, + settings, + }); + } catch (error) { + logError(error, 'Update project settings failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/common.ts b/jules_branch/apps/server/src/routes/setup/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..f03d91751c4a571c06cece292d5290ef7c0f9daa --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/common.ts @@ -0,0 +1,59 @@ +/** + * Common utilities and state for setup routes + */ + +import { createLogger } from '@automaker/utils'; +import path from 'path'; +import { secureFs } from '@automaker/platform'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Setup'); + +// Storage for API keys (in-memory cache) - private +const apiKeys: Record = {}; + +/** + * Get an API key for a provider + */ +export function getApiKey(provider: string): string | undefined { + return apiKeys[provider]; +} + +/** + * Set an API key for a provider + */ +export function setApiKey(provider: string, key: string): void { + apiKeys[provider] = key; +} + +/** + * Get all API keys (for read-only access) + */ +export function getAllApiKeys(): Record { + return { ...apiKeys }; +} + +/** + * Helper to persist API keys to .env file + * Uses centralized secureFs.writeEnvKey for path validation + */ +export async function persistApiKeyToEnv(key: string, value: string): Promise { + const envPath = path.join(process.cwd(), '.env'); + + try { + await secureFs.writeEnvKey(envPath, key, value); + logger.info(`[Setup] Persisted ${key} to .env file`); + } catch (error) { + logger.error(`[Setup] Failed to persist ${key} to .env:`, error); + throw error; + } +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); + +/** + * Marker file used to indicate a provider has been explicitly disconnected by user + */ +export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected'; diff --git a/jules_branch/apps/server/src/routes/setup/get-claude-status.ts b/jules_branch/apps/server/src/routes/setup/get-claude-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a3ccaf626db874b7ab245e01b098634565dbfa3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/get-claude-status.ts @@ -0,0 +1,182 @@ +/** + * Business logic for getting Claude CLI status + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform'; +import { getApiKey } from './common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const execAsync = promisify(exec); + +const DISCONNECTED_MARKER_FILE = '.claude-disconnected'; + +function isDisconnectedFromApp(): boolean { + try { + // Check if we're in a project directory + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + return fs.existsSync(markerPath); + } catch { + return false; + } +} + +export async function getClaudeStatus() { + let installed = false; + let version = ''; + let cliPath = ''; + let method = 'none'; + + const isWindows = process.platform === 'win32'; + + // Try to find Claude CLI using platform-specific command + try { + // Use 'where' on Windows, 'which' on Unix-like systems + const findCommand = isWindows ? 'where claude' : 'which claude'; + const { stdout } = await execAsync(findCommand); + // 'where' on Windows can return multiple paths - take the first one + cliPath = stdout.trim().split(/\r?\n/)[0]; + installed = true; + method = 'path'; + + // Get version + try { + const { stdout: versionOut } = await execAsync('claude --version'); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + } catch { + // Not in PATH, try common locations from centralized system paths + const commonPaths = getClaudeCliPaths(); + + for (const p of commonPaths) { + try { + if (await systemPathAccess(p)) { + cliPath = p; + installed = true; + method = 'local'; + + // Get version from this path + try { + const { stdout: versionOut } = await execAsync(`"${p}" --version`); + version = versionOut.trim(); + } catch { + // Version command might not be available + } + break; + } + } catch { + // Not found at this path + } + } + } + + // Check if user has manually disconnected from the app + if (isDisconnectedFromApp()) { + return { + status: installed ? 'installed' : 'not_installed', + installed, + method, + version, + path: cliPath, + auth: { + authenticated: false, + method: 'none', + hasCredentialsFile: false, + hasToken: false, + hasStoredOAuthToken: false, + hasStoredApiKey: false, + hasEnvApiKey: false, + oauthTokenValid: false, + apiKeyValid: false, + hasCliAuth: false, + hasRecentActivity: false, + }, + }; + } + + // Check authentication - detect all possible auth methods + // Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth + // apiKeys.anthropic stores direct API keys for pay-per-use + const auth = { + authenticated: false, + method: 'none' as string, + hasCredentialsFile: false, + hasToken: false, + hasStoredOAuthToken: !!getApiKey('anthropic_oauth_token'), + hasStoredApiKey: !!getApiKey('anthropic'), + hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY, + // Additional fields for detailed status + oauthTokenValid: false, + apiKeyValid: false, + hasCliAuth: false, + hasRecentActivity: false, + }; + + // Use centralized system paths to check Claude authentication indicators + const indicators = await getClaudeAuthIndicators(); + + // Check for recent activity (indicates working authentication) + if (indicators.hasStatsCacheWithActivity) { + auth.hasRecentActivity = true; + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } + + // Check for settings + sessions (indicates CLI is set up) + if (!auth.hasCliAuth && indicators.hasSettingsFile && indicators.hasProjectsSessions) { + auth.hasCliAuth = true; + auth.authenticated = true; + auth.method = 'cli_authenticated'; + } + + // Check credentials file + if (indicators.hasCredentialsFile && indicators.credentials) { + auth.hasCredentialsFile = true; + if (indicators.credentials.hasOAuthToken) { + auth.hasStoredOAuthToken = true; + auth.oauthTokenValid = true; + auth.authenticated = true; + auth.method = 'oauth_token'; + } else if (indicators.credentials.hasApiKey) { + auth.apiKeyValid = true; + auth.authenticated = true; + auth.method = 'api_key'; + } + } + + // Environment variables override stored credentials (higher priority) + if (auth.hasEnvApiKey) { + auth.authenticated = true; + auth.apiKeyValid = true; + auth.method = 'api_key_env'; + } + + // In-memory stored OAuth token (from setup wizard - subscription auth) + if (!auth.authenticated && getApiKey('anthropic_oauth_token')) { + auth.authenticated = true; + auth.oauthTokenValid = true; + auth.method = 'oauth_token'; + } + + // In-memory stored API key (from settings UI - pay-per-use) + if (!auth.authenticated && getApiKey('anthropic')) { + auth.authenticated = true; + auth.apiKeyValid = true; + auth.method = 'api_key'; + } + + return { + status: installed ? 'installed' : 'not_installed', + installed, + method, + version, + path: cliPath, + auth, + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/index.ts b/jules_branch/apps/server/src/routes/setup/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0d32933567ccbca8b17b36cdcc655e58dc5787c --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/index.ts @@ -0,0 +1,118 @@ +/** + * Setup routes - HTTP API for CLI detection, API keys, and platform info + */ + +import { Router } from 'express'; +import { createClaudeStatusHandler } from './routes/claude-status.js'; +import { createInstallClaudeHandler } from './routes/install-claude.js'; +import { createAuthClaudeHandler } from './routes/auth-claude.js'; +import { createStoreApiKeyHandler } from './routes/store-api-key.js'; +import { createDeleteApiKeyHandler } from './routes/delete-api-key.js'; +import { createApiKeysHandler } from './routes/api-keys.js'; +import { createPlatformHandler } from './routes/platform.js'; +import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js'; +import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js'; +import { createGhStatusHandler } from './routes/gh-status.js'; +import { createCursorStatusHandler } from './routes/cursor-status.js'; +import { createCodexStatusHandler } from './routes/codex-status.js'; +import { createInstallCodexHandler } from './routes/install-codex.js'; +import { createAuthCodexHandler } from './routes/auth-codex.js'; +import { createAuthCursorHandler } from './routes/auth-cursor.js'; +import { createDeauthClaudeHandler } from './routes/deauth-claude.js'; +import { createDeauthCodexHandler } from './routes/deauth-codex.js'; +import { createDeauthCursorHandler } from './routes/deauth-cursor.js'; +import { createAuthOpencodeHandler } from './routes/auth-opencode.js'; +import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js'; +import { createOpencodeStatusHandler } from './routes/opencode-status.js'; +import { createGeminiStatusHandler } from './routes/gemini-status.js'; +import { createAuthGeminiHandler } from './routes/auth-gemini.js'; +import { createDeauthGeminiHandler } from './routes/deauth-gemini.js'; +import { createCopilotStatusHandler } from './routes/copilot-status.js'; +import { createAuthCopilotHandler } from './routes/auth-copilot.js'; +import { createDeauthCopilotHandler } from './routes/deauth-copilot.js'; +import { + createGetCopilotModelsHandler, + createRefreshCopilotModelsHandler, + createClearCopilotCacheHandler, +} from './routes/copilot-models.js'; +import { + createGetOpencodeModelsHandler, + createRefreshOpencodeModelsHandler, + createGetOpencodeProvidersHandler, + createClearOpencodeCacheHandler, +} from './routes/opencode-models.js'; +import { + createGetCursorConfigHandler, + createSetCursorDefaultModelHandler, + createSetCursorModelsHandler, + createGetCursorPermissionsHandler, + createApplyPermissionProfileHandler, + createSetCustomPermissionsHandler, + createDeleteProjectPermissionsHandler, + createGetExampleConfigHandler, +} from './routes/cursor-config.js'; + +export function createSetupRoutes(): Router { + const router = Router(); + + router.get('/claude-status', createClaudeStatusHandler()); + router.post('/install-claude', createInstallClaudeHandler()); + router.post('/auth-claude', createAuthClaudeHandler()); + router.post('/deauth-claude', createDeauthClaudeHandler()); + router.post('/store-api-key', createStoreApiKeyHandler()); + router.post('/delete-api-key', createDeleteApiKeyHandler()); + router.get('/api-keys', createApiKeysHandler()); + router.get('/platform', createPlatformHandler()); + router.post('/verify-claude-auth', createVerifyClaudeAuthHandler()); + router.post('/verify-codex-auth', createVerifyCodexAuthHandler()); + router.get('/gh-status', createGhStatusHandler()); + + // Cursor CLI routes + router.get('/cursor-status', createCursorStatusHandler()); + router.post('/auth-cursor', createAuthCursorHandler()); + router.post('/deauth-cursor', createDeauthCursorHandler()); + + // Codex CLI routes + router.get('/codex-status', createCodexStatusHandler()); + router.post('/install-codex', createInstallCodexHandler()); + router.post('/auth-codex', createAuthCodexHandler()); + router.post('/deauth-codex', createDeauthCodexHandler()); + + // OpenCode CLI routes + router.get('/opencode-status', createOpencodeStatusHandler()); + router.post('/auth-opencode', createAuthOpencodeHandler()); + router.post('/deauth-opencode', createDeauthOpencodeHandler()); + + // Gemini CLI routes + router.get('/gemini-status', createGeminiStatusHandler()); + router.post('/auth-gemini', createAuthGeminiHandler()); + router.post('/deauth-gemini', createDeauthGeminiHandler()); + + // Copilot CLI routes + router.get('/copilot-status', createCopilotStatusHandler()); + router.post('/auth-copilot', createAuthCopilotHandler()); + router.post('/deauth-copilot', createDeauthCopilotHandler()); + + // Copilot Dynamic Model Discovery routes + router.get('/copilot/models', createGetCopilotModelsHandler()); + router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler()); + router.post('/copilot/cache/clear', createClearCopilotCacheHandler()); + + // OpenCode Dynamic Model Discovery routes + router.get('/opencode/models', createGetOpencodeModelsHandler()); + router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); + router.get('/opencode/providers', createGetOpencodeProvidersHandler()); + router.post('/opencode/cache/clear', createClearOpencodeCacheHandler()); + router.get('/cursor-config', createGetCursorConfigHandler()); + router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler()); + router.post('/cursor-config/models', createSetCursorModelsHandler()); + + // Cursor CLI Permissions routes + router.get('/cursor-permissions', createGetCursorPermissionsHandler()); + router.post('/cursor-permissions/profile', createApplyPermissionProfileHandler()); + router.post('/cursor-permissions/custom', createSetCustomPermissionsHandler()); + router.delete('/cursor-permissions', createDeleteProjectPermissionsHandler()); + router.get('/cursor-permissions/example', createGetExampleConfigHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/api-keys.ts b/jules_branch/apps/server/src/routes/setup/routes/api-keys.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec870e7b744fdd3508822a17188b6d537e2944b4 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/api-keys.ts @@ -0,0 +1,22 @@ +/** + * GET /api-keys endpoint - Get API keys status + */ + +import type { Request, Response } from 'express'; +import { getApiKey, getErrorMessage, logError } from '../common.js'; + +export function createApiKeysHandler() { + return async (_req: Request, res: Response): Promise => { + try { + res.json({ + success: true, + hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasGoogleKey: !!getApiKey('google'), + hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY, + }); + } catch (error) { + logError(error, 'Get API keys failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-claude.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-claude.ts new file mode 100644 index 0000000000000000000000000000000000000000..9eac09895d9c3f5aba7fc62f0f54be3bdad1f779 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-claude.ts @@ -0,0 +1,53 @@ +/** + * POST /auth-claude endpoint - Auth Claude + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createAuthClaudeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Remove the disconnected marker file to reconnect the app to the CLI + const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected'); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); + } + + // Check if CLI is already authenticated by checking auth indicators + const { getClaudeAuthIndicators } = await import('@automaker/platform'); + const indicators = await getClaudeAuthIndicators(); + const isAlreadyAuthenticated = + indicators.hasStatsCacheWithActivity || + (indicators.hasSettingsFile && indicators.hasProjectsSessions) || + indicators.hasCredentialsFile; + + if (isAlreadyAuthenticated) { + // CLI is already authenticated, just reconnect + res.json({ + success: true, + message: 'Claude CLI is now linked with the app', + wasAlreadyAuthenticated: true, + }); + } else { + // CLI needs authentication - but we can't run claude login here + // because it requires browser OAuth. Just reconnect and let the user authenticate if needed. + res.json({ + success: true, + message: + 'Claude CLI is now linked with the app. If prompted, please authenticate with "claude login" in your terminal.', + requiresManualAuth: true, + }); + } + } catch (error) { + logError(error, 'Auth Claude failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to link Claude CLI with the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-codex.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-codex.ts new file mode 100644 index 0000000000000000000000000000000000000000..79857bd8d4bbad7c2a177f0a544a543121210b50 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-codex.ts @@ -0,0 +1,50 @@ +/** + * POST /auth-codex endpoint - Authenticate Codex CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createAuthCodexHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Remove the disconnected marker file to reconnect the app to the CLI + const markerPath = path.join(process.cwd(), '.automaker', '.codex-disconnected'); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); + } + + // Use the same detection logic as the Codex provider + const { getCodexAuthIndicators } = await import('@automaker/platform'); + const indicators = await getCodexAuthIndicators(); + + const isAlreadyAuthenticated = + indicators.hasApiKey || indicators.hasAuthFile || indicators.hasOAuthToken; + + if (isAlreadyAuthenticated) { + // Already has authentication, just reconnect + res.json({ + success: true, + message: 'Codex CLI is now linked with the app', + wasAlreadyAuthenticated: true, + }); + } else { + res.json({ + success: true, + message: + 'Codex CLI is now linked with the app. If prompted, please authenticate with "codex login" in your terminal.', + requiresManualAuth: true, + }); + } + } catch (error) { + logError(error, 'Auth Codex failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to link Codex CLI with the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-copilot.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-copilot.ts new file mode 100644 index 0000000000000000000000000000000000000000..579fcc4f2096cb3e126eaba1228985967fb1ab27 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-copilot.ts @@ -0,0 +1,30 @@ +/** + * POST /auth-copilot endpoint - Connect Copilot CLI to the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { connectCopilot } from '../../../services/copilot-connection-service.js'; + +/** + * Creates handler for POST /api/setup/auth-copilot + * Removes the disconnection marker to allow Copilot CLI to be used + */ +export function createAuthCopilotHandler() { + return async (_req: Request, res: Response): Promise => { + try { + await connectCopilot(); + + res.json({ + success: true, + message: 'Copilot CLI connected to app', + }); + } catch (error) { + logError(error, 'Auth Copilot failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-cursor.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-cursor.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbd6339c80502ccdd6ba9f23f2ce0f04d9579551 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-cursor.ts @@ -0,0 +1,73 @@ +/** + * POST /auth-cursor endpoint - Authenticate Cursor CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; +import os from 'os'; + +export function createAuthCursorHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Remove the disconnected marker file to reconnect the app to the CLI + const markerPath = path.join(process.cwd(), '.automaker', '.cursor-disconnected'); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); + } + + // Check if Cursor is already authenticated using the same logic as CursorProvider + const isAlreadyAuthenticated = (): boolean => { + // Check for API key in environment + if (process.env.CURSOR_API_KEY) { + return true; + } + + // Check for credentials files + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + ]; + + for (const credPath of credentialPaths) { + if (fs.existsSync(credPath)) { + try { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken || creds.token) { + return true; + } + } catch { + // Invalid credentials file, continue checking + } + } + } + + return false; + }; + + if (isAlreadyAuthenticated()) { + res.json({ + success: true, + message: 'Cursor CLI is now linked with the app', + wasAlreadyAuthenticated: true, + }); + } else { + res.json({ + success: true, + message: + 'Cursor CLI is now linked with the app. If prompted, please authenticate with "cursor auth" in your terminal.', + requiresManualAuth: true, + }); + } + } catch (error) { + logError(error, 'Auth Cursor failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to link Cursor CLI with the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-gemini.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-gemini.ts new file mode 100644 index 0000000000000000000000000000000000000000..5faad8db37bf79752c0629650dbb314de0074272 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-gemini.ts @@ -0,0 +1,42 @@ +/** + * POST /auth-gemini endpoint - Connect Gemini CLI to the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +/** + * Creates handler for POST /api/setup/auth-gemini + * Removes the disconnection marker to allow Gemini CLI to be used + */ +export function createAuthGeminiHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const projectRoot = process.cwd(); + const automakerDir = path.join(projectRoot, '.automaker'); + const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE); + + // Remove the disconnection marker if it exists + try { + await fs.unlink(markerPath); + } catch { + // File doesn't exist, nothing to remove + } + + res.json({ + success: true, + message: 'Gemini CLI connected to app', + }); + } catch (error) { + logError(error, 'Auth Gemini failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/auth-opencode.ts b/jules_branch/apps/server/src/routes/setup/routes/auth-opencode.ts new file mode 100644 index 0000000000000000000000000000000000000000..dce314bf30483461b63851de031076b5a0bb42ba --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/auth-opencode.ts @@ -0,0 +1,47 @@ +/** + * POST /auth-opencode endpoint - Authenticate OpenCode CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createAuthOpencodeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Remove the disconnected marker file to reconnect the app to the CLI + const markerPath = path.join(process.cwd(), '.automaker', '.opencode-disconnected'); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); + } + + // Check if OpenCode is already authenticated + // For OpenCode, check if there's an auth token or API key + const hasApiKey = !!process.env.OPENCODE_API_KEY; + + if (hasApiKey) { + // Already has authentication, just reconnect + res.json({ + success: true, + message: 'OpenCode CLI is now linked with the app', + wasAlreadyAuthenticated: true, + }); + } else { + res.json({ + success: true, + message: + 'OpenCode CLI is now linked with the app. If prompted, please authenticate with OpenCode.', + requiresManualAuth: true, + }); + } + } catch (error) { + logError(error, 'Auth OpenCode failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to link OpenCode CLI with the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/claude-status.ts b/jules_branch/apps/server/src/routes/setup/routes/claude-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2ae4a593a21c538e4c54807b81ffc1c056dbf37 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/claude-status.ts @@ -0,0 +1,22 @@ +/** + * GET /claude-status endpoint - Get Claude CLI status + */ + +import type { Request, Response } from 'express'; +import { getClaudeStatus } from '../get-claude-status.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createClaudeStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = await getClaudeStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, 'Get Claude status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/codex-status.ts b/jules_branch/apps/server/src/routes/setup/routes/codex-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e721e05b86dd35af81ddc644259a745276ef658 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/codex-status.ts @@ -0,0 +1,81 @@ +/** + * GET /codex-status endpoint - Get Codex CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { CodexProvider } from '../../../providers/codex-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.codex-disconnected'; + +function isCodexDisconnectedFromApp(): boolean { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + return fs.existsSync(markerPath); + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/codex-status + * Returns Codex CLI installation and authentication status + */ +export function createCodexStatusHandler() { + const installCommand = 'npm install -g @openai/codex'; + const loginCommand = 'codex login'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (isCodexDisconnectedFromApp()) { + res.json({ + success: true, + installed: true, + version: null, + path: null, + auth: { + authenticated: false, + method: 'none', + hasApiKey: false, + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new CodexProvider(); + const status = await provider.detectInstallation(); + + // Derive auth method from authenticated status and API key presence + let authMethod = 'none'; + if (status.authenticated) { + authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated'; + } + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: status.authenticated || false, + method: authMethod, + hasApiKey: status.hasApiKey || false, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Codex status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/copilot-models.ts b/jules_branch/apps/server/src/routes/setup/routes/copilot-models.ts new file mode 100644 index 0000000000000000000000000000000000000000..08b9eda9015a742a544eb03c9a044ffafd05b005 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/copilot-models.ts @@ -0,0 +1,136 @@ +/** + * Copilot Dynamic Models API Routes + * + * Provides endpoints for: + * - GET /api/setup/copilot/models - Get available models (cached or refreshed) + * - POST /api/setup/copilot/models/refresh - Force refresh models from CLI + */ + +import type { Request, Response } from 'express'; +import { CopilotProvider } from '../../../providers/copilot-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import type { ModelDefinition } from '@automaker/types'; + +// Singleton provider instance for caching +let providerInstance: CopilotProvider | null = null; + +function getProvider(): CopilotProvider { + if (!providerInstance) { + providerInstance = new CopilotProvider(); + } + return providerInstance; +} + +/** + * Response type for models endpoint + */ +interface ModelsResponse { + success: boolean; + models?: ModelDefinition[]; + count?: number; + cached?: boolean; + error?: string; +} + +/** + * Creates handler for GET /api/setup/copilot/models + * + * Returns currently available models (from cache if available). + * Query params: + * - refresh=true: Force refresh from CLI before returning + * + * Note: If cache is empty, this will trigger a refresh to get dynamic models. + */ +export function createGetCopilotModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const forceRefresh = req.query.refresh === 'true'; + + let models: ModelDefinition[]; + let cached = true; + + if (forceRefresh) { + models = await provider.refreshModels(); + cached = false; + } else { + // Check if we have cached models + if (!provider.hasCachedModels()) { + models = await provider.refreshModels(); + cached = false; + } else { + models = provider.getAvailableModels(); + } + } + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get Copilot models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/copilot/models/refresh + * + * Forces a refresh of models from the Copilot CLI. + */ +export function createRefreshCopilotModelsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const models = await provider.refreshModels(); + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached: false, + }; + + res.json(response); + } catch (error) { + logError(error, 'Refresh Copilot models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/copilot/cache/clear + * + * Clears the model cache, forcing a fresh fetch on next access. + */ +export function createClearCopilotCacheHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + provider.clearModelCache(); + + res.json({ + success: true, + message: 'Copilot model cache cleared', + }); + } catch (error) { + logError(error, 'Clear Copilot cache failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/copilot-status.ts b/jules_branch/apps/server/src/routes/setup/routes/copilot-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..c759c118ccff270efa4a9f3d1881f91f150537b3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/copilot-status.ts @@ -0,0 +1,78 @@ +/** + * GET /copilot-status endpoint - Get Copilot CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { CopilotProvider } from '../../../providers/copilot-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.copilot-disconnected'; + +async function isCopilotDisconnectedFromApp(): Promise { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + await fs.access(markerPath); + return true; + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/copilot-status + * Returns Copilot CLI installation and authentication status + */ +export function createCopilotStatusHandler() { + const installCommand = 'npm install -g @github/copilot'; + const loginCommand = 'gh auth login'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (await isCopilotDisconnectedFromApp()) { + res.json({ + success: true, + installed: true, + version: null, + path: null, + auth: { + authenticated: false, + method: 'none', + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new CopilotProvider(); + const status = await provider.detectInstallation(); + const auth = await provider.checkAuth(); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: auth.authenticated, + method: auth.method, + login: auth.login, + host: auth.host, + error: auth.error, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Copilot status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/cursor-config.ts b/jules_branch/apps/server/src/routes/setup/routes/cursor-config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b9c05ceee9c7dd63a77f75e085c22a2563c775f --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/cursor-config.ts @@ -0,0 +1,411 @@ +/** + * Cursor CLI configuration routes + * + * Provides endpoints for managing Cursor CLI configuration: + * - GET /api/setup/cursor-config - Get current configuration + * - POST /api/setup/cursor-config/default-model - Set default model + * - POST /api/setup/cursor-config/models - Set enabled models + * + * Cursor CLI Permissions endpoints: + * - GET /api/setup/cursor-permissions - Get permissions config + * - POST /api/setup/cursor-permissions/profile - Apply a permission profile + * - POST /api/setup/cursor-permissions/custom - Set custom permissions + * - DELETE /api/setup/cursor-permissions - Delete project permissions (use global) + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { CursorConfigManager } from '../../../providers/cursor-config-manager.js'; +import { + CURSOR_MODEL_MAP, + CURSOR_PERMISSION_PROFILES, + type CursorModelId, + type CursorPermissionProfile, + type CursorCliPermissions, +} from '@automaker/types'; +import { + readGlobalConfig, + readProjectConfig, + getEffectivePermissions, + applyProfileToProject, + applyProfileGlobally, + writeProjectConfig, + deleteProjectConfig, + detectProfile, + hasProjectConfig, + getAvailableProfiles, + generateExampleConfig, +} from '../../../services/cursor-config-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Validate that a project path is safe (no path traversal) + * @throws Error if path contains traversal sequences + */ +function validateProjectPath(projectPath: string): void { + // Resolve to absolute path and check for traversal + const resolved = path.resolve(projectPath); + const normalized = path.normalize(projectPath); + + // Check for obvious traversal attempts + if (normalized.includes('..') || projectPath.includes('..')) { + throw new Error('Invalid project path: path traversal not allowed'); + } + + // Ensure the resolved path doesn't escape intended boundaries + // by checking if it starts with the normalized path components + if (!resolved.startsWith(path.resolve(normalized))) { + throw new Error('Invalid project path: path traversal detected'); + } +} + +/** + * Creates handler for GET /api/setup/cursor-config + * Returns current Cursor configuration and available models + */ +export function createGetCursorConfigHandler() { + return async (req: Request, res: Response): Promise => { + try { + const projectPath = req.query.projectPath as string; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath query parameter is required', + }); + return; + } + + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + + const configManager = new CursorConfigManager(projectPath); + + res.json({ + success: true, + config: configManager.getConfig(), + availableModels: Object.values(CURSOR_MODEL_MAP), + }); + } catch (error) { + logError(error, 'Get Cursor config failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for POST /api/setup/cursor-config/default-model + * Sets the default Cursor model + */ +export function createSetCursorDefaultModelHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { model, projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + + if (!model || !(model in CURSOR_MODEL_MAP)) { + res.status(400).json({ + success: false, + error: `Invalid model ID. Valid models: ${Object.keys(CURSOR_MODEL_MAP).join(', ')}`, + }); + return; + } + + const configManager = new CursorConfigManager(projectPath); + configManager.setDefaultModel(model as CursorModelId); + + res.json({ success: true, model }); + } catch (error) { + logError(error, 'Set Cursor default model failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for POST /api/setup/cursor-config/models + * Sets the enabled Cursor models list + */ +export function createSetCursorModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { models, projectPath } = req.body; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + + if (!Array.isArray(models)) { + res.status(400).json({ + success: false, + error: 'Models must be an array', + }); + return; + } + + // Filter to valid models only + const validModels = models.filter((m): m is CursorModelId => m in CURSOR_MODEL_MAP); + + if (validModels.length === 0) { + res.status(400).json({ + success: false, + error: 'No valid models provided', + }); + return; + } + + const configManager = new CursorConfigManager(projectPath); + configManager.setEnabledModels(validModels); + + res.json({ success: true, models: validModels }); + } catch (error) { + logError(error, 'Set Cursor models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +// ============================================================================= +// Cursor CLI Permissions Handlers +// ============================================================================= + +/** + * Creates handler for GET /api/setup/cursor-permissions + * Returns current permissions configuration and available profiles + */ +export function createGetCursorPermissionsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const projectPath = req.query.projectPath as string | undefined; + + // Validate path if provided + if (projectPath) { + validateProjectPath(projectPath); + } + + // Get global config + const globalConfig = await readGlobalConfig(); + + // Get project config if path provided + const projectConfig = projectPath ? await readProjectConfig(projectPath) : null; + + // Get effective permissions + const effectivePermissions = await getEffectivePermissions(projectPath); + + // Detect which profile is active + const activeProfile = detectProfile(effectivePermissions); + + // Check if project has its own config + const hasProject = projectPath ? await hasProjectConfig(projectPath) : false; + + res.json({ + success: true, + globalPermissions: globalConfig?.permissions || null, + projectPermissions: projectConfig?.permissions || null, + effectivePermissions, + activeProfile, + hasProjectConfig: hasProject, + availableProfiles: getAvailableProfiles(), + }); + } catch (error) { + logError(error, 'Get Cursor permissions failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for POST /api/setup/cursor-permissions/profile + * Applies a predefined permission profile + */ +export function createApplyPermissionProfileHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { profileId, projectPath, scope } = req.body as { + profileId: CursorPermissionProfile; + projectPath?: string; + scope: 'global' | 'project'; + }; + + // Validate profile + const validProfiles = CURSOR_PERMISSION_PROFILES.map((p) => p.id); + if (!validProfiles.includes(profileId)) { + res.status(400).json({ + success: false, + error: `Invalid profile. Valid profiles: ${validProfiles.join(', ')}`, + }); + return; + } + + if (scope === 'project') { + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required for project scope', + }); + return; + } + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + await applyProfileToProject(projectPath, profileId); + } else { + await applyProfileGlobally(profileId); + } + + res.json({ + success: true, + message: `Applied "${profileId}" profile to ${scope}`, + scope, + profileId, + }); + } catch (error) { + logError(error, 'Apply Cursor permission profile failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for POST /api/setup/cursor-permissions/custom + * Sets custom permissions for a project + */ +export function createSetCustomPermissionsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, permissions } = req.body as { + projectPath: string; + permissions: CursorCliPermissions; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + + if (!permissions || !Array.isArray(permissions.allow) || !Array.isArray(permissions.deny)) { + res.status(400).json({ + success: false, + error: 'permissions must have allow and deny arrays', + }); + return; + } + + await writeProjectConfig(projectPath, { + version: 1, + permissions, + }); + + res.json({ + success: true, + message: 'Custom permissions saved', + permissions, + }); + } catch (error) { + logError(error, 'Set custom Cursor permissions failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for DELETE /api/setup/cursor-permissions + * Deletes project-level permissions (falls back to global) + */ +export function createDeleteProjectPermissionsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const projectPath = req.query.projectPath as string; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath query parameter is required', + }); + return; + } + + // Validate path to prevent traversal attacks + validateProjectPath(projectPath); + + await deleteProjectConfig(projectPath); + + res.json({ + success: true, + message: 'Project permissions deleted, using global config', + }); + } catch (error) { + logError(error, 'Delete Cursor project permissions failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * Creates handler for GET /api/setup/cursor-permissions/example + * Returns an example config file for a profile + */ +export function createGetExampleConfigHandler() { + return async (req: Request, res: Response): Promise => { + try { + const profileId = (req.query.profileId as CursorPermissionProfile) || 'development'; + + const exampleConfig = generateExampleConfig(profileId); + + res.json({ + success: true, + profileId, + config: exampleConfig, + }); + } catch (error) { + logError(error, 'Get example Cursor config failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/cursor-status.ts b/jules_branch/apps/server/src/routes/setup/routes/cursor-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9349aa7903e91998304acef04b1c6692e4499fb --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/cursor-status.ts @@ -0,0 +1,88 @@ +/** + * GET /cursor-status endpoint - Get Cursor CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { CursorProvider } from '../../../providers/cursor-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.cursor-disconnected'; + +function isCursorDisconnectedFromApp(): boolean { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + return fs.existsSync(markerPath); + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/cursor-status + * Returns Cursor CLI installation and authentication status + */ +export function createCursorStatusHandler() { + const installCommand = 'curl https://cursor.com/install -fsS | bash'; + const loginCommand = 'cursor-agent login'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (isCursorDisconnectedFromApp()) { + const provider = new CursorProvider(); + const [installed, version] = await Promise.all([ + provider.isInstalled(), + provider.getVersion(), + ]); + const cliPath = installed ? provider.getCliPath() : null; + + res.json({ + success: true, + installed, + version: version || null, + path: cliPath, + auth: { + authenticated: false, + method: 'none', + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new CursorProvider(); + + const [installed, version, auth] = await Promise.all([ + provider.isInstalled(), + provider.getVersion(), + provider.checkAuth(), + ]); + + // Get CLI path from provider using public accessor + const cliPath = installed ? provider.getCliPath() : null; + + res.json({ + success: true, + installed, + version: version || null, + path: cliPath, + auth: { + authenticated: auth.authenticated, + method: auth.method, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Cursor status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-claude.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-claude.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f3c193085531683db25da4f61e87cdc54920360 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-claude.ts @@ -0,0 +1,44 @@ +/** + * POST /deauth-claude endpoint - Sign out from Claude CLI + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createDeauthClaudeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Create a marker file to indicate the CLI is disconnected from the app + const automakerDir = path.join(process.cwd(), '.automaker'); + const markerPath = path.join(automakerDir, '.claude-disconnected'); + + // Ensure .automaker directory exists + if (!fs.existsSync(automakerDir)) { + fs.mkdirSync(automakerDir, { recursive: true }); + } + + // Create the marker file with timestamp + fs.writeFileSync( + markerPath, + JSON.stringify({ + disconnectedAt: new Date().toISOString(), + message: 'Claude CLI is disconnected from the app', + }) + ); + + res.json({ + success: true, + message: 'Claude CLI is now disconnected from the app', + }); + } catch (error) { + logError(error, 'Deauth Claude failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to disconnect Claude CLI from the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-codex.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-codex.ts new file mode 100644 index 0000000000000000000000000000000000000000..f44a6e150c9e68ca88c78195707dd904ccc22101 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-codex.ts @@ -0,0 +1,44 @@ +/** + * POST /deauth-codex endpoint - Sign out from Codex CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createDeauthCodexHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Create a marker file to indicate the CLI is disconnected from the app + const automakerDir = path.join(process.cwd(), '.automaker'); + const markerPath = path.join(automakerDir, '.codex-disconnected'); + + // Ensure .automaker directory exists + if (!fs.existsSync(automakerDir)) { + fs.mkdirSync(automakerDir, { recursive: true }); + } + + // Create the marker file with timestamp + fs.writeFileSync( + markerPath, + JSON.stringify({ + disconnectedAt: new Date().toISOString(), + message: 'Codex CLI is disconnected from the app', + }) + ); + + res.json({ + success: true, + message: 'Codex CLI is now disconnected from the app', + }); + } catch (error) { + logError(error, 'Deauth Codex failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to disconnect Codex CLI from the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-copilot.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-copilot.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5420315c565678fffe62e98f328de9f06196807 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-copilot.ts @@ -0,0 +1,30 @@ +/** + * POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { disconnectCopilot } from '../../../services/copilot-connection-service.js'; + +/** + * Creates handler for POST /api/setup/deauth-copilot + * Creates a marker file to disconnect Copilot CLI from the app + */ +export function createDeauthCopilotHandler() { + return async (_req: Request, res: Response): Promise => { + try { + await disconnectCopilot(); + + res.json({ + success: true, + message: 'Copilot CLI disconnected from app', + }); + } catch (error) { + logError(error, 'Deauth Copilot failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-cursor.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-cursor.ts new file mode 100644 index 0000000000000000000000000000000000000000..303b200602aa9754b65b9cfde0959e0d1e00095a --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-cursor.ts @@ -0,0 +1,44 @@ +/** + * POST /deauth-cursor endpoint - Sign out from Cursor CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createDeauthCursorHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Create a marker file to indicate the CLI is disconnected from the app + const automakerDir = path.join(process.cwd(), '.automaker'); + const markerPath = path.join(automakerDir, '.cursor-disconnected'); + + // Ensure .automaker directory exists + if (!fs.existsSync(automakerDir)) { + fs.mkdirSync(automakerDir, { recursive: true }); + } + + // Create the marker file with timestamp + fs.writeFileSync( + markerPath, + JSON.stringify({ + disconnectedAt: new Date().toISOString(), + message: 'Cursor CLI is disconnected from the app', + }) + ); + + res.json({ + success: true, + message: 'Cursor CLI is now disconnected from the app', + }); + } catch (error) { + logError(error, 'Deauth Cursor failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to disconnect Cursor CLI from the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-gemini.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-gemini.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5b08a0a7bfd49e0afc94c812a811d791c73a3a4 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-gemini.ts @@ -0,0 +1,42 @@ +/** + * POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +/** + * Creates handler for POST /api/setup/deauth-gemini + * Creates a marker file to disconnect Gemini CLI from the app + */ +export function createDeauthGeminiHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const projectRoot = process.cwd(); + const automakerDir = path.join(projectRoot, '.automaker'); + + // Ensure .automaker directory exists + await fs.mkdir(automakerDir, { recursive: true }); + + const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE); + + // Create the disconnection marker + await fs.writeFile(markerPath, 'Gemini CLI disconnected from app'); + + res.json({ + success: true, + message: 'Gemini CLI disconnected from app', + }); + } catch (error) { + logError(error, 'Deauth Gemini failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/deauth-opencode.ts b/jules_branch/apps/server/src/routes/setup/routes/deauth-opencode.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0567bc894c37e79b780facee61e7e1b07e09c72 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/deauth-opencode.ts @@ -0,0 +1,40 @@ +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; +import * as fs from 'fs'; +import * as path from 'path'; + +export function createDeauthOpencodeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Create a marker file to indicate the CLI is disconnected from the app + const automakerDir = path.join(process.cwd(), '.automaker'); + const markerPath = path.join(automakerDir, '.opencode-disconnected'); + + // Ensure .automaker directory exists + if (!fs.existsSync(automakerDir)) { + fs.mkdirSync(automakerDir, { recursive: true }); + } + + // Create the marker file with timestamp + fs.writeFileSync( + markerPath, + JSON.stringify({ + disconnectedAt: new Date().toISOString(), + message: 'OpenCode CLI is disconnected from the app', + }) + ); + + res.json({ + success: true, + message: 'OpenCode CLI is now disconnected from the app', + }); + } catch (error) { + logError(error, 'Deauth OpenCode failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + message: 'Failed to disconnect OpenCode CLI from the app', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/delete-api-key.ts b/jules_branch/apps/server/src/routes/setup/routes/delete-api-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..242425fbfab8f74cd67476b052ddbc9c48ea2d91 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -0,0 +1,84 @@ +/** + * POST /delete-api-key endpoint - Delete a stored API key + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import path from 'path'; +import { secureFs } from '@automaker/platform'; + +const logger = createLogger('Setup'); + +// In-memory storage reference (imported from common.ts pattern) +import { setApiKey } from '../common.js'; + +/** + * Remove an API key from the .env file + * Uses centralized secureFs.removeEnvKey for path validation + */ +async function removeApiKeyFromEnv(key: string): Promise { + const envPath = path.join(process.cwd(), '.env'); + + try { + await secureFs.removeEnvKey(envPath, key); + logger.info(`[Setup] Removed ${key} from .env file`); + } catch (error) { + logger.error(`[Setup] Failed to remove ${key} from .env:`, error); + throw error; + } +} + +export function createDeleteApiKeyHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { provider } = req.body as { provider: string }; + + if (!provider) { + res.status(400).json({ + success: false, + error: 'Provider is required', + }); + return; + } + + logger.info(`[Setup] Deleting API key for provider: ${provider}`); + + // Map provider to env key name + const envKeyMap: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + }; + + const envKey = envKeyMap[provider]; + if (!envKey) { + res.status(400).json({ + success: false, + error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`, + }); + return; + } + + // Clear from in-memory storage + setApiKey(provider, ''); + + // Remove from environment + delete process.env[envKey]; + + // Remove from .env file + await removeApiKeyFromEnv(envKey); + + logger.info(`[Setup] Successfully deleted API key for ${provider}`); + + res.json({ + success: true, + message: `API key for ${provider} has been deleted`, + }); + } catch (error) { + logger.error('[Setup] Delete API key error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to delete API key', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/gemini-status.ts b/jules_branch/apps/server/src/routes/setup/routes/gemini-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec4fbee4a25c9fccaf08a240fd7da5e676c9e1cc --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/gemini-status.ts @@ -0,0 +1,79 @@ +/** + * GET /gemini-status endpoint - Get Gemini CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { GeminiProvider } from '../../../providers/gemini-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +const DISCONNECTED_MARKER_FILE = '.gemini-disconnected'; + +async function isGeminiDisconnectedFromApp(): Promise { + try { + const projectRoot = process.cwd(); + const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE); + await fs.access(markerPath); + return true; + } catch { + return false; + } +} + +/** + * Creates handler for GET /api/setup/gemini-status + * Returns Gemini CLI installation and authentication status + */ +export function createGeminiStatusHandler() { + const installCommand = 'npm install -g @google/gemini-cli'; + const loginCommand = 'gemini'; + + return async (_req: Request, res: Response): Promise => { + try { + // Check if user has manually disconnected from the app + if (await isGeminiDisconnectedFromApp()) { + res.json({ + success: true, + installed: true, + version: null, + path: null, + auth: { + authenticated: false, + method: 'none', + hasApiKey: false, + }, + installCommand, + loginCommand, + }); + return; + } + + const provider = new GeminiProvider(); + const status = await provider.detectInstallation(); + const auth = await provider.checkAuth(); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: auth.authenticated, + method: auth.method, + hasApiKey: auth.hasApiKey || false, + hasEnvApiKey: auth.hasEnvApiKey || false, + error: auth.error, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Gemini status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/gh-status.ts b/jules_branch/apps/server/src/routes/setup/routes/gh-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f78bbd6d1859be8a8f3d1fbf1793437893d18a60 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/gh-status.ts @@ -0,0 +1,126 @@ +/** + * GET /gh-status endpoint - Get GitHub CLI status + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getGitHubCliPaths, getExtendedPath, systemPathAccess } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +const execEnv = { + ...process.env, + PATH: getExtendedPath(), +}; + +export interface GhStatus { + installed: boolean; + authenticated: boolean; + version: string | null; + path: string | null; + user: string | null; + error?: string; +} + +async function getGhStatus(): Promise { + const status: GhStatus = { + installed: false, + authenticated: false, + version: null, + path: null, + user: null, + }; + + const isWindows = process.platform === 'win32'; + + // Check if gh CLI is installed + try { + const findCommand = isWindows ? 'where gh' : 'command -v gh'; + const { stdout } = await execAsync(findCommand, { env: execEnv }); + status.path = stdout.trim().split(/\r?\n/)[0]; + status.installed = true; + } catch { + // gh not in PATH, try common locations from centralized system paths + const commonPaths = getGitHubCliPaths(); + + for (const p of commonPaths) { + try { + if (await systemPathAccess(p)) { + status.path = p; + status.installed = true; + break; + } + } catch { + // Not found at this path + } + } + } + + if (!status.installed) { + return status; + } + + // Get version + try { + const { stdout } = await execAsync('gh --version', { env: execEnv }); + // Extract version from output like "gh version 2.40.1 (2024-01-09)" + const versionMatch = stdout.match(/gh version ([\d.]+)/); + status.version = versionMatch ? versionMatch[1] : stdout.trim().split('\n')[0]; + } catch { + // Version command failed + } + + // Check authentication status by actually making an API call + // gh auth status can return non-zero even when GH_TOKEN is valid + let apiCallSucceeded = false; + try { + const { stdout } = await execAsync('gh api user --jq ".login"', { env: execEnv }); + const user = stdout.trim(); + if (user) { + status.authenticated = true; + status.user = user; + apiCallSucceeded = true; + } + // If stdout is empty, fall through to gh auth status fallback + } catch { + // API call failed - fall through to gh auth status fallback + } + + // Fallback: try gh auth status if API call didn't succeed + if (!apiCallSucceeded) { + try { + const { stdout } = await execAsync('gh auth status', { env: execEnv }); + status.authenticated = true; + + // Try to extract username from output + const userMatch = + stdout.match(/Logged in to [^\s]+ account ([^\s]+)/i) || + stdout.match(/Logged in to [^\s]+ as ([^\s]+)/i); + if (userMatch) { + status.user = userMatch[1]; + } + } catch { + // Auth status returns non-zero if not authenticated + status.authenticated = false; + } + } + + return status; +} + +export function createGhStatusHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const status = await getGhStatus(); + res.json({ + success: true, + ...status, + }); + } catch (error) { + logError(error, 'Get GitHub CLI status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/install-claude.ts b/jules_branch/apps/server/src/routes/setup/routes/install-claude.ts new file mode 100644 index 0000000000000000000000000000000000000000..644f5e10f9a526f54d8ce7d723e8871e01fe8064 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/install-claude.ts @@ -0,0 +1,23 @@ +/** + * POST /install-claude endpoint - Install Claude CLI + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; + +export function createInstallClaudeHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // In web mode, we can't install CLIs directly + // Return instructions instead + res.json({ + success: false, + error: + 'CLI installation requires terminal access. Please install manually using: npm install -g @anthropic-ai/claude-code', + }); + } catch (error) { + logError(error, 'Install Claude CLI failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/install-codex.ts b/jules_branch/apps/server/src/routes/setup/routes/install-codex.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea40e92dcbee797d942cc650a048937d01d17083 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/install-codex.ts @@ -0,0 +1,33 @@ +/** + * POST /install-codex endpoint - Install Codex CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; + +/** + * Creates handler for POST /api/setup/install-codex + * Installs Codex CLI (currently returns instructions for manual install) + */ +export function createInstallCodexHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // For now, return manual installation instructions + // In the future, this could potentially trigger npm global install + const installCommand = 'npm install -g @openai/codex'; + + res.json({ + success: true, + message: `Please install Codex CLI manually by running: ${installCommand}`, + requiresManualInstall: true, + installCommand, + }); + } catch (error) { + logError(error, 'Install Codex failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/opencode-models.ts b/jules_branch/apps/server/src/routes/setup/routes/opencode-models.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7909bf99e45c2670d769b63b1b347a825510776 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/opencode-models.ts @@ -0,0 +1,186 @@ +/** + * OpenCode Dynamic Models API Routes + * + * Provides endpoints for: + * - GET /api/setup/opencode/models - Get available models (cached or refreshed) + * - POST /api/setup/opencode/models/refresh - Force refresh models from CLI + * - GET /api/setup/opencode/providers - Get authenticated providers + */ + +import type { Request, Response } from 'express'; +import { + OpencodeProvider, + type OpenCodeProviderInfo, +} from '../../../providers/opencode-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import type { ModelDefinition } from '@automaker/types'; + +// Singleton provider instance for caching +let providerInstance: OpencodeProvider | null = null; + +function getProvider(): OpencodeProvider { + if (!providerInstance) { + providerInstance = new OpencodeProvider(); + } + return providerInstance; +} + +/** + * Response type for models endpoint + */ +interface ModelsResponse { + success: boolean; + models?: ModelDefinition[]; + count?: number; + cached?: boolean; + error?: string; +} + +/** + * Response type for providers endpoint + */ +interface ProvidersResponse { + success: boolean; + providers?: OpenCodeProviderInfo[]; + authenticated?: OpenCodeProviderInfo[]; + error?: string; +} + +/** + * Creates handler for GET /api/setup/opencode/models + * + * Returns currently available models (from cache if available). + * Query params: + * - refresh=true: Force refresh from CLI before returning + * + * Note: If cache is empty, this will trigger a refresh to get dynamic models. + */ +export function createGetOpencodeModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const forceRefresh = req.query.refresh === 'true'; + + let models: ModelDefinition[]; + let cached = true; + + if (forceRefresh) { + models = await provider.refreshModels(); + cached = false; + } else { + // Check if we have cached models + const cachedModels = provider.getAvailableModels(); + + // If cache only has default models (provider.hasCachedModels() would be false), + // trigger a refresh to get dynamic models + if (!provider.hasCachedModels()) { + models = await provider.refreshModels(); + cached = false; + } else { + models = cachedModels; + } + } + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/models/refresh + * + * Forces a refresh of models from the OpenCode CLI. + */ +export function createRefreshOpencodeModelsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const models = await provider.refreshModels(); + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached: false, + }; + + res.json(response); + } catch (error) { + logError(error, 'Refresh OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for GET /api/setup/opencode/providers + * + * Returns authenticated providers from OpenCode CLI. + * This calls `opencode auth list` to get provider status. + */ +export function createGetOpencodeProvidersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const providers = await provider.fetchAuthenticatedProviders(); + + // Filter to only authenticated providers + const authenticated = providers.filter((p) => p.authenticated); + + const response: ProvidersResponse = { + success: true, + providers, + authenticated, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get OpenCode providers failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ProvidersResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/cache/clear + * + * Clears the model cache, forcing a fresh fetch on next access. + */ +export function createClearOpencodeCacheHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + provider.clearModelCache(); + + res.json({ + success: true, + message: 'OpenCode model cache cleared', + }); + } catch (error) { + logError(error, 'Clear OpenCode cache failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/opencode-status.ts b/jules_branch/apps/server/src/routes/setup/routes/opencode-status.ts new file mode 100644 index 0000000000000000000000000000000000000000..f474cfb18584a4f42e4cafd27b4cf90d8e590ea6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/opencode-status.ts @@ -0,0 +1,59 @@ +/** + * GET /opencode-status endpoint - Get OpenCode CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { OpencodeProvider } from '../../../providers/opencode-provider.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Creates handler for GET /api/setup/opencode-status + * Returns OpenCode CLI installation and authentication status + */ +export function createOpencodeStatusHandler() { + const installCommand = 'curl -fsSL https://opencode.ai/install | bash'; + const loginCommand = 'opencode auth login'; + + return async (_req: Request, res: Response): Promise => { + try { + const provider = new OpencodeProvider(); + const status = await provider.detectInstallation(); + + // Derive auth method from authenticated status and API key presence + let authMethod = 'none'; + if (status.authenticated) { + authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated'; + } + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: status.authenticated || false, + method: authMethod, + hasApiKey: status.hasApiKey || false, + hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY, + hasOAuthToken: status.hasOAuthToken || false, + }, + recommendation: status.installed + ? undefined + : 'Install OpenCode CLI to use multi-provider AI models.', + installCommand, + loginCommand, + installCommands: { + macos: installCommand, + linux: installCommand, + npm: 'npm install -g opencode-ai', + }, + }); + } catch (error) { + logError(error, 'Get OpenCode status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/platform.ts b/jules_branch/apps/server/src/routes/setup/routes/platform.ts new file mode 100644 index 0000000000000000000000000000000000000000..303cdd8772e2c1428aac0f9de5772ed6c722aaca --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/platform.ts @@ -0,0 +1,27 @@ +/** + * GET /platform endpoint - Get platform info + */ + +import type { Request, Response } from 'express'; +import os from 'os'; +import { getErrorMessage, logError } from '../common.js'; + +export function createPlatformHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const platform = os.platform(); + res.json({ + success: true, + platform, + arch: os.arch(), + homeDir: os.homedir(), + isWindows: platform === 'win32', + isMac: platform === 'darwin', + isLinux: platform === 'linux', + }); + } catch (error) { + logError(error, 'Get platform info failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/store-api-key.ts b/jules_branch/apps/server/src/routes/setup/routes/store-api-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..eae2e430bfeec08ba8eb1b5de42e34fe7540f6b9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/store-api-key.ts @@ -0,0 +1,49 @@ +/** + * POST /store-api-key endpoint - Store API key + */ + +import type { Request, Response } from 'express'; +import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Setup'); + +export function createStoreApiKeyHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { provider, apiKey } = req.body as { + provider: string; + apiKey: string; + }; + + if (!provider || !apiKey) { + res.status(400).json({ success: false, error: 'provider and apiKey required' }); + return; + } + + const providerEnvMap: Record = { + anthropic: 'ANTHROPIC_API_KEY', + anthropic_oauth_token: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + }; + const envKey = providerEnvMap[provider]; + if (!envKey) { + res.status(400).json({ + success: false, + error: `Unsupported provider: ${provider}. Only anthropic and openai are supported.`, + }); + return; + } + + setApiKey(provider, apiKey); + process.env[envKey] = apiKey; + await persistApiKeyToEnv(envKey, apiKey); + logger.info(`[Setup] Stored API key as ${envKey}`); + + res.json({ success: true }); + } catch (error) { + logError(error, 'Store API key failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/jules_branch/apps/server/src/routes/setup/routes/verify-claude-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5a3546750ccc3d75df47c21a547d5907b09883c --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -0,0 +1,368 @@ +/** + * POST /verify-claude-auth endpoint - Verify Claude authentication by running a test query + * Supports verifying either CLI auth or API key auth independently + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { getClaudeAuthIndicators } from '@automaker/platform'; +import { getApiKey } from '../common.js'; +import { + createSecureAuthEnv, + AuthSessionManager, + AuthRateLimiter, + validateApiKey, + createTempEnvOverride, +} from '../../../lib/auth-utils.js'; + +const logger = createLogger('Setup'); +const rateLimiter = new AuthRateLimiter(); + +// Known error patterns that indicate auth failure +const AUTH_ERROR_PATTERNS = [ + 'OAuth token revoked', + 'Please run /login', + 'please run /login', + 'token revoked', + 'invalid_api_key', + 'authentication_error', + 'unauthorized', + 'not authenticated', + 'authentication failed', + 'invalid api key', + 'api key is invalid', +]; + +// Patterns that indicate billing/credit issues - should FAIL verification +const BILLING_ERROR_PATTERNS = [ + 'credit balance is too low', + 'credit balance too low', + 'insufficient credits', + 'insufficient balance', + 'no credits', + 'out of credits', + 'billing', + 'payment required', + 'add credits', +]; + +// Patterns that indicate rate/usage limits - should FAIL verification +// Users need to wait or upgrade their plan +const RATE_LIMIT_PATTERNS = [ + 'limit reached', + 'rate limit', + 'rate_limit', + 'resets', // Only valid if it's a temporary reset, not a billing issue + '/upgrade', + 'extra-usage', +]; + +function isRateLimitError(text: string): boolean { + const lowerText = text.toLowerCase(); + // First check if it's a billing error - billing errors are NOT rate limits + if (isBillingError(text)) { + return false; + } + return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase())); +} + +function isBillingError(text: string): boolean { + const lowerText = text.toLowerCase(); + return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase())); +} + +function containsAuthError(text: string): boolean { + const lowerText = text.toLowerCase(); + return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern.toLowerCase())); +} + +export function createVerifyClaudeAuthHandler() { + return async (req: Request, res: Response): Promise => { + try { + // In E2E/CI mock mode, skip real API calls + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + res.json({ success: true, authenticated: true }); + return; + } + + // Get the auth method and optional API key from the request body + const { authMethod, apiKey } = req.body as { + authMethod?: 'cli' | 'api_key'; + apiKey?: string; + }; + + // Rate limiting to prevent abuse + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + if (!rateLimiter.canAttempt(clientIp)) { + const resetTime = rateLimiter.getResetTime(clientIp); + res.status(429).json({ + success: false, + authenticated: false, + error: 'Too many authentication attempts. Please try again later.', + resetTime, + }); + return; + } + + logger.info( + `[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}` + ); + + // Create an AbortController with a 30-second timeout + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 30000); + + let authenticated = false; + let errorMessage = ''; + let receivedAnyContent = false; + let cleanupEnv: (() => void) | undefined; + + // Create secure auth session + const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + try { + // For API key verification, validate the key first + if (authMethod === 'api_key' && apiKey) { + const validation = validateApiKey(apiKey, 'anthropic'); + if (!validation.isValid) { + res.json({ + success: true, + authenticated: false, + error: validation.error, + }); + return; + } + } + + // Create secure environment without modifying process.env + const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'anthropic'); + + // For API key verification without provided key, use stored key or env var + if (authMethod === 'api_key' && !apiKey) { + const storedApiKey = getApiKey('anthropic'); + if (storedApiKey) { + authEnv.ANTHROPIC_API_KEY = storedApiKey; + logger.info('[Setup] Using stored API key for verification'); + } else if (!authEnv.ANTHROPIC_API_KEY) { + res.json({ + success: true, + authenticated: false, + error: 'No API key configured. Please enter an API key first.', + }); + return; + } + } + + // Store the secure environment in session manager + AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic'); + + // Create temporary environment override for SDK call + cleanupEnv = createTempEnvOverride(authEnv); + + // Run a minimal query to verify authentication + const stream = query({ + prompt: "Reply with only the word 'ok'", + options: { + model: 'claude-sonnet-4-6', + maxTurns: 1, + allowedTools: [], + abortController, + }, + }); + + // Collect all messages and check for errors + const allMessages: string[] = []; + + for await (const msg of stream) { + const msgStr = JSON.stringify(msg); + allMessages.push(msgStr); + logger.info('[Setup] Stream message:', msgStr.substring(0, 500)); + + // Check for billing errors FIRST - these should fail verification + if (isBillingError(msgStr)) { + logger.error('[Setup] Found billing error in message'); + errorMessage = + 'Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com'; + authenticated = false; + break; + } + + // Check if any part of the message contains auth errors + if (containsAuthError(msgStr)) { + logger.error('[Setup] Found auth error in message'); + if (authMethod === 'cli') { + errorMessage = + "CLI authentication failed. Please run 'claude login' in your terminal to authenticate."; + } else { + errorMessage = 'API key is invalid or has been revoked.'; + } + break; + } + + // Check specifically for assistant messages with text content + const msgRecord = msg as Record; + const msgMessage = msgRecord.message as Record | undefined; + if (msg.type === 'assistant' && msgMessage?.content) { + const content = msgMessage.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) { + const text = block.text; + logger.info('[Setup] Assistant text:', text); + + if (containsAuthError(text)) { + if (authMethod === 'cli') { + errorMessage = + "CLI authentication failed. Please run 'claude login' in your terminal to authenticate."; + } else { + errorMessage = 'API key is invalid or has been revoked.'; + } + break; + } + + // Valid text response that's not an error + if (text.toLowerCase().includes('ok') || text.length > 0) { + receivedAnyContent = true; + } + } + } + } + } + + // Check for result messages + if (msg.type === 'result') { + const resultStr = JSON.stringify(msg); + + // First check for billing errors - these should FAIL verification + if (isBillingError(resultStr)) { + logger.error('[Setup] Billing error detected - insufficient credits'); + errorMessage = + 'Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com'; + authenticated = false; + break; + } + // Check if it's a rate limit error - should FAIL verification + else if (isRateLimitError(resultStr)) { + logger.warn('[Setup] Rate limit detected - treating as unverified'); + errorMessage = + 'Rate limit reached. Please wait a while before trying again or upgrade your plan.'; + authenticated = false; + break; + } else if (containsAuthError(resultStr)) { + if (authMethod === 'cli') { + errorMessage = + "CLI authentication failed. Please run 'claude login' in your terminal to authenticate."; + } else { + errorMessage = 'API key is invalid or has been revoked.'; + } + } else { + // Got a result without errors + receivedAnyContent = true; + } + } + } + + // Determine authentication status + if (errorMessage) { + authenticated = false; + } else if (receivedAnyContent) { + authenticated = true; + } else { + // No content received - might be an issue + logger.warn('[Setup] No content received from stream'); + logger.warn('[Setup] All messages:', allMessages.join('\n')); + errorMessage = 'No response received from Claude. Please check your authentication.'; + } + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + + logger.error('[Setup] Claude auth verification exception:', errMessage); + + // Check for billing errors FIRST - these always fail + if (isBillingError(errMessage)) { + authenticated = false; + errorMessage = + 'Credit balance is too low. Please add credits to your Anthropic account at console.anthropic.com'; + } + // Check for rate limit in exception - should FAIL verification + else if (isRateLimitError(errMessage)) { + authenticated = false; + errorMessage = + 'Rate limit reached. Please wait a while before trying again or upgrade your plan.'; + logger.warn('[Setup] Rate limit in exception - treating as unverified'); + } + // If we already determined auth was successful, keep it + else if (authenticated) { + logger.info('[Setup] Auth already confirmed, ignoring exception'); + } + // Check for auth-related errors in exception + else if (containsAuthError(errMessage)) { + if (authMethod === 'cli') { + errorMessage = + "CLI authentication failed. Please run 'claude login' in your terminal to authenticate."; + } else { + errorMessage = 'API key is invalid or has been revoked.'; + } + } else if (errMessage.includes('abort') || errMessage.includes('timeout')) { + errorMessage = 'Verification timed out. Please try again.'; + } else if (errMessage.includes('exit') && errMessage.includes('code 1')) { + // Process exited with code 1 but we might have gotten rate limit info in the stream + // Check if we received any content that indicated auth worked + if (receivedAnyContent && !errorMessage) { + authenticated = true; + logger.info('[Setup] Process exit 1 but content received - auth valid'); + } else if (!errorMessage) { + errorMessage = errMessage; + } + } else if (!errorMessage) { + errorMessage = errMessage; + } + } finally { + clearTimeout(timeoutId); + // Restore process.env to its original state + cleanupEnv?.(); + // Clean up the auth session + AuthSessionManager.destroySession(sessionId); + } + + logger.info('[Setup] Verification result:', { + authenticated, + errorMessage, + authMethod, + }); + + // Determine specific auth type for success messages + const effectiveAuthMethod = authMethod ?? 'api_key'; + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (effectiveAuthMethod === 'api_key') { + authType = 'api_key'; + } else if (effectiveAuthMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + try { + const indicators = await getClaudeAuthIndicators(); + authType = indicators.credentials?.hasOAuthToken ? 'oauth' : 'cli'; + } catch { + // Fall back to generic CLI if credential check fails + authType = 'cli'; + } + } + } + + res.json({ + success: true, + authenticated, + authType, + error: errorMessage || undefined, + }); + } catch (error) { + logger.error('[Setup] Verify Claude auth endpoint error:', error); + res.status(500).json({ + success: false, + authenticated: false, + error: error instanceof Error ? error.message : 'Verification failed', + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/setup/routes/verify-codex-auth.ts b/jules_branch/apps/server/src/routes/setup/routes/verify-codex-auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dcf1559858d4dd53915852587817e14b229ed4e --- /dev/null +++ b/jules_branch/apps/server/src/routes/setup/routes/verify-codex-auth.ts @@ -0,0 +1,288 @@ +/** + * POST /verify-codex-auth endpoint - Verify Codex authentication + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import { CODEX_MODEL_MAP } from '@automaker/types'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { getApiKey } from '../common.js'; +import { getCodexAuthIndicators } from '@automaker/platform'; +import { + createSecureAuthEnv, + AuthSessionManager, + AuthRateLimiter, + validateApiKey, + createTempEnvOverride, +} from '../../../lib/auth-utils.js'; + +const logger = createLogger('Setup'); +const rateLimiter = new AuthRateLimiter(); +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const AUTH_PROMPT = "Reply with only the word 'ok'"; +const AUTH_TIMEOUT_MS = 30000; +const ERROR_BILLING_MESSAGE = + 'Credit balance is too low. Please add credits to your OpenAI account.'; +const ERROR_RATE_LIMIT_MESSAGE = + 'Rate limit reached. Please wait a while before trying again or upgrade your plan.'; +const ERROR_CLI_AUTH_REQUIRED = + "CLI authentication failed. Please run 'codex login' to authenticate."; +const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.'; +const AUTH_ERROR_PATTERNS = [ + 'authentication', + 'unauthorized', + 'invalid_api_key', + 'invalid api key', + 'api key is invalid', + 'not authenticated', + 'login', + 'auth(', + 'token refresh', + 'tokenrefresh', + 'failed to parse server response', + 'transport channel closed', +]; +const BILLING_ERROR_PATTERNS = [ + 'credit balance is too low', + 'credit balance too low', + 'insufficient credits', + 'insufficient balance', + 'no credits', + 'out of credits', + 'billing', + 'payment required', + 'add credits', +]; +const RATE_LIMIT_PATTERNS = [ + 'limit reached', + 'rate limit', + 'rate_limit', + 'too many requests', + 'resets', + '429', +]; + +function containsAuthError(text: string): boolean { + const lowerText = text.toLowerCase(); + return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +function isBillingError(text: string): boolean { + const lowerText = text.toLowerCase(); + return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +function isRateLimitError(text: string): boolean { + if (isBillingError(text)) { + return false; + } + const lowerText = text.toLowerCase(); + return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +export function createVerifyCodexAuthHandler() { + return async (req: Request, res: Response): Promise => { + // In E2E/CI mock mode, skip real API calls + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + res.json({ success: true, authenticated: true }); + return; + } + + const { authMethod, apiKey } = req.body as { + authMethod?: 'cli' | 'api_key'; + apiKey?: string; + }; + + // Create session ID for cleanup + const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // Rate limiting + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + if (!rateLimiter.canAttempt(clientIp)) { + const resetTime = rateLimiter.getResetTime(clientIp); + res.status(429).json({ + success: false, + authenticated: false, + error: 'Too many authentication attempts. Please try again later.', + resetTime, + }); + return; + } + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS); + + try { + // Create secure environment without modifying process.env + const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai'); + + // For API key auth, validate and use the provided key or stored key + if (authMethod === 'api_key') { + if (apiKey) { + // Use the provided API key + const validation = validateApiKey(apiKey, 'openai'); + if (!validation.isValid) { + res.json({ success: true, authenticated: false, error: validation.error }); + return; + } + authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; + } else { + // Try stored key + const storedApiKey = getApiKey('openai'); + if (storedApiKey) { + const validation = validateApiKey(storedApiKey, 'openai'); + if (!validation.isValid) { + res.json({ success: true, authenticated: false, error: validation.error }); + return; + } + authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; + } else if (!authEnv[OPENAI_API_KEY_ENV]) { + res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); + return; + } + } + } + + // Create session and temporary environment override + AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai'); + const cleanupEnv = createTempEnvOverride(authEnv); + + try { + if (authMethod === 'cli') { + const authIndicators = await getCodexAuthIndicators(); + if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) { + res.json({ + success: true, + authenticated: false, + error: ERROR_CLI_AUTH_REQUIRED, + }); + return; + } + } + + // Use Codex provider explicitly (not ProviderFactory.getProviderForModel) + // because Cursor also supports GPT models and has higher priority + const provider = ProviderFactory.getProviderByName('codex'); + if (!provider) { + throw new Error('Codex provider not available'); + } + const stream = provider.executeQuery({ + prompt: AUTH_PROMPT, + model: CODEX_MODEL_MAP.gpt52Codex, + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], + abortController, + }); + + let receivedAnyContent = false; + let errorMessage = ''; + + for await (const msg of stream) { + if (msg.type === 'error' && msg.error) { + if (isBillingError(msg.error)) { + errorMessage = ERROR_BILLING_MESSAGE; + } else if (isRateLimitError(msg.error)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + } else { + errorMessage = msg.error; + } + break; + } + + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + receivedAnyContent = true; + if (isBillingError(block.text)) { + errorMessage = ERROR_BILLING_MESSAGE; + break; + } + if (isRateLimitError(block.text)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + break; + } + if (containsAuthError(block.text)) { + errorMessage = block.text; + break; + } + } + } + } + + if (msg.type === 'result' && msg.result) { + receivedAnyContent = true; + if (isBillingError(msg.result)) { + errorMessage = ERROR_BILLING_MESSAGE; + } else if (isRateLimitError(msg.result)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + } else if (containsAuthError(msg.result)) { + errorMessage = msg.result; + break; + } + } + } + + if (errorMessage) { + // Rate limit and billing errors mean auth succeeded but usage is limited + const isUsageLimitError = + errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE; + + const response: { + success: boolean; + authenticated: boolean; + error: string; + details?: string; + } = { + success: true, + authenticated: isUsageLimitError ? true : false, + error: isUsageLimitError + ? errorMessage + : authMethod === 'cli' + ? ERROR_CLI_AUTH_REQUIRED + : 'API key is invalid or has been revoked.', + }; + + // Include detailed error for auth failures so users can debug + if (!isUsageLimitError && errorMessage !== response.error) { + response.details = errorMessage; + } + + res.json(response); + return; + } + + if (!receivedAnyContent) { + res.json({ + success: true, + authenticated: false, + error: 'No response received from Codex. Please check your authentication.', + }); + return; + } + + res.json({ success: true, authenticated: true }); + } finally { + // Clean up environment override + cleanupEnv(); + } + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + logger.error('[Setup] Codex auth verification error:', errMessage); + const normalizedError = isBillingError(errMessage) + ? ERROR_BILLING_MESSAGE + : isRateLimitError(errMessage) + ? ERROR_RATE_LIMIT_MESSAGE + : errMessage; + res.json({ + success: true, + authenticated: false, + error: normalizedError, + }); + } finally { + clearTimeout(timeoutId); + // Clean up session + AuthSessionManager.destroySession(sessionId); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/templates/common.ts b/jules_branch/apps/server/src/routes/templates/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0ee96ddab5fc2b078db00b601652edf37624c71 --- /dev/null +++ b/jules_branch/apps/server/src/routes/templates/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for templates routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +export const logger = createLogger('Templates'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/templates/index.ts b/jules_branch/apps/server/src/routes/templates/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..38eb0270f097c428141fbdba5bdec4eab5e30034 --- /dev/null +++ b/jules_branch/apps/server/src/routes/templates/index.ts @@ -0,0 +1,15 @@ +/** + * Templates routes + * Provides API for cloning GitHub starter templates + */ + +import { Router } from 'express'; +import { createCloneHandler } from './routes/clone.js'; + +export function createTemplatesRoutes(): Router { + const router = Router(); + + router.post('/clone', createCloneHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/templates/routes/clone.ts b/jules_branch/apps/server/src/routes/templates/routes/clone.ts new file mode 100644 index 0000000000000000000000000000000000000000..5874a3efeca5a055486bd180f8016230fc7b0ea0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/templates/routes/clone.ts @@ -0,0 +1,204 @@ +/** + * POST /clone endpoint - Clone a GitHub template to a new project directory + */ + +import type { Request, Response } from 'express'; +import { spawn } from 'child_process'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { PathNotAllowedError } from '@automaker/platform'; +import { logger, getErrorMessage, logError } from '../common.js'; + +export function createCloneHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { repoUrl, projectName, parentDir } = req.body as { + repoUrl: string; + projectName: string; + parentDir: string; + }; + + // Validate inputs + if (!repoUrl || !projectName || !parentDir) { + res.status(400).json({ + success: false, + error: 'repoUrl, projectName, and parentDir are required', + }); + return; + } + + logger.info( + `[Templates] Clone request - Repo: ${repoUrl}, Project: ${projectName}, Parent: ${parentDir}` + ); + + // Validate repo URL is a valid GitHub URL + const githubUrlPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+$/; + if (!githubUrlPattern.test(repoUrl)) { + res.status(400).json({ + success: false, + error: 'Invalid GitHub repository URL', + }); + return; + } + + // Sanitize project name (allow alphanumeric, dash, underscore) + const sanitizedName = projectName.replace(/[^a-zA-Z0-9-_]/g, '-'); + if (sanitizedName !== projectName) { + logger.info(`[Templates] Sanitized project name: ${projectName} -> ${sanitizedName}`); + } + + // Build full project path + const projectPath = path.join(parentDir, sanitizedName); + + const resolvedParent = path.resolve(parentDir); + const resolvedProject = path.resolve(projectPath); + const relativePath = path.relative(resolvedParent, resolvedProject); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + res.status(400).json({ + success: false, + error: 'Invalid project name; potential path traversal attempt.', + }); + return; + } + + // Check if directory already exists (secureFs.access also validates path is allowed) + try { + await secureFs.access(projectPath); + res.status(400).json({ + success: false, + error: `Directory "${sanitizedName}" already exists in ${parentDir}`, + }); + return; + } catch (accessError) { + if (accessError instanceof PathNotAllowedError) { + res.status(403).json({ + success: false, + error: `Project path not allowed: ${projectPath}. Must be within ALLOWED_ROOT_DIRECTORY.`, + }); + return; + } + // Directory doesn't exist, which is what we want + } + + // Ensure parent directory exists + try { + // Check if parentDir is a root path (Windows: C:\, D:\, etc. or Unix: /) + const isWindowsRoot = /^[A-Za-z]:\\?$/.test(parentDir); + const isUnixRoot = parentDir === '/' || parentDir === ''; + const isRoot = isWindowsRoot || isUnixRoot; + + if (isRoot) { + // Root paths always exist, just verify access + logger.info(`[Templates] Using root path: ${parentDir}`); + await secureFs.access(parentDir); + } else { + // Check if parent directory exists + let parentExists = false; + try { + await secureFs.access(parentDir); + parentExists = true; + } catch { + parentExists = false; + } + + if (!parentExists) { + logger.info(`[Templates] Creating parent directory: ${parentDir}`); + await secureFs.mkdir(parentDir, { recursive: true }); + } else { + logger.info(`[Templates] Parent directory exists: ${parentDir}`); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('[Templates] Failed to access parent directory:', parentDir, error); + res.status(500).json({ + success: false, + error: `Failed to access parent directory: ${errorMessage}`, + }); + return; + } + + logger.info(`[Templates] Cloning ${repoUrl} to ${projectPath}`); + + // Clone the repository + const cloneResult = await new Promise<{ + success: boolean; + error?: string; + }>((resolve) => { + const gitProcess = spawn('git', ['clone', repoUrl, projectPath], { + cwd: parentDir, + }); + + let stderr = ''; + + gitProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true }); + } else { + resolve({ + success: false, + error: stderr || `Git clone failed with code ${code}`, + }); + } + }); + + gitProcess.on('error', (error) => { + resolve({ + success: false, + error: `Failed to spawn git: ${error.message}`, + }); + }); + }); + + if (!cloneResult.success) { + res.status(500).json({ + success: false, + error: cloneResult.error || 'Failed to clone repository', + }); + return; + } + + // Remove .git directory to start fresh + try { + const gitDir = path.join(projectPath, '.git'); + await secureFs.rm(gitDir, { recursive: true, force: true }); + logger.info('[Templates] Removed .git directory'); + } catch (error) { + logger.warn('[Templates] Could not remove .git directory:', error); + // Continue anyway - not critical + } + + // Initialize a fresh git repository + await new Promise((resolve) => { + const gitInit = spawn('git', ['init'], { + cwd: projectPath, + }); + + gitInit.on('close', () => { + logger.info('[Templates] Initialized fresh git repository'); + resolve(); + }); + + gitInit.on('error', () => { + logger.warn('[Templates] Could not initialize git'); + resolve(); + }); + }); + + logger.info(`[Templates] Successfully cloned template to ${projectPath}`); + + res.json({ + success: true, + projectPath, + projectName: sanitizedName, + }); + } catch (error) { + logError(error, 'Clone template failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/common.ts b/jules_branch/apps/server/src/routes/terminal/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e8b6b32923b39131ad6f1aea9c2efb71fa8098c --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/common.ts @@ -0,0 +1,149 @@ +/** + * Common utilities and state for terminal routes + */ + +import { randomBytes } from 'crypto'; +import { createLogger } from '@automaker/utils'; +import type { Request, Response, NextFunction } from 'express'; + +const logger = createLogger('Terminal'); + +// Read env variables lazily to ensure dotenv has loaded them +function getTerminalPassword(): string | undefined { + return process.env.TERMINAL_PASSWORD; +} + +function getTerminalEnabledConfig(): boolean { + return process.env.TERMINAL_ENABLED !== 'false'; // Enabled by default +} + +// In-memory session tokens (would use Redis in production) - private +const validTokens: Map = new Map(); +const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Add a token to the valid tokens map + */ +export function addToken(token: string, data: { createdAt: Date; expiresAt: Date }): void { + validTokens.set(token, data); +} + +/** + * Delete a token from the valid tokens map + */ +export function deleteToken(token: string): void { + validTokens.delete(token); +} + +/** + * Get token data for a given token + */ +export function getTokenData(token: string): { createdAt: Date; expiresAt: Date } | undefined { + return validTokens.get(token); +} + +/** + * Generate a cryptographically secure random token + */ +export function generateToken(): string { + return `term-${randomBytes(32).toString('base64url')}`; +} + +/** + * Clean up expired tokens + */ +export function cleanupExpiredTokens(): void { + const now = new Date(); + validTokens.forEach((data, token) => { + if (data.expiresAt < now) { + validTokens.delete(token); + } + }); +} + +// Clean up expired tokens every 5 minutes +setInterval(cleanupExpiredTokens, 5 * 60 * 1000); + +/** + * Validate a terminal session token + */ +export function validateTerminalToken(token: string | undefined): boolean { + if (!token) return false; + + const tokenData = validTokens.get(token); + if (!tokenData) return false; + + if (tokenData.expiresAt < new Date()) { + validTokens.delete(token); + return false; + } + + return true; +} + +/** + * Check if terminal requires password + */ +export function isTerminalPasswordRequired(): boolean { + return !!getTerminalPassword(); +} + +/** + * Check if terminal is enabled + */ +export function isTerminalEnabled(): boolean { + return getTerminalEnabledConfig(); +} + +/** + * Terminal authentication middleware + * Checks for valid session token if password is configured + */ +export function terminalAuthMiddleware(req: Request, res: Response, next: NextFunction): void { + // Check if terminal is enabled + if (!getTerminalEnabledConfig()) { + res.status(403).json({ + success: false, + error: 'Terminal access is disabled', + }); + return; + } + + // If no password configured, allow all requests + if (!getTerminalPassword()) { + next(); + return; + } + + // Check for session token + const token = (req.headers['x-terminal-token'] as string) || (req.query.token as string); + + if (!validateTerminalToken(token)) { + res.status(401).json({ + success: false, + error: 'Terminal authentication required', + passwordRequired: true, + }); + return; + } + + next(); +} + +export function getTerminalPasswordConfig(): string | undefined { + return getTerminalPassword(); +} + +export function getTerminalEnabledConfigValue(): boolean { + return getTerminalEnabledConfig(); +} + +export function getTokenExpiryMs(): number { + return TOKEN_EXPIRY_MS; +} + +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/terminal/index.ts b/jules_branch/apps/server/src/routes/terminal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..380801e521fdb8d147ac1a42a802de747eb6759d --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/index.ts @@ -0,0 +1,44 @@ +/** + * Terminal routes with password protection + * + * Provides REST API for terminal session management and authentication. + * WebSocket connections for real-time I/O are handled separately in index.ts. + */ + +import { Router } from 'express'; +import { + terminalAuthMiddleware, + validateTerminalToken, + isTerminalEnabled, + isTerminalPasswordRequired, +} from './common.js'; +import { createStatusHandler } from './routes/status.js'; +import { createAuthHandler } from './routes/auth.js'; +import { createLogoutHandler } from './routes/logout.js'; +import { createSessionsListHandler, createSessionsCreateHandler } from './routes/sessions.js'; +import { createSessionDeleteHandler } from './routes/session-delete.js'; +import { createSessionResizeHandler } from './routes/session-resize.js'; +import { createSettingsGetHandler, createSettingsUpdateHandler } from './routes/settings.js'; + +// Re-export for use in main index.ts +export { validateTerminalToken, isTerminalEnabled, isTerminalPasswordRequired }; + +export function createTerminalRoutes(): Router { + const router = Router(); + + router.get('/status', createStatusHandler()); + router.post('/auth', createAuthHandler()); + router.post('/logout', createLogoutHandler()); + + // Apply terminal auth middleware to all routes below + router.use(terminalAuthMiddleware); + + router.get('/sessions', createSessionsListHandler()); + router.post('/sessions', createSessionsCreateHandler()); + router.delete('/sessions/:id', createSessionDeleteHandler()); + router.post('/sessions/:id/resize', createSessionResizeHandler()); + router.get('/settings', createSettingsGetHandler()); + router.put('/settings', createSettingsUpdateHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/auth.ts b/jules_branch/apps/server/src/routes/terminal/routes/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..0aa29b345de53136df9872cab8aff6b2e269bf39 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/auth.ts @@ -0,0 +1,65 @@ +/** + * POST /auth endpoint - Authenticate with password to get a session token + */ + +import type { Request, Response } from 'express'; +import { + getTerminalEnabledConfigValue, + getTerminalPasswordConfig, + generateToken, + addToken, + getTokenExpiryMs, +} from '../common.js'; + +export function createAuthHandler() { + return (req: Request, res: Response): void => { + if (!getTerminalEnabledConfigValue()) { + res.status(403).json({ + success: false, + error: 'Terminal access is disabled', + }); + return; + } + + const terminalPassword = getTerminalPasswordConfig(); + + // If no password required, return immediate success + if (!terminalPassword) { + res.json({ + success: true, + data: { + authenticated: true, + passwordRequired: false, + }, + }); + return; + } + + const { password } = req.body; + + if (!password || password !== terminalPassword) { + res.status(401).json({ + success: false, + error: 'Invalid password', + }); + return; + } + + // Generate session token + const token = generateToken(); + const now = new Date(); + addToken(token, { + createdAt: now, + expiresAt: new Date(now.getTime() + getTokenExpiryMs()), + }); + + res.json({ + success: true, + data: { + authenticated: true, + token, + expiresIn: getTokenExpiryMs(), + }, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/logout.ts b/jules_branch/apps/server/src/routes/terminal/routes/logout.ts new file mode 100644 index 0000000000000000000000000000000000000000..2af85713c4b55be211cf0cdead69f3c980e3acf6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/logout.ts @@ -0,0 +1,20 @@ +/** + * POST /logout endpoint - Invalidate a session token + */ + +import type { Request, Response } from 'express'; +import { deleteToken } from '../common.js'; + +export function createLogoutHandler() { + return (req: Request, res: Response): void => { + const token = (req.headers['x-terminal-token'] as string) || req.body.token; + + if (token) { + deleteToken(token); + } + + res.json({ + success: true, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/session-delete.ts b/jules_branch/apps/server/src/routes/terminal/routes/session-delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..dec3c69434e6e834ed749e2cc76f153c23df1e01 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/session-delete.ts @@ -0,0 +1,26 @@ +/** + * DELETE /sessions/:id endpoint - Kill a terminal session + */ + +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; + +export function createSessionDeleteHandler() { + return (req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const { id } = req.params; + const killed = terminalService.killSession(id); + + if (!killed) { + res.status(404).json({ + success: false, + error: 'Session not found', + }); + return; + } + + res.json({ + success: true, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/session-resize.ts b/jules_branch/apps/server/src/routes/terminal/routes/session-resize.ts new file mode 100644 index 0000000000000000000000000000000000000000..41db9763093772757dc9783bd3911d91553ce048 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/session-resize.ts @@ -0,0 +1,36 @@ +/** + * POST /sessions/:id/resize endpoint - Resize a terminal session + */ + +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; + +export function createSessionResizeHandler() { + return (req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const { id } = req.params; + const { cols, rows } = req.body; + + if (!cols || !rows) { + res.status(400).json({ + success: false, + error: 'cols and rows are required', + }); + return; + } + + const resized = terminalService.resize(id, cols, rows); + + if (!resized) { + res.status(404).json({ + success: false, + error: 'Session not found', + }); + return; + } + + res.json({ + success: true, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/sessions.ts b/jules_branch/apps/server/src/routes/terminal/routes/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d4b5383640074ef6bfb58ae5cefba8390ab8df7 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/sessions.ts @@ -0,0 +1,70 @@ +/** + * GET /sessions endpoint - List all active terminal sessions + * POST /sessions endpoint - Create a new terminal session + */ + +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Terminal'); + +export function createSessionsListHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + const sessions = terminalService.getAllSessions(); + res.json({ + success: true, + data: sessions, + }); + }; +} + +export function createSessionsCreateHandler() { + return async (req: Request, res: Response): Promise => { + try { + const terminalService = getTerminalService(); + const { cwd, cols, rows, shell } = req.body; + + const session = await terminalService.createSession({ + cwd, + cols: cols || 80, + rows: rows || 24, + shell, + }); + + // Check if session creation was refused due to limit + if (!session) { + const maxSessions = terminalService.getMaxSessions(); + const currentSessions = terminalService.getSessionCount(); + logger.warn(`Session limit reached: ${currentSessions}/${maxSessions}`); + res.status(429).json({ + success: false, + error: 'Maximum terminal sessions reached', + details: `Server limit is ${maxSessions} concurrent sessions. Please close unused terminals.`, + currentSessions, + maxSessions, + }); + return; + } + + res.json({ + success: true, + data: { + id: session.id, + cwd: session.cwd, + shell: session.shell, + createdAt: session.createdAt, + }, + }); + } catch (error) { + logError(error, 'Create terminal session failed'); + res.status(500).json({ + success: false, + error: 'Failed to create terminal session', + details: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/settings.ts b/jules_branch/apps/server/src/routes/terminal/routes/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d8146065faa13d4b1a5e986f4ef346b9c0c5456 --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/settings.ts @@ -0,0 +1,83 @@ +/** + * GET/PUT /settings endpoint - Get/Update terminal settings + */ + +import type { Request, Response } from 'express'; +import { + getTerminalService, + MIN_MAX_SESSIONS, + MAX_MAX_SESSIONS, +} from '../../../services/terminal-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createSettingsGetHandler() { + return (_req: Request, res: Response): void => { + try { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, 'Get terminal settings failed'); + res.status(500).json({ + success: false, + error: 'Failed to get terminal settings', + details: getErrorMessage(error), + }); + } + }; +} + +export function createSettingsUpdateHandler() { + return (req: Request, res: Response): void => { + try { + const terminalService = getTerminalService(); + const { maxSessions } = req.body; + + // Validate maxSessions if provided + if (maxSessions !== undefined) { + if (typeof maxSessions !== 'number') { + res.status(400).json({ + success: false, + error: 'maxSessions must be a number', + }); + return; + } + if (!Number.isInteger(maxSessions)) { + res.status(400).json({ + success: false, + error: 'maxSessions must be an integer', + }); + return; + } + if (maxSessions < MIN_MAX_SESSIONS || maxSessions > MAX_MAX_SESSIONS) { + res.status(400).json({ + success: false, + error: `maxSessions must be between ${MIN_MAX_SESSIONS} and ${MAX_MAX_SESSIONS}`, + }); + return; + } + terminalService.setMaxSessions(maxSessions); + } + + res.json({ + success: true, + data: { + maxSessions: terminalService.getMaxSessions(), + currentSessions: terminalService.getSessionCount(), + }, + }); + } catch (error) { + logError(error, 'Update terminal settings failed'); + res.status(500).json({ + success: false, + error: 'Failed to update terminal settings', + details: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/terminal/routes/status.ts b/jules_branch/apps/server/src/routes/terminal/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..670b405c3bfe4ce733895fdc711c48a783f9e88e --- /dev/null +++ b/jules_branch/apps/server/src/routes/terminal/routes/status.ts @@ -0,0 +1,21 @@ +/** + * GET /status endpoint - Get terminal status + */ + +import type { Request, Response } from 'express'; +import { getTerminalService } from '../../../services/terminal-service.js'; +import { getTerminalEnabledConfigValue, isTerminalPasswordRequired } from '../common.js'; + +export function createStatusHandler() { + return (_req: Request, res: Response): void => { + const terminalService = getTerminalService(); + res.json({ + success: true, + data: { + enabled: getTerminalEnabledConfigValue(), + passwordRequired: isTerminalPasswordRequired(), + platform: terminalService.getPlatformInfo(), + }, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/workspace/common.ts b/jules_branch/apps/server/src/routes/workspace/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..bec656dda5d9ae586bc7a0b787afd3dbdb189b81 --- /dev/null +++ b/jules_branch/apps/server/src/routes/workspace/common.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for workspace routes + */ + +import { createLogger } from '@automaker/utils'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Workspace'); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); diff --git a/jules_branch/apps/server/src/routes/workspace/index.ts b/jules_branch/apps/server/src/routes/workspace/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..374249975c48522200f96fe79080ea8599877efa --- /dev/null +++ b/jules_branch/apps/server/src/routes/workspace/index.ts @@ -0,0 +1,17 @@ +/** + * Workspace routes + * Provides API endpoints for workspace directory management + */ + +import { Router } from 'express'; +import { createConfigHandler } from './routes/config.js'; +import { createDirectoriesHandler } from './routes/directories.js'; + +export function createWorkspaceRoutes(): Router { + const router = Router(); + + router.get('/config', createConfigHandler()); + router.get('/directories', createDirectoriesHandler()); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/workspace/routes/config.ts b/jules_branch/apps/server/src/routes/workspace/routes/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ea5cbee6bbfb1ed485b8fba95b691506ffafb44 --- /dev/null +++ b/jules_branch/apps/server/src/routes/workspace/routes/config.ts @@ -0,0 +1,58 @@ +/** + * GET /config endpoint - Get workspace configuration status + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getAllowedRootDirectory, getDataDirectory } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createConfigHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const allowedRootDirectory = getAllowedRootDirectory(); + const dataDirectory = getDataDirectory(); + + if (!allowedRootDirectory) { + // When ALLOWED_ROOT_DIRECTORY is not set, return DATA_DIR as default directory + res.json({ + success: true, + configured: false, + defaultDir: dataDirectory || null, + }); + return; + } + + // Check if the directory exists + try { + const resolvedWorkspaceDir = path.resolve(allowedRootDirectory); + const stats = await secureFs.stat(resolvedWorkspaceDir); + if (!stats.isDirectory()) { + res.json({ + success: true, + configured: false, + error: 'ALLOWED_ROOT_DIRECTORY is not a valid directory', + }); + return; + } + + res.json({ + success: true, + configured: true, + workspaceDir: resolvedWorkspaceDir, + defaultDir: resolvedWorkspaceDir, + }); + } catch { + res.json({ + success: true, + configured: false, + error: 'ALLOWED_ROOT_DIRECTORY path does not exist', + }); + } + } catch (error) { + logError(error, 'Get workspace config failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/workspace/routes/directories.ts b/jules_branch/apps/server/src/routes/workspace/routes/directories.ts new file mode 100644 index 0000000000000000000000000000000000000000..09a66e1b9e2aaa5c97496199d9b32c281f90a469 --- /dev/null +++ b/jules_branch/apps/server/src/routes/workspace/routes/directories.ts @@ -0,0 +1,60 @@ +/** + * GET /directories endpoint - List directories in workspace + */ + +import type { Request, Response } from 'express'; +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getAllowedRootDirectory } from '@automaker/platform'; +import { getErrorMessage, logError } from '../common.js'; + +export function createDirectoriesHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const allowedRootDirectory = getAllowedRootDirectory(); + + if (!allowedRootDirectory) { + res.status(400).json({ + success: false, + error: 'ALLOWED_ROOT_DIRECTORY is not configured', + }); + return; + } + + const resolvedWorkspaceDir = path.resolve(allowedRootDirectory); + + // Check if directory exists + try { + await secureFs.stat(resolvedWorkspaceDir); + } catch { + res.status(400).json({ + success: false, + error: 'Workspace directory path does not exist', + }); + return; + } + + // Read directory contents + const entries = await secureFs.readdir(resolvedWorkspaceDir, { + withFileTypes: true, + }); + + // Filter to directories only and map to result format + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => ({ + name: entry.name, + path: path.join(resolvedWorkspaceDir, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.json({ + success: true, + directories, + }); + } catch (error) { + logError(error, 'List workspace directories failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/common.ts b/jules_branch/apps/server/src/routes/worktree/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ceb50bf31e131c682a4f6dd761a151565f486ef --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/common.ts @@ -0,0 +1,164 @@ +/** + * Common utilities for worktree routes + */ + +import { + createLogger, + isValidBranchName, + isValidRemoteName, + MAX_BRANCH_NAME_LENGTH, +} from '@automaker/utils'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +// Re-export execGitCommand from the canonical shared module so any remaining +// consumers that import from this file continue to work. +export { execGitCommand } from '../../lib/git.js'; + +const logger = createLogger('Worktree'); +export const execAsync = promisify(exec); + +// Re-export git validation utilities from the canonical shared module so +// existing consumers that import from this file continue to work. +export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH }; + +// ============================================================================ +// Extended PATH configuration for Electron apps +// ============================================================================ + +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const additionalPaths: string[] = []; + +if (process.platform === 'win32') { + // Windows paths + if (process.env.LOCALAPPDATA) { + additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + } + if (process.env.PROGRAMFILES) { + additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + } + if (process.env['ProgramFiles(x86)']) { + additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); + } +} else { + // Unix/Mac paths + additionalPaths.push( + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/usr/local/bin', // Homebrew on Intel Mac, common Linux location + '/home/linuxbrew/.linuxbrew/bin', // Linuxbrew + `${process.env.HOME}/.local/bin` // pipx, other user installs + ); +} + +const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)] + .filter(Boolean) + .join(pathSeparator); + +/** + * Environment variables with extended PATH for executing shell commands. + * Electron apps don't inherit the user's shell PATH, so we need to add + * common tool installation locations. + */ +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +/** + * Check if gh CLI is available on the system + */ +export async function isGhCliAvailable(): Promise { + try { + const checkCommand = process.platform === 'win32' ? 'where gh' : 'command -v gh'; + await execAsync(checkCommand, { env: execEnv }); + return true; + } catch { + return false; + } +} + +export const AUTOMAKER_INITIAL_COMMIT_MESSAGE = 'chore: automaker initial commit'; + +/** + * Normalize path separators to forward slashes for cross-platform consistency. + * This ensures paths from `path.join()` (backslashes on Windows) match paths + * from git commands (which may use forward slashes). + */ +export function normalizePath(p: string): string { + return p.replace(/\\/g, '/'); +} + +/** + * Check if a git repository has at least one commit (i.e., HEAD exists) + * Returns false for freshly initialized repos with no commits + */ +export async function hasCommits(repoPath: string): Promise { + try { + await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); + return true; + } catch { + return false; + } +} + +/** + * Check if an error is ENOENT (file/path not found or spawn failed) + * These are expected in test environments with mock paths + */ +export function isENOENT(error: unknown): boolean { + return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'; +} + +/** + * Check if a path is a mock/test path that doesn't exist + */ +export function isMockPath(worktreePath: string): boolean { + return worktreePath.startsWith('/mock/') || worktreePath.includes('/mock/'); +} + +/** + * Conditionally log worktree errors - suppress ENOENT for mock paths + * to reduce noise in test output + */ +export function logWorktreeError(error: unknown, message: string, worktreePath?: string): void { + // Don't log ENOENT errors for mock paths (expected in tests) + if (isENOENT(error) && worktreePath && isMockPath(worktreePath)) { + return; + } + logError(error, message); +} + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); + +/** + * Ensure the repository has at least one commit so git commands that rely on HEAD work. + * Returns true if an empty commit was created, false if the repo already had commits. + * @param repoPath - Path to the git repository + * @param env - Optional environment variables to pass to git (e.g., GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL) + */ +export async function ensureInitialCommit( + repoPath: string, + env?: Record +): Promise { + try { + await execAsync('git rev-parse --verify HEAD', { cwd: repoPath }); + return false; + } catch { + try { + await execAsync(`git commit --allow-empty -m "${AUTOMAKER_INITIAL_COMMIT_MESSAGE}"`, { + cwd: repoPath, + env: { ...process.env, ...env }, + }); + logger.info(`[Worktree] Created initial empty commit to enable worktrees in ${repoPath}`); + return true; + } catch (error) { + const reason = getErrorMessageShared(error); + throw new Error( + `Failed to create initial git commit. Please commit manually and retry. ${reason}` + ); + } + } +} diff --git a/jules_branch/apps/server/src/routes/worktree/index.ts b/jules_branch/apps/server/src/routes/worktree/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d786616b2fb12463516be7ee43c287dde496b38d --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/index.ts @@ -0,0 +1,341 @@ +/** + * Worktree routes - HTTP API for git worktree operations + */ + +import { Router } from 'express'; +import type { EventEmitter } from '../../lib/events.js'; +import { validatePathParams } from '../../middleware/validate-paths.js'; +import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js'; +import { createInfoHandler } from './routes/info.js'; +import { createStatusHandler } from './routes/status.js'; +import { createListHandler } from './routes/list.js'; +import { createDiffsHandler } from './routes/diffs.js'; +import { createFileDiffHandler } from './routes/file-diff.js'; +import { createMergeHandler } from './routes/merge.js'; +import { createCreateHandler } from './routes/create.js'; +import { createDeleteHandler } from './routes/delete.js'; +import { createCreatePRHandler } from './routes/create-pr.js'; +import { createPRInfoHandler } from './routes/pr-info.js'; +import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; +import { createPushHandler } from './routes/push.js'; +import { createPullHandler } from './routes/pull.js'; +import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; +import { createListBranchesHandler } from './routes/list-branches.js'; +import { createSwitchBranchHandler } from './routes/switch-branch.js'; +import { + createOpenInEditorHandler, + createGetDefaultEditorHandler, + createGetAvailableEditorsHandler, + createRefreshEditorsHandler, +} from './routes/open-in-editor.js'; +import { + createOpenInTerminalHandler, + createGetAvailableTerminalsHandler, + createGetDefaultTerminalHandler, + createRefreshTerminalsHandler, + createOpenInExternalTerminalHandler, +} from './routes/open-in-terminal.js'; +import { createInitGitHandler } from './routes/init-git.js'; +import { createMigrateHandler } from './routes/migrate.js'; +import { createStartDevHandler } from './routes/start-dev.js'; +import { createStopDevHandler } from './routes/stop-dev.js'; +import { createListDevServersHandler } from './routes/list-dev-servers.js'; +import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js'; +import { createStartTestsHandler } from './routes/start-tests.js'; +import { createStopTestsHandler } from './routes/stop-tests.js'; +import { createGetTestLogsHandler } from './routes/test-logs.js'; +import { + createGetInitScriptHandler, + createPutInitScriptHandler, + createDeleteInitScriptHandler, + createRunInitScriptHandler, +} from './routes/init-script.js'; +import { createCommitLogHandler } from './routes/commit-log.js'; +import { createDiscardChangesHandler } from './routes/discard-changes.js'; +import { createListRemotesHandler } from './routes/list-remotes.js'; +import { createAddRemoteHandler } from './routes/add-remote.js'; +import { createStashPushHandler } from './routes/stash-push.js'; +import { createStashListHandler } from './routes/stash-list.js'; +import { createStashApplyHandler } from './routes/stash-apply.js'; +import { createStashDropHandler } from './routes/stash-drop.js'; +import { createCherryPickHandler } from './routes/cherry-pick.js'; +import { createBranchCommitLogHandler } from './routes/branch-commit-log.js'; +import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js'; +import { createRebaseHandler } from './routes/rebase.js'; +import { createAbortOperationHandler } from './routes/abort-operation.js'; +import { createContinueOperationHandler } from './routes/continue-operation.js'; +import { createStageFilesHandler } from './routes/stage-files.js'; +import { createCheckChangesHandler } from './routes/check-changes.js'; +import { createSetTrackingHandler } from './routes/set-tracking.js'; +import { createSyncHandler } from './routes/sync.js'; +import { createUpdatePRNumberHandler } from './routes/update-pr-number.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import type { FeatureLoader } from '../../services/feature-loader.js'; + +export function createWorktreeRoutes( + events: EventEmitter, + settingsService?: SettingsService, + featureLoader?: FeatureLoader +): Router { + const router = Router(); + + router.post('/info', validatePathParams('projectPath'), createInfoHandler()); + router.post('/status', validatePathParams('projectPath'), createStatusHandler()); + router.post('/list', createListHandler()); + router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler()); + router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler()); + router.post( + '/merge', + validatePathParams('projectPath'), + requireValidProject, + createMergeHandler(events) + ); + router.post( + '/create', + validatePathParams('projectPath'), + createCreateHandler(events, settingsService) + ); + router.post( + '/delete', + validatePathParams('projectPath', 'worktreePath'), + createDeleteHandler(events, featureLoader) + ); + router.post('/create-pr', createCreatePRHandler()); + router.post('/pr-info', createPRInfoHandler()); + router.post( + '/update-pr-number', + validatePathParams('worktreePath', 'projectPath?'), + requireValidWorktree, + createUpdatePRNumberHandler() + ); + router.post( + '/commit', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createCommitHandler() + ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler(settingsService) + ); + router.post( + '/push', + validatePathParams('worktreePath'), + requireValidWorktree, + createPushHandler() + ); + router.post( + '/pull', + validatePathParams('worktreePath'), + requireValidWorktree, + createPullHandler() + ); + router.post( + '/sync', + validatePathParams('worktreePath'), + requireValidWorktree, + createSyncHandler() + ); + router.post( + '/set-tracking', + validatePathParams('worktreePath'), + requireValidWorktree, + createSetTrackingHandler() + ); + router.post( + '/checkout-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createCheckoutBranchHandler(events) + ); + router.post( + '/check-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createCheckChangesHandler() + ); + router.post( + '/list-branches', + validatePathParams('worktreePath'), + requireValidWorktree, + createListBranchesHandler() + ); + router.post( + '/switch-branch', + validatePathParams('worktreePath'), + requireValidWorktree, + createSwitchBranchHandler(events) + ); + router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler()); + router.post( + '/open-in-terminal', + validatePathParams('worktreePath'), + createOpenInTerminalHandler() + ); + router.get('/default-editor', createGetDefaultEditorHandler()); + router.get('/available-editors', createGetAvailableEditorsHandler()); + router.post('/refresh-editors', createRefreshEditorsHandler()); + + // External terminal routes + router.get('/available-terminals', createGetAvailableTerminalsHandler()); + router.get('/default-terminal', createGetDefaultTerminalHandler()); + router.post('/refresh-terminals', createRefreshTerminalsHandler()); + router.post( + '/open-in-external-terminal', + validatePathParams('worktreePath'), + createOpenInExternalTerminalHandler() + ); + + router.post('/init-git', validatePathParams('projectPath'), createInitGitHandler()); + router.post('/migrate', createMigrateHandler()); + router.post( + '/start-dev', + validatePathParams('projectPath', 'worktreePath'), + createStartDevHandler(settingsService) + ); + router.post('/stop-dev', createStopDevHandler()); + router.post('/list-dev-servers', createListDevServersHandler()); + router.get( + '/dev-server-logs', + validatePathParams('worktreePath'), + createGetDevServerLogsHandler() + ); + + // Test runner routes + router.post( + '/start-tests', + validatePathParams('worktreePath', 'projectPath?'), + createStartTestsHandler(settingsService) + ); + router.post('/stop-tests', createStopTestsHandler()); + router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler()); + + // Init script routes + router.get('/init-script', createGetInitScriptHandler()); + router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler()); + router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler()); + router.post( + '/run-init-script', + validatePathParams('projectPath', 'worktreePath'), + createRunInitScriptHandler(events) + ); + + // Discard changes route + router.post( + '/discard-changes', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createDiscardChangesHandler() + ); + + // List remotes route + router.post( + '/list-remotes', + validatePathParams('worktreePath'), + requireValidWorktree, + createListRemotesHandler() + ); + + // Add remote route + router.post( + '/add-remote', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createAddRemoteHandler() + ); + + // Commit log route + router.post( + '/commit-log', + validatePathParams('worktreePath'), + requireValidWorktree, + createCommitLogHandler(events) + ); + + // Stash routes + router.post( + '/stash-push', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashPushHandler(events) + ); + router.post( + '/stash-list', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashListHandler(events) + ); + router.post( + '/stash-apply', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashApplyHandler(events) + ); + router.post( + '/stash-drop', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createStashDropHandler(events) + ); + + // Cherry-pick route + router.post( + '/cherry-pick', + validatePathParams('worktreePath'), + requireValidWorktree, + createCherryPickHandler(events) + ); + + // Generate PR description route + router.post( + '/generate-pr-description', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGeneratePRDescriptionHandler(settingsService) + ); + + // Branch commit log route (get commits from a specific branch) + router.post( + '/branch-commit-log', + validatePathParams('worktreePath'), + requireValidWorktree, + createBranchCommitLogHandler(events) + ); + + // Rebase route + router.post( + '/rebase', + validatePathParams('worktreePath'), + requireValidWorktree, + createRebaseHandler(events) + ); + + // Abort in-progress merge/rebase/cherry-pick + router.post( + '/abort-operation', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createAbortOperationHandler(events) + ); + + // Continue in-progress merge/rebase/cherry-pick after resolving conflicts + router.post( + '/continue-operation', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createContinueOperationHandler(events) + ); + + // Stage/unstage files route + router.post( + '/stage-files', + validatePathParams('worktreePath', 'files[]'), + requireGitRepoOnly, + createStageFilesHandler() + ); + + return router; +} diff --git a/jules_branch/apps/server/src/routes/worktree/middleware.ts b/jules_branch/apps/server/src/routes/worktree/middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb83377f6a0dfbb96b290e67e3ab7806eac29efb --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/middleware.ts @@ -0,0 +1,75 @@ +/** + * Middleware for worktree route validation + */ + +import type { Request, Response, NextFunction } from 'express'; +import { isGitRepo } from '@automaker/git-utils'; +import { hasCommits } from './common.js'; + +interface ValidationOptions { + /** Check if the path is a git repository (default: true) */ + requireGitRepo?: boolean; + /** Check if the repository has at least one commit (default: true) */ + requireCommits?: boolean; + /** The name of the request body field containing the path (default: 'worktreePath') */ + pathField?: 'worktreePath' | 'projectPath'; +} + +/** + * Middleware factory to validate that a path is a valid git repository with commits. + * This reduces code duplication across route handlers. + * + * @param options - Validation options + * @returns Express middleware function + */ +export function requireValidGitRepo(options: ValidationOptions = {}) { + const { requireGitRepo = true, requireCommits = true, pathField = 'worktreePath' } = options; + + return async (req: Request, res: Response, next: NextFunction): Promise => { + const repoPath = req.body[pathField] as string | undefined; + + if (!repoPath) { + // Let the route handler deal with missing path validation + next(); + return; + } + + if (requireGitRepo && !(await isGitRepo(repoPath))) { + res.status(400).json({ + success: false, + error: 'Not a git repository', + code: 'NOT_GIT_REPO', + }); + return; + } + + if (requireCommits && !(await hasCommits(repoPath))) { + res.status(400).json({ + success: false, + error: 'Repository has no commits yet', + code: 'NO_COMMITS', + }); + return; + } + + next(); + }; +} + +/** + * Middleware to validate git repo for worktreePath field + */ +export const requireValidWorktree = requireValidGitRepo({ pathField: 'worktreePath' }); + +/** + * Middleware to validate git repo for projectPath field + */ +export const requireValidProject = requireValidGitRepo({ pathField: 'projectPath' }); + +/** + * Middleware to validate git repo without requiring commits (for commit route) + */ +export const requireGitRepoOnly = requireValidGitRepo({ + pathField: 'worktreePath', + requireCommits: false, +}); diff --git a/jules_branch/apps/server/src/routes/worktree/routes/abort-operation.ts b/jules_branch/apps/server/src/routes/worktree/routes/abort-operation.ts new file mode 100644 index 0000000000000000000000000000000000000000..297e2ac8a8d00dca545c9dc69c49a9f9697b0714 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/abort-operation.ts @@ -0,0 +1,117 @@ +/** + * POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick + * + * Detects which operation (merge, rebase, or cherry-pick) is in progress + * and aborts it, returning the repository to a clean state. + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as fs from 'fs/promises'; +import { getErrorMessage, logError, execAsync } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; + +/** + * Detect what type of conflict operation is currently in progress + */ +async function detectOperation( + worktreePath: string +): Promise<'merge' | 'rebase' | 'cherry-pick' | null> { + try { + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { + cwd: worktreePath, + }); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] = + await Promise.all([ + fs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + if (rebaseMergeExists || rebaseApplyExists) return 'rebase'; + if (mergeHeadExists) return 'merge'; + if (cherryPickHeadExists) return 'cherry-pick'; + return null; + } catch { + return null; + } +} + +export function createAbortOperationHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + const resolvedWorktreePath = path.resolve(worktreePath); + + // Detect what operation is in progress + const operation = await detectOperation(resolvedWorktreePath); + + if (!operation) { + res.status(400).json({ + success: false, + error: 'No merge, rebase, or cherry-pick in progress', + }); + return; + } + + // Abort the operation + let abortCommand: string; + switch (operation) { + case 'merge': + abortCommand = 'git merge --abort'; + break; + case 'rebase': + abortCommand = 'git rebase --abort'; + break; + case 'cherry-pick': + abortCommand = 'git cherry-pick --abort'; + break; + } + + await execAsync(abortCommand, { cwd: resolvedWorktreePath }); + + // Emit event + events.emit('conflict:aborted', { + worktreePath: resolvedWorktreePath, + operation, + }); + + res.json({ + success: true, + result: { + operation, + message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`, + }, + }); + } catch (error) { + logError(error, 'Abort operation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/add-remote.ts b/jules_branch/apps/server/src/routes/worktree/routes/add-remote.ts new file mode 100644 index 0000000000000000000000000000000000000000..2938920367fec184a851761fb178e76ce3b0e238 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/add-remote.ts @@ -0,0 +1,166 @@ +/** + * POST /add-remote endpoint - Add a new remote to a git repository + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError } from '../common.js'; + +const execFileAsync = promisify(execFile); + +/** Maximum allowed length for remote names */ +const MAX_REMOTE_NAME_LENGTH = 250; + +/** Maximum allowed length for remote URLs */ +const MAX_REMOTE_URL_LENGTH = 2048; + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30000; + +/** + * Validate remote name - must be alphanumeric with dashes/underscores + * Git remote names have similar restrictions to branch names + */ +function isValidRemoteName(name: string): boolean { + // Remote names should be alphanumeric, may contain dashes, underscores, periods + // Cannot start with a dash or period, cannot be empty + if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) { + return false; + } + return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name); +} + +/** + * Validate remote URL - basic validation for git remote URLs + * Supports HTTPS, SSH, and git:// protocols + */ +function isValidRemoteUrl(url: string): boolean { + if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) { + return false; + } + // Support common git URL formats: + // - https://github.com/user/repo.git + // - git@github.com:user/repo.git + // - git://github.com/user/repo.git + // - ssh://git@github.com/user/repo.git + const httpsPattern = /^https?:\/\/.+/; + const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/; + const gitProtocolPattern = /^git:\/\/.+/; + const sshProtocolPattern = /^ssh:\/\/.+/; + + return ( + httpsPattern.test(url) || + sshPattern.test(url) || + gitProtocolPattern.test(url) || + sshProtocolPattern.test(url) + ); +} + +export function createAddRemoteHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remoteName, remoteUrl } = req.body as { + worktreePath: string; + remoteName: string; + remoteUrl: string; + }; + + // Validate required fields + const requiredFields = { worktreePath, remoteName, remoteUrl }; + for (const [key, value] of Object.entries(requiredFields)) { + if (!value) { + res.status(400).json({ success: false, error: `${key} required` }); + return; + } + } + + // Validate remote name + if (!isValidRemoteName(remoteName)) { + res.status(400).json({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + return; + } + + // Validate remote URL + if (!isValidRemoteUrl(remoteUrl)) { + res.status(400).json({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + return; + } + + // Check if remote already exists + try { + const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], { + cwd: worktreePath, + }); + const remoteNames = existingRemotes + .trim() + .split('\n') + .filter((r) => r.trim()); + if (remoteNames.includes(remoteName)) { + res.status(400).json({ + success: false, + error: `Remote '${remoteName}' already exists`, + code: 'REMOTE_EXISTS', + }); + return; + } + } catch (error) { + // If git remote fails, continue with adding the remote. Log for debugging. + logWorktreeError( + error, + 'Checking for existing remotes failed, proceeding to add.', + worktreePath + ); + } + + // Add the remote using execFile with array arguments to prevent command injection + await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], { + cwd: worktreePath, + }); + + // Optionally fetch from the new remote to get its branches + let fetchSucceeded = false; + try { + await execFileAsync('git', ['fetch', remoteName, '--quiet'], { + cwd: worktreePath, + timeout: FETCH_TIMEOUT_MS, + }); + fetchSucceeded = true; + } catch (fetchError) { + // Fetch failed (maybe offline or invalid URL), but remote was added successfully + logWorktreeError( + fetchError, + `Fetch from new remote '${remoteName}' failed (remote added successfully)`, + worktreePath + ); + fetchSucceeded = false; + } + + res.json({ + success: true, + result: { + remoteName, + remoteUrl, + fetched: fetchSucceeded, + message: fetchSucceeded + ? `Successfully added remote '${remoteName}' and fetched its branches` + : `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`, + }, + }); + } catch (error) { + const worktreePath = req.body?.worktreePath; + logWorktreeError(error, 'Add remote failed', worktreePath); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/branch-commit-log.ts b/jules_branch/apps/server/src/routes/worktree/routes/branch-commit-log.ts new file mode 100644 index 0000000000000000000000000000000000000000..60562d9758da703b14d48f6a24f1e2a8f6f377e8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/branch-commit-log.ts @@ -0,0 +1,92 @@ +/** + * POST /branch-commit-log endpoint - Get recent commit history for a specific branch + * + * Similar to commit-log but allows specifying a branch name to get commits from + * any branch, not just the currently checked out one. Useful for cherry-pick workflows + * where you need to browse commits from other branches. + * + * The handler only validates input, invokes the service, streams lifecycle events + * via the EventEmitter, and sends the final JSON response. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js'; +import { isValidBranchName } from '@automaker/utils'; + +export function createBranchCommitLogHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { + worktreePath, + branchName, + limit = 20, + } = req.body as { + worktreePath: string; + branchName?: string; + limit?: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Validate branchName before forwarding to execGitCommand. + // Reject values that start with '-', contain NUL, contain path-traversal + // sequences, or include characters outside the safe whitelist. + // An absent branchName is allowed (the service defaults it to HEAD). + if (branchName !== undefined && !isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: 'Invalid branchName: value contains unsafe characters or sequences', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('branchCommitLog:start', { + worktreePath, + branchName: branchName || 'HEAD', + limit, + }); + + // Delegate all Git work to the service + const result = await getBranchCommitLog(worktreePath, branchName, limit); + + // Emit progress with the number of commits fetched + events.emit('branchCommitLog:progress', { + worktreePath, + branchName: result.branch, + commitsLoaded: result.total, + }); + + // Emit done event + events.emit('branchCommitLog:done', { + worktreePath, + branchName: result.branch, + total: result.total, + }); + + res.json({ + success: true, + result, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('branchCommitLog:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Get branch commit log failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/branch-tracking.ts b/jules_branch/apps/server/src/routes/worktree/routes/branch-tracking.ts new file mode 100644 index 0000000000000000000000000000000000000000..4144b94a374ae41ca8af3a9c500ed98bd0730203 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/branch-tracking.ts @@ -0,0 +1,109 @@ +/** + * Branch tracking utilities + * + * Tracks active branches in .automaker so users + * can switch between branches even after worktrees are removed. + */ + +import * as secureFs from '../../../lib/secure-fs.js'; +import path from 'path'; +import { getBranchTrackingPath, ensureAutomakerDir } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('BranchTracking'); + +export interface TrackedBranch { + name: string; + createdAt: string; + lastActivatedAt?: string; +} + +interface BranchTrackingData { + branches: TrackedBranch[]; +} + +/** + * Read tracked branches from file + */ +export async function getTrackedBranches(projectPath: string): Promise { + try { + const filePath = getBranchTrackingPath(projectPath); + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + const data: BranchTrackingData = JSON.parse(content); + return data.branches || []; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + logger.warn('Failed to read tracked branches:', error); + return []; + } +} + +/** + * Save tracked branches to file + */ +async function saveTrackedBranches(projectPath: string, branches: TrackedBranch[]): Promise { + const automakerDir = await ensureAutomakerDir(projectPath); + const filePath = path.join(automakerDir, 'active-branches.json'); + const data: BranchTrackingData = { branches }; + await secureFs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * Add a branch to tracking + */ +export async function trackBranch(projectPath: string, branchName: string): Promise { + const branches = await getTrackedBranches(projectPath); + + // Check if already tracked + const existing = branches.find((b) => b.name === branchName); + if (existing) { + return; // Already tracked + } + + branches.push({ + name: branchName, + createdAt: new Date().toISOString(), + }); + + await saveTrackedBranches(projectPath, branches); + logger.info(`Now tracking branch: ${branchName}`); +} + +/** + * Remove a branch from tracking + */ +export async function untrackBranch(projectPath: string, branchName: string): Promise { + const branches = await getTrackedBranches(projectPath); + const filtered = branches.filter((b) => b.name !== branchName); + + if (filtered.length !== branches.length) { + await saveTrackedBranches(projectPath, filtered); + logger.info(`Stopped tracking branch: ${branchName}`); + } +} + +/** + * Update last activated timestamp for a branch + */ +export async function updateBranchActivation( + projectPath: string, + branchName: string +): Promise { + const branches = await getTrackedBranches(projectPath); + const branch = branches.find((b) => b.name === branchName); + + if (branch) { + branch.lastActivatedAt = new Date().toISOString(); + await saveTrackedBranches(projectPath, branches); + } +} + +/** + * Check if a branch is tracked + */ +export async function isBranchTracked(projectPath: string, branchName: string): Promise { + const branches = await getTrackedBranches(projectPath); + return branches.some((b) => b.name === branchName); +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/check-changes.ts b/jules_branch/apps/server/src/routes/worktree/routes/check-changes.ts new file mode 100644 index 0000000000000000000000000000000000000000..b49ba8ff5ecf8699230f9ecf00e1fd69913a1bdb --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/check-changes.ts @@ -0,0 +1,104 @@ +/** + * POST /check-changes endpoint - Check for uncommitted changes in a worktree + * + * Returns a summary of staged, unstaged, and untracked files to help + * the user decide whether to stash before a branch operation. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; + +/** + * Parse `git status --porcelain` output into categorised file lists. + * + * Porcelain format gives two status characters per line: + * XY filename + * where X is the index (staged) status and Y is the worktree (unstaged) status. + * + * - '?' in both columns → untracked + * - Non-space/non-'?' in X → staged change + * - Non-space/non-'?' in Y (when not untracked) → unstaged change + * + * A file can appear in both staged and unstaged if it was partially staged. + */ +function parseStatusOutput(stdout: string): { + staged: string[]; + unstaged: string[]; + untracked: string[]; +} { + const staged: string[] = []; + const unstaged: string[] = []; + const untracked: string[] = []; + + const lines = stdout.trim().split('\n').filter(Boolean); + + for (const line of lines) { + if (line.length < 3) continue; + + const x = line[0]; // index status + const y = line[1]; // worktree status + // Handle renames which use " -> " separator + const rawPath = line.slice(3); + const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath; + + if (x === '?' && y === '?') { + untracked.push(filePath); + } else { + if (x !== ' ' && x !== '?') { + staged.push(filePath); + } + if (y !== ' ' && y !== '?') { + unstaged.push(filePath); + } + } + } + + return { staged, unstaged, untracked }; +} + +export function createCheckChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Get porcelain status (includes staged, unstaged, and untracked files) + const stdout = await execGitCommand(['status', '--porcelain'], worktreePath); + + const { staged, unstaged, untracked } = parseStatusOutput(stdout); + + const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0; + + // Deduplicate file paths across staged, unstaged, and untracked arrays + // to avoid double-counting partially staged files + const uniqueFilePaths = new Set([...staged, ...unstaged, ...untracked]); + + res.json({ + success: true, + result: { + hasChanges, + staged, + unstaged, + untracked, + totalFiles: uniqueFilePaths.size, + }, + }); + } catch (error) { + logError(error, 'Check changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/checkout-branch.ts b/jules_branch/apps/server/src/routes/worktree/routes/checkout-branch.ts new file mode 100644 index 0000000000000000000000000000000000000000..97b4419b57745ed18eff7b9673ff3eb80b947566 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/checkout-branch.ts @@ -0,0 +1,229 @@ +/** + * POST /checkout-branch endpoint - Create and checkout a new branch + * + * Supports automatic stash handling: when `stashChanges` is true, local changes + * are stashed before creating the branch and reapplied after. If the stash pop + * results in merge conflicts, returns a special response so the UI can create a + * conflict resolution task. + * + * Git business logic is delegated to checkout-branch-service.ts when stash + * handling is requested. Otherwise, falls back to the original simple flow. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts. + * Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams + * middleware in index.ts. + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { stat } from 'fs/promises'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performCheckoutBranch } from '../../../services/checkout-branch-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('CheckoutBranchRoute'); + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * Non-fatal: fetch errors are logged and swallowed so the workflow continues. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (error instanceof Error && error.message === 'Process aborted') { + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs + } finally { + clearTimeout(timerId); + } +} + +export function createCheckoutBranchHandler(events?: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as { + worktreePath: string; + branchName: string; + baseBranch?: string; + /** When true, stash local changes before checkout and reapply after */ + stashChanges?: boolean; + /** When true, include untracked files in the stash (defaults to true) */ + includeUntracked?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (!branchName) { + res.status(400).json({ + success: false, + error: 'branchName required', + }); + return; + } + + // Validate branch name using shared allowlist: /^[a-zA-Z0-9._\-/]+$/ + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', + }); + return; + } + + // Validate base branch if provided + if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') { + res.status(400).json({ + success: false, + error: + 'Invalid base branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.', + }); + return; + } + + // Resolve and validate worktreePath to prevent traversal attacks. + const resolvedPath = path.resolve(worktreePath); + try { + const stats = await stat(resolvedPath); + if (!stats.isDirectory()) { + res.status(400).json({ + success: false, + error: 'worktreePath is not a directory', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'worktreePath does not exist or is not accessible', + }); + return; + } + + // Use the service for stash-aware checkout + if (stashChanges) { + const result = await performCheckoutBranch( + resolvedPath, + branchName, + baseBranch, + { + stashChanges: true, + includeUntracked: includeUntracked ?? true, + }, + events + ); + + if (!result.success) { + const statusCode = isBranchError(result.error) ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + ...(result.stashPopConflicts !== undefined && { + stashPopConflicts: result.stashPopConflicts, + }), + ...(result.stashPopConflictMessage && { + stashPopConflictMessage: result.stashPopConflictMessage, + }), + }); + return; + } + + res.json({ + success: true, + result: result.result, + }); + return; + } + + // Original simple flow (no stash handling) + // Fetch latest remote refs before creating the branch so that + // base branch validation works for remote references like "origin/main" + await fetchRemotes(resolvedPath); + + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + resolvedPath + ); + const currentBranch = currentBranchOutput.trim(); + + // Check if branch already exists + try { + await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath); + res.status(400).json({ + success: false, + error: `Branch '${branchName}' already exists`, + }); + return; + } catch { + // Branch doesn't exist, good to create + } + + // If baseBranch is provided, verify it exists before using it + if (baseBranch) { + try { + await execGitCommand(['rev-parse', '--verify', baseBranch], resolvedPath); + } catch { + res.status(400).json({ + success: false, + error: `Base branch '${baseBranch}' does not exist`, + }); + return; + } + } + + // Create and checkout the new branch + const checkoutArgs = ['checkout', '-b', branchName]; + if (baseBranch) { + checkoutArgs.push(baseBranch); + } + await execGitCommand(checkoutArgs, resolvedPath); + + res.json({ + success: true, + result: { + previousBranch: currentBranch, + newBranch: branchName, + message: `Created and checked out branch '${branchName}'`, + }, + }); + } catch (error) { + events?.emit('switch:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Checkout branch failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Determine whether an error message represents a client error (400). + * Stash failures are server-side errors and are intentionally excluded here + * so they are returned as HTTP 500 rather than HTTP 400. + */ +function isBranchError(error?: string): boolean { + if (!error) return false; + return error.includes('already exists') || error.includes('does not exist'); +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/cherry-pick.ts b/jules_branch/apps/server/src/routes/worktree/routes/cherry-pick.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f404a0f5598c5885738f4718adbc20fb0adfbef --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/cherry-pick.ts @@ -0,0 +1,107 @@ +/** + * POST /cherry-pick endpoint - Cherry-pick one or more commits into the current branch + * + * Applies commits from another branch onto the current branch. + * Supports single or multiple commit cherry-picks. + * + * Git business logic is delegated to cherry-pick-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * The global event emitter is passed into the service so all lifecycle + * events (started, success, conflict, abort, verify-failed) are broadcast + * to WebSocket clients. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { getErrorMessage, logError } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { verifyCommits, runCherryPick } from '../../../services/cherry-pick-service.js'; + +export function createCherryPickHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, commitHashes, options } = req.body as { + worktreePath: string; + commitHashes: string[]; + options?: { + noCommit?: boolean; + }; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + // Normalize the path to prevent path traversal and ensure consistent paths + const resolvedWorktreePath = path.resolve(worktreePath); + + if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) { + res.status(400).json({ + success: false, + error: 'commitHashes array is required and must contain at least one commit hash', + }); + return; + } + + // Validate each commit hash format (should be hex string) + for (const hash of commitHashes) { + if (!/^[a-fA-F0-9]+$/.test(hash)) { + res.status(400).json({ + success: false, + error: `Invalid commit hash format: "${hash}"`, + }); + return; + } + } + + // Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing + const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events); + if (invalidHash !== null) { + res.status(400).json({ + success: false, + error: `Commit "${invalidHash}" does not exist`, + }); + return; + } + + // Execute the cherry-pick via the service. + // The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict, + // and cherry-pick:abort at the appropriate lifecycle points. + const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events); + + if (result.success) { + res.json({ + success: true, + result: { + cherryPicked: result.cherryPicked, + commitHashes: result.commitHashes, + branch: result.branch, + message: result.message, + }, + }); + } else if (result.hasConflicts) { + res.status(409).json({ + success: false, + error: result.error, + hasConflicts: true, + aborted: result.aborted, + }); + } + } catch (error) { + // Emit failure event for unexpected (non-conflict) errors + events.emit('cherry-pick:failure', { + error: getErrorMessage(error), + }); + + logError(error, 'Cherry-pick failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/commit-log.ts b/jules_branch/apps/server/src/routes/worktree/routes/commit-log.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbdce1c3196649eb695abad7b8ce5d703c554849 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/commit-log.ts @@ -0,0 +1,72 @@ +/** + * POST /commit-log endpoint - Get recent commit history for a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to commit-log-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getCommitLog } from '../../../services/commit-log-service.js'; + +export function createCommitLogHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, limit = 20 } = req.body as { + worktreePath: string; + limit?: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('commitLog:start', { + worktreePath, + limit, + }); + + // Delegate all Git work to the service + const result = await getCommitLog(worktreePath, limit); + + // Emit progress with the number of commits fetched + events.emit('commitLog:progress', { + worktreePath, + branch: result.branch, + commitsLoaded: result.total, + }); + + // Emit complete event + events.emit('commitLog:complete', { + worktreePath, + branch: result.branch, + total: result.total, + }); + + res.json({ + success: true, + result, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('commitLog:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Get commit log failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/commit.ts b/jules_branch/apps/server/src/routes/worktree/routes/commit.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bfbfd5835563a03b5600ad93594b9ed832a0b51 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/commit.ts @@ -0,0 +1,93 @@ +/** + * POST /commit endpoint - Commit changes in a worktree + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec, execFile } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +export function createCommitHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, message, files } = req.body as { + worktreePath: string; + message: string; + files?: string[]; + }; + + if (!worktreePath || !message) { + res.status(400).json({ + success: false, + error: 'worktreePath and message required', + }); + return; + } + + // Check for uncommitted changes + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + + if (!status.trim()) { + res.json({ + success: true, + result: { + committed: false, + message: 'No changes to commit', + }, + }); + return; + } + + // Stage changes - either specific files or all changes + if (files && files.length > 0) { + // Reset any previously staged changes first + await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }).catch(() => { + // Ignore errors from reset (e.g., if nothing is staged) + }); + // Stage only the selected files (args array avoids shell injection) + await execFileAsync('git', ['add', ...files], { cwd: worktreePath }); + } else { + // Stage all changes (original behavior) + await execFileAsync('git', ['add', '-A'], { cwd: worktreePath }); + } + + // Create commit (pass message as arg to avoid shell injection) + await execFileAsync('git', ['commit', '-m', message], { + cwd: worktreePath, + }); + + // Get commit hash + const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { + cwd: worktreePath, + }); + const commitHash = hashOutput.trim().substring(0, 8); + + // Get branch name + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + const branchName = branchOutput.trim(); + + res.json({ + success: true, + result: { + committed: true, + commitHash, + branch: branchName, + message, + }, + }); + } catch (error) { + logError(error, 'Commit worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/continue-operation.ts b/jules_branch/apps/server/src/routes/worktree/routes/continue-operation.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7582c02a0e0b094c3028d4b535ff6a424e89a0b --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/continue-operation.ts @@ -0,0 +1,151 @@ +/** + * POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick + * + * After conflicts have been resolved, this endpoint continues the operation. + * For merge: performs git commit (merge is auto-committed after conflict resolution) + * For rebase: runs git rebase --continue + * For cherry-pick: runs git cherry-pick --continue + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as fs from 'fs/promises'; +import { getErrorMessage, logError, execAsync } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; + +/** + * Detect what type of conflict operation is currently in progress + */ +async function detectOperation( + worktreePath: string +): Promise<'merge' | 'rebase' | 'cherry-pick' | null> { + try { + const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { + cwd: worktreePath, + }); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] = + await Promise.all([ + fs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + fs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + if (rebaseMergeExists || rebaseApplyExists) return 'rebase'; + if (mergeHeadExists) return 'merge'; + if (cherryPickHeadExists) return 'cherry-pick'; + return null; + } catch { + return null; + } +} + +/** + * Check if there are still unmerged paths (unresolved conflicts) + */ +async function hasUnmergedPaths(worktreePath: string): Promise { + try { + const { stdout: statusOutput } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + } catch { + return false; + } +} + +export function createContinueOperationHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + const resolvedWorktreePath = path.resolve(worktreePath); + + // Detect what operation is in progress + const operation = await detectOperation(resolvedWorktreePath); + + if (!operation) { + res.status(400).json({ + success: false, + error: 'No merge, rebase, or cherry-pick in progress', + }); + return; + } + + // Check for unresolved conflicts + if (await hasUnmergedPaths(resolvedWorktreePath)) { + res.status(409).json({ + success: false, + error: + 'There are still unresolved conflicts. Please resolve all conflicts before continuing.', + hasUnresolvedConflicts: true, + }); + return; + } + + // Stage all resolved files first + await execAsync('git add -A', { cwd: resolvedWorktreePath }); + + // Continue the operation + let continueCommand: string; + switch (operation) { + case 'merge': + // For merge, we need to commit after resolving conflicts + continueCommand = 'git commit --no-edit'; + break; + case 'rebase': + continueCommand = 'git rebase --continue'; + break; + case 'cherry-pick': + continueCommand = 'git cherry-pick --continue'; + break; + } + + await execAsync(continueCommand, { + cwd: resolvedWorktreePath, + env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening + }); + + // Emit event + events.emit('conflict:resolved', { + worktreePath: resolvedWorktreePath, + operation, + }); + + res.json({ + success: true, + result: { + operation, + message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`, + }, + }); + } catch (error) { + logError(error, 'Continue operation failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/create-pr.ts b/jules_branch/apps/server/src/routes/worktree/routes/create-pr.ts new file mode 100644 index 0000000000000000000000000000000000000000..de63aea94e21e729e95261101970a57cc301a869 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/create-pr.ts @@ -0,0 +1,438 @@ +/** + * POST /create-pr endpoint - Commit changes and create a pull request from a worktree + */ + +import type { Request, Response } from 'express'; +import { + getErrorMessage, + logError, + execAsync, + execEnv, + isValidBranchName, + isValidRemoteName, + isGhCliAvailable, +} from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { spawnProcess } from '@automaker/platform'; +import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; +import { resolvePrTarget } from '../../../services/pr-service.js'; + +const logger = createLogger('CreatePR'); + +export function createCreatePRHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { + worktreePath, + projectPath, + commitMessage, + prTitle, + prBody, + baseBranch, + draft, + remote, + targetRemote, + } = req.body as { + worktreePath: string; + projectPath?: string; + commitMessage?: string; + prTitle?: string; + prBody?: string; + baseBranch?: string; + draft?: boolean; + remote?: string; + /** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */ + targetRemote?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Use projectPath if provided, otherwise derive from worktreePath + // For worktrees, projectPath is needed to store metadata in the main project's .automaker folder + const effectiveProjectPath = projectPath || worktreePath; + + // Get current branch name + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + env: execEnv, + }); + const branchName = branchOutput.trim(); + + // Validate branch name for security + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: 'Invalid branch name contains unsafe characters', + }); + return; + } + + // --- Input validation: run all validation before any git write operations --- + + // Validate remote names before use to prevent command injection + if (remote !== undefined && !isValidRemoteName(remote)) { + res.status(400).json({ + success: false, + error: 'Invalid remote name contains unsafe characters', + }); + return; + } + if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) { + res.status(400).json({ + success: false, + error: 'Invalid target remote name contains unsafe characters', + }); + return; + } + + const pushRemote = remote || 'origin'; + + // Resolve repository URL, fork workflow, and target remote information. + // This is needed for both the existing PR check and PR creation. + // Resolve early so validation errors are caught before any writes. + let repoUrl: string | null = null; + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + try { + const prTarget = await resolvePrTarget({ + worktreePath, + pushRemote, + targetRemote, + }); + repoUrl = prTarget.repoUrl; + upstreamRepo = prTarget.upstreamRepo; + originOwner = prTarget.originOwner; + } catch (resolveErr) { + // resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote) + res.status(400).json({ + success: false, + error: getErrorMessage(resolveErr), + }); + return; + } + + // --- Validation complete — proceed with git operations --- + + // Check for uncommitted changes + logger.debug(`Checking for uncommitted changes in: ${worktreePath}`); + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + env: execEnv, + }); + const hasChanges = status.trim().length > 0; + logger.debug(`Has uncommitted changes: ${hasChanges}`); + if (hasChanges) { + logger.debug(`Changed files:\n${status}`); + } + + // If there are changes, commit them before creating the PR + let commitHash: string | null = null; + if (hasChanges) { + const message = commitMessage || `Changes from ${branchName}`; + logger.debug(`Committing changes with message: ${message}`); + + try { + // Stage all changes + logger.debug(`Running: git add -A`); + await execAsync('git add -A', { cwd: worktreePath, env: execEnv }); + + // Create commit — pass message as a separate arg to avoid shell injection + logger.debug(`Running: git commit`); + await execGitCommand(['commit', '-m', message], worktreePath); + + // Get commit hash + const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', { + cwd: worktreePath, + env: execEnv, + }); + commitHash = hashOutput.trim().substring(0, 8); + logger.info(`Commit successful: ${commitHash}`); + } catch (commitErr: unknown) { + const err = commitErr as { stderr?: string; message?: string }; + const commitError = err.stderr || err.message || 'Commit failed'; + logger.error(`Commit failed: ${commitError}`); + + // Return error immediately - don't proceed with push/PR if commit fails + res.status(500).json({ + success: false, + error: `Failed to commit changes: ${commitError}`, + }); + return; + } + } + + // Push the branch to remote (use selected remote or default to 'origin') + // Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName. + let pushError: string | null = null; + try { + await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv); + } catch { + // If push fails, try with --set-upstream + try { + await execGitCommand( + ['push', '--set-upstream', pushRemote, branchName], + worktreePath, + execEnv + ); + } catch (error2: unknown) { + // Capture push error for reporting + const err = error2 as { stderr?: string; message?: string }; + pushError = err.stderr || err.message || 'Push failed'; + logger.error('Push failed:', pushError); + } + } + + // If push failed, return error + if (pushError) { + res.status(500).json({ + success: false, + error: `Failed to push branch: ${pushError}`, + }); + return; + } + + // Create PR using gh CLI or provide browser fallback + const base = baseBranch || 'main'; + const title = prTitle || branchName; + const body = prBody || `Changes from branch ${branchName}`; + let prUrl: string | null = null; + let prError: string | null = null; + let browserUrl: string | null = null; + let ghCliAvailable = false; + + // Check if gh CLI is available (cross-platform) + ghCliAvailable = await isGhCliAvailable(); + + // Construct browser URL for PR creation + if (repoUrl) { + const encodedTitle = encodeURIComponent(title); + const encodedBody = encodeURIComponent(body); + // Encode base branch and head branch to handle special chars like # or % + const encodedBase = encodeURIComponent(base); + const encodedBranch = encodeURIComponent(branchName); + + if (upstreamRepo && originOwner) { + // Fork workflow (or cross-remote PR): PR to target from push remote + browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + } else { + // Regular repo + browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`; + } + } + + let prNumber: number | undefined; + let prAlreadyExisted = false; + + if (ghCliAvailable) { + // First, check if a PR already exists for this branch using gh pr list + // This is more reliable than gh pr view as it explicitly searches by branch name + // For forks/cross-remote, we need to use owner:branch format for the head parameter + const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; + + logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`); + try { + const listArgs = ['pr', 'list']; + if (upstreamRepo) { + listArgs.push('--repo', upstreamRepo); + } + listArgs.push( + '--head', + headRef, + '--json', + 'number,title,url,state,createdAt', + '--limit', + '1' + ); + logger.debug(`Running: gh ${listArgs.join(' ')}`); + const listResult = await spawnProcess({ + command: 'gh', + args: listArgs, + cwd: worktreePath, + env: execEnv, + }); + if (listResult.exitCode !== 0) { + logger.error( + `gh pr list failed with exit code ${listResult.exitCode}: ` + + `stderr=${listResult.stderr}, stdout=${listResult.stdout}` + ); + throw new Error( + `gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}` + ); + } + const existingPrOutput = listResult.stdout; + logger.debug(`gh pr list output: ${existingPrOutput}`); + + const existingPrs = JSON.parse(existingPrOutput); + + if (Array.isArray(existingPrs) && existingPrs.length > 0) { + const existingPr = existingPrs[0]; + // PR already exists - use it and store metadata + logger.info(`PR already exists for branch ${branchName}: PR #${existingPr.number}`); + prUrl = existingPr.url; + prNumber = existingPr.number; + prAlreadyExisted = true; + + // Store the existing PR info in metadata + // GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: existingPr.number, + url: existingPr.url, + title: existingPr.title || title, + state: validatePRState(existingPr.state), + createdAt: existingPr.createdAt || new Date().toISOString(), + }); + logger.debug( + `Stored existing PR info for branch ${branchName}: PR #${existingPr.number}` + ); + } else { + logger.debug(`No existing PR found for branch ${branchName}`); + } + } catch (listError) { + // gh pr list failed - log but continue to try creating + logger.debug(`gh pr list failed (this is ok, will try to create):`, listError); + } + + // Only create a new PR if one doesn't already exist + if (!prUrl) { + try { + // Build gh pr create args as an array to avoid shell injection on + // title/body (backticks, $, \ were unsafe with string interpolation) + const prArgs = ['pr', 'create', '--base', base]; + + // If this is a fork (has upstream remote), specify the repo and head + if (upstreamRepo && originOwner) { + // For forks: --repo specifies where to create PR, --head specifies source + prArgs.push('--repo', upstreamRepo, '--head', `${originOwner}:${branchName}`); + } else { + // Not a fork, just specify the head branch + prArgs.push('--head', branchName); + } + + prArgs.push('--title', title, '--body', body); + if (draft) prArgs.push('--draft'); + + logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`); + const prResult = await spawnProcess({ + command: 'gh', + args: prArgs, + cwd: worktreePath, + env: execEnv, + }); + if (prResult.exitCode !== 0) { + throw Object.assign(new Error(prResult.stderr || 'gh pr create failed'), { + stderr: prResult.stderr, + }); + } + prUrl = prResult.stdout.trim(); + logger.info(`PR created: ${prUrl}`); + + // Extract PR number and store metadata for newly created PR + if (prUrl) { + const prMatch = prUrl.match(/\/pull\/(\d+)/); + prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined; + + if (prNumber) { + try { + // Note: GitHub doesn't have a 'DRAFT' state - drafts still show as 'OPEN' + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: prNumber, + url: prUrl, + title, + state: 'OPEN', + createdAt: new Date().toISOString(), + }); + logger.debug(`Stored PR info for branch ${branchName}: PR #${prNumber}`); + } catch (metadataError) { + logger.error('Failed to store PR metadata:', metadataError); + } + } + } + } catch (ghError: unknown) { + // gh CLI failed - check if it's "already exists" error and try to fetch the PR + const err = ghError as { stderr?: string; message?: string }; + const errorMessage = err.stderr || err.message || 'PR creation failed'; + logger.debug(`gh pr create failed: ${errorMessage}`); + + // If error indicates PR already exists, try to fetch it + if (errorMessage.toLowerCase().includes('already exists')) { + logger.debug(`PR already exists error - trying to fetch existing PR`); + try { + // Build args as an array to avoid shell injection. + // When upstreamRepo is set (fork/cross-remote workflow) we must + // query the upstream repository so we find the correct PR. + const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt']; + if (upstreamRepo) { + viewArgs.push('--repo', upstreamRepo); + } + logger.debug(`Running: gh ${viewArgs.join(' ')}`); + const viewResult = await spawnProcess({ + command: 'gh', + args: viewArgs, + cwd: worktreePath, + env: execEnv, + }); + if (viewResult.exitCode !== 0) { + throw new Error( + `gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}` + ); + } + const existingPr = JSON.parse(viewResult.stdout); + if (existingPr.url) { + prUrl = existingPr.url; + prNumber = existingPr.number; + prAlreadyExisted = true; + + // GitHub CLI returns uppercase states: OPEN, MERGED, CLOSED + await updateWorktreePRInfo(effectiveProjectPath, branchName, { + number: existingPr.number, + url: existingPr.url, + title: existingPr.title || title, + state: validatePRState(existingPr.state), + createdAt: existingPr.createdAt || new Date().toISOString(), + }); + logger.debug(`Fetched and stored existing PR: #${existingPr.number}`); + } + } catch (viewError) { + logger.error('Failed to fetch existing PR:', viewError); + prError = errorMessage; + } + } else { + prError = errorMessage; + } + } + } + } else { + prError = 'gh_cli_not_available'; + } + + // Return result with browser fallback URL + res.json({ + success: true, + result: { + branch: branchName, + committed: hasChanges, + commitHash, + pushed: true, + prUrl, + prNumber, + prCreated: !!prUrl, + prAlreadyExisted, + prError: prError || undefined, + browserUrl: browserUrl || undefined, + ghCliAvailable, + }, + }); + } catch (error) { + logError(error, 'Create PR failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/create.ts b/jules_branch/apps/server/src/routes/worktree/routes/create.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b4417b80afd28ff1e5555d2c6cbcd5da2c4165d --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/create.ts @@ -0,0 +1,343 @@ +/** + * POST /create endpoint - Create a new git worktree + * + * This endpoint handles worktree creation with proper checks: + * 1. First checks if git already has a worktree for the branch (anywhere) + * 2. If found, returns the existing worktree (no error) + * 3. Syncs the base branch from its remote tracking branch (fast-forward only) + * 4. Only creates a new worktree if none exists for the branch + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { WorktreeService } from '../../../services/worktree-service.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { + getErrorMessage, + logError, + normalizePath, + ensureInitialCommit, + isValidBranchName, +} from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { trackBranch } from './branch-tracking.js'; +import { createLogger } from '@automaker/utils'; +import { runInitScript } from '../../../services/init-script-service.js'; +import { + syncBaseBranch, + type BaseBranchSyncResult, +} from '../../../services/branch-sync-service.js'; + +const logger = createLogger('Worktree'); + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +const execAsync = promisify(exec); + +/** + * Find an existing worktree for a given branch by checking git worktree list + */ +async function findExistingWorktreeForBranch( + projectPath: string, + branchName: string +): Promise<{ path: string; branch: 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 + // Critical for cross-platform compatibility (Windows, macOS, Linux) + const resolvedPath = path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + return { path: resolvedPath, branch: currentBranch }; + } + currentPath = null; + currentBranch = null; + } + } + + // Check the last entry (if file doesn't end with newline) + if (currentPath && currentBranch && currentBranch === branchName) { + // Resolve to absolute path for cross-platform compatibility + const resolvedPath = path.isAbsolute(currentPath) + ? path.resolve(currentPath) + : path.resolve(projectPath, currentPath); + return { path: resolvedPath, branch: currentBranch }; + } + + return null; + } catch { + return null; + } +} + +export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) { + const worktreeService = new WorktreeService(); + + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, branchName, baseBranch } = req.body as { + projectPath: string; + branchName: string; + baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main". + }; + + if (!projectPath || !branchName) { + res.status(400).json({ + success: false, + error: 'projectPath and branchName required', + }); + return; + } + + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + // Validate base branch if provided + if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') { + res.status(400).json({ + success: false, + error: + 'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + if (!(await isGitRepo(projectPath))) { + res.status(400).json({ + success: false, + error: 'Not a git repository', + }); + return; + } + + // Ensure the repository has at least one commit so worktree commands referencing HEAD succeed + // Pass git identity env vars so commits work without global git config + const gitEnv = { + GIT_AUTHOR_NAME: 'Automaker', + GIT_AUTHOR_EMAIL: 'automaker@localhost', + GIT_COMMITTER_NAME: 'Automaker', + GIT_COMMITTER_EMAIL: 'automaker@localhost', + }; + await ensureInitialCommit(projectPath, gitEnv); + + // First, check if git already has a worktree for this branch (anywhere) + const existingWorktree = await findExistingWorktreeForBranch(projectPath, branchName); + if (existingWorktree) { + // Worktree already exists, return it as success (not an error) + // This handles manually created worktrees or worktrees from previous runs + logger.info( + `Found existing worktree for branch "${branchName}" at: ${existingWorktree.path}` + ); + + // Track the branch so it persists in the UI + await trackBranch(projectPath, branchName); + + res.json({ + success: true, + worktree: { + path: normalizePath(existingWorktree.path), + branch: branchName, + isNew: false, // Not newly created + }, + }); + return; + } + + // Sanitize branch name for directory usage + const sanitizedName = branchName.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, sanitizedName); + + // Create worktrees directory if it doesn't exist + await secureFs.mkdir(worktreesDir, { recursive: true }); + + // Fetch latest from all remotes before creating the worktree. + // This ensures remote refs are up-to-date for: + // - Remote base branches (e.g. "origin/main") + // - Existing remote branches being checked out as worktrees + // - Branch existence checks against fresh remote state + logger.info('Fetching from all remotes before creating worktree'); + try { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller); + } finally { + clearTimeout(timerId); + } + } catch (fetchErr) { + // Non-fatal: log but continue — refs might already be cached locally + logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`); + } + + // Sync the base branch with its remote tracking branch (fast-forward only). + // This ensures the new worktree starts from an up-to-date state rather than + // a potentially stale local copy. If the sync fails or the branch has diverged, + // we proceed with the local copy and inform the user. + const effectiveBase = baseBranch || 'HEAD'; + let syncResult: BaseBranchSyncResult = { attempted: false, synced: false }; + + // Only sync if the base is a real branch (not 'HEAD') + // Pass skipFetch=true because we already fetched all remotes above. + if (effectiveBase !== 'HEAD') { + logger.info(`Syncing base branch '${effectiveBase}' before creating worktree`); + syncResult = await syncBaseBranch(projectPath, effectiveBase, true); + if (syncResult.attempted) { + if (syncResult.synced) { + logger.info(`Base branch sync result: ${syncResult.message}`); + } else { + logger.warn(`Base branch sync result: ${syncResult.message}`); + } + } + } else { + // When using HEAD, try to sync the currently checked-out branch + // Pass skipFetch=true because we already fetched all remotes above. + try { + const currentBranch = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + projectPath + ); + const trimmedBranch = currentBranch.trim(); + if (trimmedBranch && trimmedBranch !== 'HEAD') { + logger.info( + `Syncing current branch '${trimmedBranch}' (HEAD) before creating worktree` + ); + syncResult = await syncBaseBranch(projectPath, trimmedBranch, true); + if (syncResult.attempted) { + if (syncResult.synced) { + logger.info(`HEAD branch sync result: ${syncResult.message}`); + } else { + logger.warn(`HEAD branch sync result: ${syncResult.message}`); + } + } + } + } catch { + // Could not determine HEAD branch — skip sync + } + } + + // Check if branch exists (using array arguments to prevent injection) + let branchExists = false; + try { + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); + branchExists = true; + } catch { + // Branch doesn't exist + } + + // Create worktree (using array arguments to prevent injection) + if (branchExists) { + // Use existing branch + await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath); + } else { + // Create new branch from base or HEAD + const base = baseBranch || 'HEAD'; + await execGitCommand( + ['worktree', 'add', '-b', branchName, worktreePath, base], + projectPath + ); + } + + // Note: We intentionally do NOT symlink .automaker to worktrees + // Features and config are always accessed from the main project path + // This avoids symlink loop issues when activating worktrees + + // Track the branch so it persists in the UI even after worktree is removed + await trackBranch(projectPath, branchName); + + // Resolve to absolute path for cross-platform compatibility + // normalizePath converts to forward slashes for API consistency + const absoluteWorktreePath = path.resolve(worktreePath); + + // Get the commit hash the new worktree is based on for logging + let baseCommitHash: string | undefined; + try { + const hash = await execGitCommand(['rev-parse', '--short', 'HEAD'], absoluteWorktreePath); + baseCommitHash = hash.trim(); + } catch { + // Non-critical — just for logging + } + + if (baseCommitHash) { + logger.info(`New worktree for '${branchName}' based on commit ${baseCommitHash}`); + } + + // Copy configured files into the new worktree before responding + // This runs synchronously to ensure files are in place before any init script + try { + await worktreeService.copyConfiguredFiles( + projectPath, + absoluteWorktreePath, + settingsService, + events + ); + } catch (copyErr) { + // Log but don't fail worktree creation – files may be partially copied + logger.warn('Some configured files failed to copy to worktree:', copyErr); + } + + // Respond immediately (non-blocking) + res.json({ + success: true, + worktree: { + path: normalizePath(absoluteWorktreePath), + branch: branchName, + isNew: !branchExists, + baseCommitHash, + ...(syncResult.attempted + ? { + syncResult: { + synced: syncResult.synced, + remote: syncResult.remote, + message: syncResult.message, + diverged: syncResult.diverged, + }, + } + : {}), + }, + }); + + // Trigger init script asynchronously after response + // runInitScript internally checks if script exists and hasn't already run + runInitScript({ + projectPath, + worktreePath: absoluteWorktreePath, + branch: branchName, + emitter: events, + }).catch((err) => { + logger.error(`Init script failed for ${branchName}:`, err); + }); + } catch (error) { + logError(error, 'Create worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/delete.ts b/jules_branch/apps/server/src/routes/worktree/routes/delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..034be28ef740be611e5f3f0ea162094d34e7678f --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/delete.ts @@ -0,0 +1,205 @@ +/** + * POST /delete endpoint - Delete a git worktree + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs/promises'; +import { isGitRepo } from '@automaker/git-utils'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { createLogger } from '@automaker/utils'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { EventEmitter } from '../../../lib/events.js'; + +const execAsync = promisify(exec); +const logger = createLogger('Worktree'); + +export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath, deleteBranch } = req.body as { + projectPath: string; + worktreePath: string; + deleteBranch?: boolean; // Whether to also delete the branch + }; + + if (!projectPath || !worktreePath) { + res.status(400).json({ + success: false, + error: 'projectPath and worktreePath required', + }); + return; + } + + if (!(await isGitRepo(projectPath))) { + res.status(400).json({ + success: false, + error: 'Not a git repository', + }); + return; + } + + // Get branch name before removing worktree + let branchName: string | null = null; + try { + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + branchName = stdout.trim(); + } catch { + // Could not get branch name - worktree directory may already be gone + logger.debug('Could not determine branch for worktree, directory may be missing'); + } + + // Remove the worktree (using array arguments to prevent injection) + let removeSucceeded = false; + try { + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); + removeSucceeded = true; + } catch (removeError) { + // `git worktree remove` can fail if the directory is already missing + // or in a bad state. Try pruning stale worktree entries as a fallback. + logger.debug('git worktree remove failed, trying prune', { + error: getErrorMessage(removeError), + }); + try { + await execGitCommand(['worktree', 'prune'], projectPath); + + // Verify the specific worktree is no longer registered after prune. + // `git worktree prune` exits 0 even if worktreePath was never registered, + // so we must explicitly check the worktree list to avoid false positives. + const { stdout: listOut } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + // Parse porcelain output and check for an exact path match. + // Using substring .includes() can produce false positives when one + // worktree path is a prefix of another (e.g. /foo vs /foobar). + const stillRegistered = listOut + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length).trim()) + .some((registeredPath) => registeredPath === worktreePath); + if (stillRegistered) { + // Prune didn't clean up our entry - treat as failure + throw removeError; + } + removeSucceeded = true; + } catch (pruneError) { + // If pruneError is the original removeError re-thrown, propagate it + if (pruneError === removeError) { + throw removeError; + } + logger.warn('git worktree prune also failed', { + error: getErrorMessage(pruneError), + }); + // If both remove and prune fail, still try to return success + // if the worktree directory no longer exists (it may have been + // manually deleted already). + let dirExists = false; + try { + await fs.access(worktreePath); + dirExists = true; + } catch { + // Directory doesn't exist + } + if (dirExists) { + // Directory still exists - this is a real failure + throw removeError; + } + // Directory is gone, treat as success + removeSucceeded = true; + } + } + + // Optionally delete the branch (only if worktree was successfully removed) + let branchDeleted = false; + if ( + removeSucceeded && + deleteBranch && + branchName && + branchName !== 'main' && + branchName !== 'master' + ) { + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`); + } else { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + // Branch deletion failed, not critical + logger.warn(`Failed to delete branch: ${branchName}`); + } + } + } + + // Emit worktree:deleted event after successful deletion + events.emit('worktree:deleted', { + worktreePath, + projectPath, + branchName, + branchDeleted, + }); + + // Move features associated with the deleted branch to the main worktree + // This prevents features from being orphaned when a worktree is deleted + let featuresMovedToMain = 0; + if (featureLoader && branchName) { + try { + const allFeatures = await featureLoader.getAll(projectPath); + const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName); + for (const feature of affectedFeatures) { + try { + await featureLoader.update(projectPath, feature.id, { + branchName: null, + }); + featuresMovedToMain++; + // Emit feature:migrated event for each successfully migrated feature + events.emit('feature:migrated', { + featureId: feature.id, + status: 'migrated', + fromBranch: branchName, + toWorktreeId: null, // migrated to main worktree (no specific worktree) + projectPath, + }); + } catch (featureUpdateError) { + // Non-fatal: log per-feature failure but continue migrating others + logger.warn('Failed to move feature to main worktree after deletion', { + error: getErrorMessage(featureUpdateError), + featureId: feature.id, + branchName, + }); + } + } + if (featuresMovedToMain > 0) { + logger.info( + `Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}` + ); + } + } catch (featureError) { + // Non-fatal: log but don't fail the deletion (getAll failed) + logger.warn('Failed to load features for migration to main worktree after deletion', { + error: getErrorMessage(featureError), + branchName, + }); + } + } + + res.json({ + success: true, + deleted: { + worktreePath, + branch: branchDeleted ? branchName : null, + branchDeleted, + featuresMovedToMain, + }, + }); + } catch (error) { + logError(error, 'Delete worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/dev-server-logs.ts b/jules_branch/apps/server/src/routes/worktree/routes/dev-server-logs.ts new file mode 100644 index 0000000000000000000000000000000000000000..79d3553bff1d1964a60d5af251816b9ef4a43da8 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/dev-server-logs.ts @@ -0,0 +1,53 @@ +/** + * GET /dev-server-logs endpoint - Get buffered logs for a worktree's dev server + * + * Returns the scrollback buffer containing historical log output for a running + * dev server. Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createGetDevServerLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.query as { + worktreePath?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath query parameter is required', + }); + return; + } + + const devServerService = getDevServerService(); + const result = devServerService.getServerLogs(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + url: result.result.url, + logs: result.result.logs, + startedAt: result.result.startedAt, + }, + }); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get dev server logs', + }); + } + } catch (error) { + logError(error, 'Get dev server logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/diffs.ts b/jules_branch/apps/server/src/routes/worktree/routes/diffs.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e8586bfa4e556455a5f0ab828d5ba59087f97c0 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/diffs.ts @@ -0,0 +1,88 @@ +/** + * POST /diffs endpoint - Get diffs for a worktree + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getGitRepositoryDiffs } from '../../common.js'; + +export function createDiffsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); + return; + } + + // If worktrees aren't enabled, don't probe .worktrees at all. + // This avoids noisy logs that make it look like features are "running in worktrees". + if (useWorktrees === false) { + const result = await getGitRepositoryDiffs(projectPath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), + }); + return; + } + + // Git worktrees are stored in project directory + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); + + try { + // Check if worktree exists + await secureFs.access(worktreePath); + + // Get diffs from worktree + const result = await getGitRepositoryDiffs(worktreePath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), + }); + } catch (innerError) { + // Worktree doesn't exist - fallback to main project path + const code = (innerError as NodeJS.ErrnoException | undefined)?.code; + // ENOENT is expected when a feature has no worktree; don't log as an error. + if (code && code !== 'ENOENT') { + logError(innerError, 'Worktree access failed, falling back to main project'); + } + + try { + const result = await getGitRepositoryDiffs(projectPath); + res.json({ + success: true, + diff: result.diff, + files: result.files, + hasChanges: result.hasChanges, + ...(result.mergeState ? { mergeState: result.mergeState } : {}), + }); + } catch (fallbackError) { + logError(fallbackError, 'Fallback to main project also failed'); + res.json({ success: true, diff: '', files: [], hasChanges: false }); + } + } + } catch (error) { + logError(error, 'Get worktree diffs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/discard-changes.ts b/jules_branch/apps/server/src/routes/worktree/routes/discard-changes.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb2c9399dcdcd5b837ca129e24a7b9e904940d91 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -0,0 +1,317 @@ +/** + * POST /discard-changes endpoint - Discard uncommitted changes in a worktree + * + * Supports two modes: + * 1. Discard ALL changes (when no files array is provided) + * - Resets staged changes (git reset HEAD) + * - Discards modified tracked files (git checkout .) + * - Removes untracked files and directories (git clean -ffd) + * + * 2. Discard SELECTED files (when files array is provided) + * - Unstages selected staged files (git reset HEAD -- ) + * - Reverts selected tracked file changes (git checkout -- ) + * - Removes selected untracked files (git clean -ffd -- ) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import * as path from 'path'; +import * as fs from 'fs'; +import { getErrorMessage, logError } from '@automaker/utils'; +import { execGitCommand } from '../../../lib/git.js'; + +/** + * Validate that a file path does not escape the worktree directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd) and + * rejects symlinks inside the worktree that point outside of it. + */ +function validateFilePath(filePath: string, worktreePath: string): boolean { + // Resolve the full path relative to the worktree (lexical resolution) + const resolved = path.resolve(worktreePath, filePath); + const normalizedWorktree = path.resolve(worktreePath); + + // First, perform lexical prefix check + const lexicalOk = + resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree; + if (!lexicalOk) { + return false; + } + + // Then, attempt symlink-aware validation using realpath. + // This catches symlinks inside the worktree that point outside of it. + try { + const realResolved = fs.realpathSync(resolved); + const realWorktree = fs.realpathSync(normalizedWorktree); + return realResolved.startsWith(realWorktree + path.sep) || realResolved === realWorktree; + } catch { + // If realpath fails (e.g., target doesn't exist yet for untracked files), + // fall back to the lexical startsWith check which already passed above. + return true; + } +} + +/** + * Parse a file path from git status --porcelain output, handling renames. + * For renamed files (R status), git reports "old_path -> new_path" and + * we need the new path to match what parseGitStatus() returns in git-utils. + */ +function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string { + const trimmedPath = rawPath.trim(); + if (indexStatus === 'R' || workTreeStatus === 'R') { + const arrowIndex = trimmedPath.indexOf(' -> '); + if (arrowIndex !== -1) { + return trimmedPath.slice(arrowIndex + 4); + } + } + return trimmedPath; +} + +export function createDiscardChangesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, files } = req.body as { + worktreePath: string; + files?: string[]; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Check for uncommitted changes first + const status = await execGitCommand(['status', '--porcelain'], worktreePath); + + if (!status.trim()) { + res.json({ + success: true, + result: { + discarded: false, + message: 'No changes to discard', + }, + }); + return; + } + + // Get branch name before discarding + const branchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const branchName = branchOutput.trim(); + + // Parse the status output to categorize files + // Git --porcelain format: XY PATH where X=index status, Y=worktree status + // For renamed files: XY OLD_PATH -> NEW_PATH + const statusLines = status.trim().split('\n').filter(Boolean); + const allFiles = statusLines.map((line) => { + const fileStatus = line.substring(0, 2); + const rawPath = line.slice(3); + const indexStatus = fileStatus.charAt(0); + const workTreeStatus = fileStatus.charAt(1); + // Parse path consistently with parseGitStatus() in git-utils, + // which extracts the new path for renames + const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus); + return { status: fileStatus, path: filePath }; + }); + + // Determine which files to discard + const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length; + + if (isSelectiveDiscard) { + // Selective discard: only discard the specified files + const filesToDiscard = new Set(files); + + // Validate all requested file paths stay within the worktree + const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath)); + if (invalidPaths.length > 0) { + res.status(400).json({ + success: false, + error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`, + }); + return; + } + + // Separate files into categories for proper git operations + const trackedModified: string[] = []; // Modified/deleted tracked files + const stagedFiles: string[] = []; // Files that are staged + const untrackedFiles: string[] = []; // Untracked files (?) + const warnings: string[] = []; + + // Track which requested files were matched so we can handle unmatched ones + const matchedFiles = new Set(); + + for (const file of allFiles) { + if (!filesToDiscard.has(file.path)) continue; + matchedFiles.add(file.path); + + // file.status is the raw two-character XY git porcelain status (no trim) + // X = index/staging status, Y = worktree status + const xy = file.status.substring(0, 2); + const indexStatus = xy.charAt(0); + const workTreeStatus = xy.charAt(1); + + if (indexStatus === '?' && workTreeStatus === '?') { + untrackedFiles.push(file.path); + } else if (indexStatus === 'A') { + // Staged-new file: must be reset (unstaged) then cleaned (deleted). + // Never pass to trackedModified — the file has no HEAD version to + // check out, so `git checkout --` would fail or do nothing. + stagedFiles.push(file.path); + untrackedFiles.push(file.path); + } else { + // Check if the file has staged changes (index status X) + if (indexStatus !== ' ' && indexStatus !== '?') { + stagedFiles.push(file.path); + } + // Check for working tree changes (worktree status Y): handles MM, MD, etc. + if (workTreeStatus !== ' ' && workTreeStatus !== '?') { + trackedModified.push(file.path); + } + } + } + + // Handle files from the UI that didn't match any entry in allFiles. + // This can happen due to timing differences between the UI loading diffs + // and the discard request, or path format differences. + // Attempt to clean unmatched files directly as untracked files. + for (const requestedFile of files) { + if (!matchedFiles.has(requestedFile)) { + untrackedFiles.push(requestedFile); + } + } + + // 1. Unstage selected staged files (using execFile to bypass shell) + if (stagedFiles.length > 0) { + try { + await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to unstage files: ${msg}`); + warnings.push(`Failed to unstage some files: ${msg}`); + } + } + + // 2. Revert selected tracked file changes + if (trackedModified.length > 0) { + try { + await execGitCommand(['checkout', '--', ...trackedModified], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to revert tracked files: ${msg}`); + warnings.push(`Failed to revert some tracked files: ${msg}`); + } + } + + // 3. Remove selected untracked files + // Use -ffd (double force) to also handle nested git repositories + if (untrackedFiles.length > 0) { + try { + await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to clean untracked files: ${msg}`); + warnings.push(`Failed to remove some untracked files: ${msg}`); + } + } + + const fileCount = files.length; + + // Verify the remaining state + const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath); + + const remainingCount = finalStatus.trim() + ? finalStatus.trim().split('\n').filter(Boolean).length + : 0; + const actualDiscarded = allFiles.length - remainingCount; + + let message = + actualDiscarded < fileCount + ? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining` + : `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`; + + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: actualDiscarded, + filesRemaining: remainingCount, + branch: branchName, + message, + ...(warnings.length > 0 && { warnings }), + }, + }); + } else { + // Discard ALL changes (original behavior) + const fileCount = allFiles.length; + const warnings: string[] = []; + + // 1. Reset any staged changes + try { + await execGitCommand(['reset', 'HEAD'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git reset HEAD failed: ${msg}`); + warnings.push(`Failed to unstage changes: ${msg}`); + } + + // 2. Discard changes in tracked files + try { + await execGitCommand(['checkout', '.'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git checkout . failed: ${msg}`); + warnings.push(`Failed to revert tracked changes: ${msg}`); + } + + // 3. Remove untracked files and directories + // Use -ffd (double force) to also handle nested git repositories + try { + await execGitCommand(['clean', '-ffd', '--'], worktreePath); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git clean -ffd failed: ${msg}`); + warnings.push(`Failed to remove untracked files: ${msg}`); + } + + // Verify all changes were discarded + const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath); + + if (finalStatus.trim()) { + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } + } + } catch (error) { + logError(error, 'Discard changes failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/file-diff.ts b/jules_branch/apps/server/src/routes/worktree/routes/file-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3d4ed1a6bed02e9e5d502e7eb712f08a3e4bbb9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/file-diff.ts @@ -0,0 +1,82 @@ +/** + * POST /file-diff endpoint - Get diff for a specific file + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; +import { generateSyntheticDiffForNewFile } from '../../common.js'; + +const execAsync = promisify(exec); + +export function createFileDiffHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, filePath, useWorktrees } = req.body as { + projectPath: string; + featureId: string; + filePath: string; + useWorktrees?: boolean; + }; + + if (!projectPath || !featureId || !filePath) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and filePath required', + }); + return; + } + + // If worktrees aren't enabled, don't probe .worktrees at all. + if (useWorktrees === false) { + res.json({ success: true, diff: '', filePath }); + return; + } + + // Git worktrees are stored in project directory + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); + + try { + await secureFs.access(worktreePath); + + // First check if the file is untracked + const { stdout: status } = await execAsync(`git status --porcelain -- "${filePath}"`, { + cwd: worktreePath, + }); + + const isUntracked = status.trim().startsWith('??'); + + let diff: string; + if (isUntracked) { + // Generate synthetic diff for untracked file + diff = await generateSyntheticDiffForNewFile(worktreePath, filePath); + } else { + // Use regular git diff for tracked files + const result = await execAsync(`git diff HEAD -- "${filePath}"`, { + cwd: worktreePath, + maxBuffer: 10 * 1024 * 1024, + }); + diff = result.stdout; + } + + res.json({ success: true, diff, filePath }); + } catch (innerError) { + const code = (innerError as NodeJS.ErrnoException | undefined)?.code; + // ENOENT is expected when a feature has no worktree; don't log as an error. + if (code && code !== 'ENOENT') { + logError(innerError, 'Worktree file diff failed'); + } + res.json({ success: true, diff: '', filePath }); + } + } catch (error) { + logError(error, 'Get worktree file diff failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/jules_branch/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab3a3aca3fe48ccf5c9b0b7a17c6ed8d676e1a4b --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,270 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a concise, + * conventional commit message from git changes. Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { createLogger } from '@automaker/utils'; +import { isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { mergeCommitMessagePrompts } from '@automaker/prompts'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execFileAsync = promisify(execFile); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** + * Wraps an async generator with a timeout. + * If the generator takes longer than the timeout, it throws an error. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + let timerId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout( + () => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + try { + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { + // Capture the original error, then attempt to close the iterator. + // If iterator.return() throws, log it but rethrow the original error + // so the timeout error (not the teardown error) is preserved. + try { + await iterator.return?.(); + } catch (teardownErr) { + logger.warn('Error during iterator cleanup after timeout:', teardownErr); + } + throw err; + }); + if (result.done) { + done = true; + } else { + yield result.value; + } + } + } finally { + clearTimeout(timerId); + } +} + +/** + * Get the effective system prompt for commit message generation. + * Uses custom prompt from settings if enabled, otherwise falls back to default. + */ +async function getSystemPrompt(settingsService?: SettingsService): Promise { + const settings = await settingsService?.getGlobalSettings(); + const prompts = mergeCommitMessagePrompts(settings?.promptCustomization?.commitMessage); + return prompts.systemPrompt; +} + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +export function createGenerateCommitMessageHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository (check for .git folder or file for worktrees) + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider: claudeCompatibleProvider, + credentials, + } = await getPhaseModelWithOverrides( + 'commitMessageModel', + settingsService, + worktreePath, + '[GenerateCommitMessage]' + ); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info( + `Using model for commit message: ${model}`, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); + + // Get the effective system prompt (custom or default) + const systemPrompt = await getSystemPrompt(settingsService); + + // Get provider for the model type + const aiProvider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + // For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation + const effectivePrompt = isCursorModel(model) + ? `${systemPrompt}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt; + + logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`); + + let responseText = ''; + const stream = aiProvider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + thinkingLevel, // Pass thinking level for extended thinking support + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }); + + // Wrap with timeout to prevent indefinite hangs + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } + } + } + + const message = responseText.trim(); + + if (!message) { + logger.warn('Received empty response from model'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/generate-pr-description.ts b/jules_branch/apps/server/src/routes/worktree/routes/generate-pr-description.ts new file mode 100644 index 0000000000000000000000000000000000000000..a588f82dea99da823f381c1b5ae8d9dc400c4f22 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/generate-pr-description.ts @@ -0,0 +1,491 @@ +/** + * POST /worktree/generate-pr-description endpoint - Generate an AI PR description from git diff + * + * Uses the configured model (via phaseModels.commitMessageModel) to generate a pull request + * title and description from the branch's changes compared to the base branch. + * Defaults to Claude Haiku for speed. + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { createLogger } from '@automaker/utils'; +import { isCursorModel, stripProviderPrefix } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getErrorMessage, logError } from '../common.js'; +import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js'; + +const logger = createLogger('GeneratePRDescription'); +const execFileAsync = promisify(execFile); + +/** Timeout for AI provider calls in milliseconds (30 seconds) */ +const AI_TIMEOUT_MS = 30_000; + +/** Max diff size to send to AI (characters) */ +const MAX_DIFF_SIZE = 15_000; + +const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided. + +IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else. + +Output your response in EXACTLY this format (including the markers): +---TITLE--- + +---BODY--- +## Summary +<1-3 bullet points describing the key changes> + +## Changes + + +Rules: +- Your ENTIRE response must start with ---TITLE--- and contain nothing before it +- The title should be concise and descriptive (50-72 characters) +- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle") +- The description should explain WHAT changed and WHY +- Group related changes together +- Use markdown formatting for the body +- Do NOT include the branch name in the title +- Focus on the user-facing impact when possible +- If there are breaking changes, mention them prominently +- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created +- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes +- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff +- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`; + +/** + * Wraps an async generator with a timeout. + */ +async function* withTimeout( + generator: AsyncIterable, + timeoutMs: number +): AsyncGenerator { + let timerId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timerId = setTimeout( + () => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + try { + while (!done) { + const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => { + // Timeout (or other error) — attempt to gracefully close the source generator + await iterator.return?.(); + throw err; + }); + if (result.done) { + done = true; + } else { + yield result.value; + } + } + } finally { + clearTimeout(timerId); + } +} + +interface GeneratePRDescriptionRequestBody { + worktreePath: string; + baseBranch?: string; +} + +interface GeneratePRDescriptionSuccessResponse { + success: true; + title: string; + body: string; +} + +interface GeneratePRDescriptionErrorResponse { + success: false; + error: string; +} + +export function createGeneratePRDescriptionHandler( + settingsService?: SettingsService +): (req: Request, res: Response) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, baseBranch } = req.body as GeneratePRDescriptionRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + // Validate that the directory exists + if (!existsSync(worktreePath)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath does not exist', + }; + res.status(400).json(response); + return; + } + + // Validate that it's a git repository + const gitPath = join(worktreePath, '.git'); + if (!existsSync(gitPath)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'worktreePath is not a git repository', + }; + res.status(400).json(response); + return; + } + + // Validate baseBranch to allow only safe branch name characters + if (baseBranch !== undefined && !/^[\w.\-/]+$/.test(baseBranch)) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'baseBranch contains invalid characters', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating PR description for worktree: ${worktreePath}`); + + // Get current branch name + const { stdout: branchOutput } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { cwd: worktreePath } + ); + const branchName = branchOutput.trim(); + + // Determine the base branch for comparison + const base = baseBranch || 'main'; + + // Collect diffs in three layers and combine them: + // 1. Committed changes on the branch: `git diff base...HEAD` + // 2. Staged (cached) changes not yet committed: `git diff --cached` + // 3. Unstaged changes to tracked files: `git diff` (no --cached flag) + // + // Untracked files are intentionally excluded — they are typically build artifacts, + // planning files, hidden dotfiles, or other files unrelated to the PR. + // `git diff` and `git diff --cached` only show changes to files already tracked by git, + // which is exactly the correct scope. + // + // We combine all three sources and deduplicate by file path so that a file modified + // in commits AND with additional uncommitted changes is not double-counted. + + /** Parse a unified diff into per-file hunks keyed by file path */ + function parseDiffIntoFileHunks(diffText: string): Map { + const fileHunks = new Map(); + if (!diffText.trim()) return fileHunks; + + // Split on "diff --git" boundaries (keep the delimiter) + const sections = diffText.split(/(?=^diff --git )/m); + for (const section of sections) { + if (!section.trim()) continue; + // Use a back-reference pattern so the "b/" side must match the "a/" capture, + // correctly handling paths that contain " b/" in their name. + // Falls back to a two-capture pattern to handle renames (a/ and b/ differ). + const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m); + const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null; + const match = backrefMatch || renameMatch; + if (match) { + // Prefer the backref capture (identical paths); for renames use the destination (match[2]) + const filePath = backrefMatch ? match[1] : match[2]; + // Merge hunks if the same file appears in multiple diff sources + const existing = fileHunks.get(filePath) ?? ''; + fileHunks.set(filePath, existing + section); + } + } + return fileHunks; + } + + // --- Step 1: committed changes (branch vs base) --- + let committedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + committedDiff = stdout; + } catch { + // Base branch may not exist locally; try the remote tracking branch + try { + const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + committedDiff = stdout; + } catch { + // Cannot compare against base — leave committedDiff empty; the uncommitted + // changes gathered below will still be included. + logger.warn(`Could not get committed diff against ${base} or origin/${base}`); + } + } + + // --- Step 2: staged changes (tracked files only) --- + let stagedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff', '--cached'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + stagedDiff = stdout; + } catch (err) { + // Non-fatal — staged diff is a best-effort supplement + logger.debug('Failed to get staged diff', err); + } + + // --- Step 3: unstaged changes (tracked files only) --- + let unstagedDiff = ''; + try { + const { stdout } = await execFileAsync('git', ['diff'], { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, + }); + unstagedDiff = stdout; + } catch (err) { + // Non-fatal — unstaged diff is a best-effort supplement + logger.debug('Failed to get unstaged diff', err); + } + + // --- Combine and deduplicate --- + // Build a map of filePath → diff content by concatenating hunks from all sources + // in chronological order (committed → staged → unstaged) so that no changes + // are lost when a file appears in multiple diff sources. + const combinedFileHunks = new Map(); + + for (const source of [committedDiff, stagedDiff, unstagedDiff]) { + const hunks = parseDiffIntoFileHunks(source); + for (const [filePath, hunk] of hunks) { + if (combinedFileHunks.has(filePath)) { + combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk); + } else { + combinedFileHunks.set(filePath, hunk); + } + } + } + + const diff = Array.from(combinedFileHunks.values()).join(''); + + // Log what files were included for observability + if (combinedFileHunks.size > 0) { + logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`); + logger.debug( + `PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}` + ); + } + + // Also get the commit log for context — always scoped to the selected base branch + // so the log only contains commits that are part of this PR. + // We do NOT fall back to an unscoped `git log` because that would include commits + // from the base branch itself and produce misleading AI context. + let commitLog = ''; + try { + const { stdout: logOutput } = await execFileAsync( + 'git', + ['log', `${base}..HEAD`, '--oneline', '--no-decorate'], + { + cwd: worktreePath, + maxBuffer: 1024 * 1024, + } + ); + commitLog = logOutput.trim(); + } catch { + // Base branch not available locally — try the remote tracking branch + try { + const { stdout: logOutput } = await execFileAsync( + 'git', + ['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'], + { + cwd: worktreePath, + maxBuffer: 1024 * 1024, + } + ); + commitLog = logOutput.trim(); + } catch { + // Cannot scope commit log to base branch — leave empty rather than + // including unscoped commits that would pollute the AI context. + logger.warn(`Could not get commit log against ${base} or origin/${base}`); + } + } + + if (!diff.trim() && !commitLog.trim()) { + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'No changes found to generate a PR description from', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long + const truncatedDiff = + diff.length > MAX_DIFF_SIZE + ? diff.substring(0, MAX_DIFF_SIZE) + '\n\n[... diff truncated ...]' + : diff; + + // Build the user prompt + let userPrompt = `Generate a pull request title and description for the following changes.\n\nBranch: ${branchName}\nBase Branch: ${base}\n`; + + if (commitLog) { + userPrompt += `\nCommit History:\n${commitLog}\n`; + } + + if (truncatedDiff) { + userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + } + + // Get model from phase settings with provider info + const { + phaseModel: phaseModelEntry, + provider: claudeCompatibleProvider, + credentials, + } = await getPhaseModelWithOverrides( + 'commitMessageModel', + settingsService, + worktreePath, + '[GeneratePRDescription]' + ); + const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry); + + logger.info( + `Using model for PR description: ${model}`, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); + + // Get provider for the model type + const aiProvider = ProviderFactory.getProviderForModel(model); + const bareModel = stripProviderPrefix(model); + + // For Cursor models, combine prompts + const effectivePrompt = isCursorModel(model) + ? `${PR_DESCRIPTION_SYSTEM_PROMPT}\n\n${userPrompt}` + : userPrompt; + const effectiveSystemPrompt = isCursorModel(model) ? undefined : PR_DESCRIPTION_SYSTEM_PROMPT; + + logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`); + + let responseText = ''; + const stream = aiProvider.executeQuery({ + prompt: effectivePrompt, + model: bareModel, + cwd: worktreePath, + systemPrompt: effectiveSystemPrompt, + maxTurns: 1, + allowedTools: [], + readOnly: true, + thinkingLevel, + claudeCompatibleProvider, + credentials, + }); + + // Wrap with timeout + for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + // Use result text if longer than accumulated text (consistent with simpleQuery pattern) + if (msg.result.length > responseText.length) { + responseText = msg.result; + } + } + } + + const fullResponse = responseText.trim(); + + if (!fullResponse || fullResponse.length === 0) { + logger.warn('Received empty response from model'); + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: 'Failed to generate PR description - empty response', + }; + res.status(500).json(response); + return; + } + + // Parse the response to extract title and body. + // The model may include conversational preamble before the structured markers, + // so we search for the markers anywhere in the response, not just at the start. + let title = ''; + let body = ''; + + const titleMatch = fullResponse.match(/---TITLE---\s*\n([\s\S]*?)(?=---BODY---|$)/); + const bodyMatch = fullResponse.match(/---BODY---\s*\n([\s\S]*?)$/); + + if (titleMatch && bodyMatch) { + title = titleMatch[1].trim(); + body = bodyMatch[1].trim(); + } else { + // Fallback: try to extract meaningful content, skipping any conversational preamble. + // Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc. + const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0); + + // Skip lines that look like conversational preamble + let startIndex = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // Check if this line looks like conversational AI preamble + if ( + /^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test( + line + ) || + /^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test( + line + ) + ) { + startIndex = i + 1; + continue; + } + break; + } + + // Use remaining lines after skipping preamble + const contentLines = lines.slice(startIndex); + if (contentLines.length > 0) { + title = contentLines[0].trim(); + body = contentLines.slice(1).join('\n').trim(); + } else { + // If all lines were filtered as preamble, use the original first non-empty line + title = lines[0]?.trim() || ''; + body = lines.slice(1).join('\n').trim(); + } + } + + // Clean up title - remove any markdown headings, quotes, or marker artifacts + title = title + .replace(/^#+\s*/, '') + .replace(/^["']|["']$/g, '') + .replace(/^---\w+---\s*/, ''); + + logger.info(`Generated PR title: ${title.substring(0, 100)}...`); + + const response: GeneratePRDescriptionSuccessResponse = { + success: true, + title, + body, + }; + res.json(response); + } catch (error) { + logError(error, 'Generate PR description failed'); + const response: GeneratePRDescriptionErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/info.ts b/jules_branch/apps/server/src/routes/worktree/routes/info.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c2eb808eda31e83cf2a6bdd02d6a53d949916e1 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/info.ts @@ -0,0 +1,53 @@ +/** + * POST /info endpoint - Get worktree info + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError, normalizePath } from '../common.js'; + +const execAsync = promisify(exec); + +export function createInfoHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); + return; + } + + // Check if worktree exists (git worktrees are stored in project directory) + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); + try { + await secureFs.access(worktreePath); + const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + }); + res.json({ + success: true, + worktreePath: normalizePath(worktreePath), + branchName: stdout.trim(), + }); + } catch { + res.json({ success: true, worktreePath: null, branchName: null }); + } + } catch (error) { + logError(error, 'Get worktree info failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/init-git.ts b/jules_branch/apps/server/src/routes/worktree/routes/init-git.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d58942f3f15f1c5c123646d8beefb5792f14f86 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/init-git.ts @@ -0,0 +1,133 @@ +/** + * POST /init-git endpoint - Initialize a git repository in a directory + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { join } from 'path'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createInitGitHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { + projectPath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath required', + }); + return; + } + + // Check if .git already exists + const gitDirPath = join(projectPath, '.git'); + try { + await secureFs.access(gitDirPath); + // .git exists + res.json({ + success: true, + result: { + initialized: false, + message: 'Git repository already exists', + }, + }); + return; + } catch { + // .git doesn't exist, continue with initialization + } + + // Initialize git with 'main' as the default branch (matching GitHub's standard since 2020) + // Run commands sequentially so failures can be handled and partial state cleaned up. + let gitDirCreated = false; + try { + // Step 1: initialize the repository + try { + await execAsync(`git init --initial-branch=main`, { cwd: projectPath }); + } catch (initError: unknown) { + const stderr = + initError && typeof initError === 'object' && 'stderr' in initError + ? String((initError as { stderr?: string }).stderr) + : ''; + // Idempotent: if .git was created by a concurrent request or a stale lock exists, + // treat as "repo already exists" instead of failing + if ( + /could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test( + stderr + ) + ) { + try { + await secureFs.access(gitDirPath); + res.json({ + success: true, + result: { + initialized: false, + message: 'Git repository already exists', + }, + }); + return; + } catch { + // .git still missing, rethrow original error + } + } + throw initError; + } + gitDirCreated = true; + + // Step 2: ensure user.name and user.email are set so the commit can succeed. + // Check the global/system config first; only set locally if missing. + let userName = ''; + let userEmail = ''; + try { + ({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath })); + } catch { + // not set globally – will configure locally below + } + try { + ({ stdout: userEmail } = await execAsync(`git config user.email`, { + cwd: projectPath, + })); + } catch { + // not set globally – will configure locally below + } + + if (!userName.trim()) { + await execAsync(`git config user.name "Automaker"`, { cwd: projectPath }); + } + if (!userEmail.trim()) { + await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath }); + } + + // Step 3: create the initial empty commit + await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath }); + } catch (error: unknown) { + // Clean up the partial .git directory so subsequent runs behave deterministically + if (gitDirCreated) { + try { + await secureFs.rm(gitDirPath, { recursive: true, force: true }); + } catch { + // best-effort cleanup; ignore errors + } + } + throw error; + } + + res.json({ + success: true, + result: { + initialized: true, + message: 'Git repository initialized with initial commit', + }, + }); + } catch (error) { + logError(error, 'Init git failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/init-script.ts b/jules_branch/apps/server/src/routes/worktree/routes/init-script.ts new file mode 100644 index 0000000000000000000000000000000000000000..e11dfd535bda26922c1e78bc681d91d080a97ecf --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/init-script.ts @@ -0,0 +1,280 @@ +/** + * Init Script routes - Read/write/run the worktree-init.sh file + * + * POST /init-script - Read the init script content + * PUT /init-script - Write content to the init script file + * DELETE /init-script - Delete the init script file + * POST /run-init-script - Run the init script for a worktree + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../../lib/events.js'; +import { forceRunInitScript } from '../../../services/init-script-service.js'; + +const logger = createLogger('InitScript'); + +/** Fixed path for init script within .automaker directory */ +const INIT_SCRIPT_FILENAME = 'worktree-init.sh'; + +/** Maximum allowed size for init scripts (1MB) */ +const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024; + +/** + * Get the full path to the init script for a project + */ +function getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME); +} + +/** + * GET /init-script - Read the init script content + */ +export function createGetInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const rawProjectPath = req.query.projectPath; + + // Validate projectPath is a non-empty string (not an array or undefined) + if (!rawProjectPath || typeof rawProjectPath !== 'string') { + res.status(400).json({ + success: false, + error: 'projectPath query parameter is required', + }); + return; + } + + const projectPath = rawProjectPath.trim(); + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath cannot be empty', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + try { + const content = await secureFs.readFile(scriptPath, 'utf-8'); + res.json({ + success: true, + exists: true, + content: content as string, + path: scriptPath, + }); + } catch { + // File doesn't exist + res.json({ + success: true, + exists: false, + content: '', + path: scriptPath, + }); + } + } catch (error) { + logError(error, 'Read init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * PUT /init-script - Write content to the init script file + */ +export function createPutInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, content } = req.body as { + projectPath: string; + content: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (typeof content !== 'string') { + res.status(400).json({ + success: false, + error: 'content must be a string', + }); + return; + } + + // Validate script size to prevent disk exhaustion + const sizeBytes = Buffer.byteLength(content, 'utf-8'); + if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) { + res.status(400).json({ + success: false, + error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`, + }); + return; + } + + // Log warning if potentially dangerous patterns are detected (non-blocking) + const dangerousPatterns = [ + /rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable) + /curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash + /wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(content)) { + logger.warn( + `Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.` + ); + } + } + + const scriptPath = getInitScriptPath(projectPath); + const automakerDir = path.dirname(scriptPath); + + // Ensure .automaker directory exists + await secureFs.mkdir(automakerDir, { recursive: true }); + + // Write the script content + await secureFs.writeFile(scriptPath, content, 'utf-8'); + + logger.info(`Wrote init script to ${scriptPath}`); + + res.json({ + success: true, + path: scriptPath, + }); + } catch (error) { + logError(error, 'Write init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * DELETE /init-script - Delete the init script file + */ +export function createDeleteInitScriptHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + await secureFs.rm(scriptPath, { force: true }); + logger.info(`Deleted init script at ${scriptPath}`); + res.json({ + success: true, + }); + } catch (error) { + logError(error, 'Delete init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} + +/** + * POST /run-init-script - Run (or re-run) the init script for a worktree + */ +export function createRunInitScriptHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath, branch } = req.body as { + projectPath: string; + worktreePath: string; + branch: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!branch) { + res.status(400).json({ + success: false, + error: 'branch is required', + }); + return; + } + + // Validate branch name to prevent injection via environment variables + if (!isValidBranchName(branch)) { + res.status(400).json({ + success: false, + error: + 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.', + }); + return; + } + + const scriptPath = getInitScriptPath(projectPath); + + // Check if script exists + try { + await secureFs.access(scriptPath); + } catch { + res.status(404).json({ + success: false, + error: 'No init script found. Create one in Settings > Worktrees.', + }); + return; + } + + logger.info(`Running init script for branch "${branch}" (forced)`); + + // Run the script asynchronously (non-blocking) + forceRunInitScript({ + projectPath, + worktreePath, + branch, + emitter: events, + }); + + // Return immediately - progress will be streamed via WebSocket events + res.json({ + success: true, + message: 'Init script started', + }); + } catch (error) { + logError(error, 'Run init script failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/list-branches.ts b/jules_branch/apps/server/src/routes/worktree/routes/list-branches.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4ceea216fd384a11c0265d27b4060e17f97fc04 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/list-branches.ts @@ -0,0 +1,197 @@ +/** + * POST /list-branches endpoint - List all local branches and optionally remote branches + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js'; +import { getRemotesWithBranch } from '../../../services/worktree-service.js'; + +const execFileAsync = promisify(execFile); + +interface BranchInfo { + name: string; + isCurrent: boolean; + isRemote: boolean; +} + +export function createListBranchesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, includeRemote = false } = req.body as { + worktreePath: string; + includeRemote?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI) + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const currentBranch = currentBranchOutput.trim(); + + // List all local branches + const branchesOutput = await execGitCommand( + ['branch', '--format=%(refname:short)'], + worktreePath + ); + + const branches: BranchInfo[] = branchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .map((name) => { + // Remove any surrounding quotes (Windows git may preserve them) + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + return { + name: cleanName, + isCurrent: cleanName === currentBranch, + isRemote: false, + }; + }); + + // Fetch remote branches if requested + if (includeRemote) { + try { + // Fetch latest remote refs (silently, don't fail if offline) + try { + await execGitCommand(['fetch', '--all', '--quiet'], worktreePath); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // List remote branches + const remoteBranchesOutput = await execGitCommand( + ['branch', '-r', '--format=%(refname:short)'], + worktreePath + ); + + const localBranchNames = new Set(branches.map((b) => b.name)); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((b) => b.trim()) + .forEach((name) => { + // Remove any surrounding quotes + const cleanName = name.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanName.includes('/HEAD')) return; + + // Skip bare remote names without a branch (e.g. "origin" by itself) + if (!cleanName.includes('/')) return; + + // Only add remote branches if a branch with the exact same name isn't already + // in the list. This avoids duplicates if a local branch is named like a remote one. + // Note: We intentionally include remote branches even when a local branch with the + // same base name exists (e.g., show "origin/main" even if local "main" exists), + // since users need to select remote branches as PR base targets. + if (!localBranchNames.has(cleanName)) { + branches.push({ + name: cleanName, // Keep full name like "origin/main" + isCurrent: false, + isRemote: true, + }); + } + }); + } catch { + // Ignore errors fetching remote branches - return local branches only + } + } + + // Check if any remotes are configured for this repository + let hasAnyRemotes = false; + try { + const remotesOutput = await execGitCommand(['remote'], worktreePath); + hasAnyRemotes = remotesOutput.trim().length > 0; + } catch { + // If git remote fails, assume no remotes + hasAnyRemotes = false; + } + + // Get ahead/behind count for current branch and check if remote branch exists + let aheadCount = 0; + let behindCount = 0; + let hasRemoteBranch = false; + let trackingRemote: string | undefined; + // List of remote names that have a branch matching the current branch name + let remotesWithBranch: string[] = []; + try { + // First check if there's a remote tracking branch + const { stdout: upstreamOutput } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', `${currentBranch}@{upstream}`], + { cwd: worktreePath } + ); + + const upstreamRef = upstreamOutput.trim(); + if (upstreamRef) { + hasRemoteBranch = true; + // Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin") + const slashIndex = upstreamRef.indexOf('/'); + if (slashIndex !== -1) { + trackingRemote = upstreamRef.slice(0, slashIndex); + } + const { stdout: aheadBehindOutput } = await execFileAsync( + 'git', + ['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`], + { cwd: worktreePath } + ); + const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number); + aheadCount = ahead || 0; + behindCount = behind || 0; + } + } catch { + // No upstream branch set - check if the branch exists on any remote + try { + // Check if there's a matching branch on origin (most common remote) + const { stdout: remoteBranchOutput } = await execFileAsync( + 'git', + ['ls-remote', '--heads', 'origin', currentBranch], + { cwd: worktreePath, timeout: 5000 } + ); + hasRemoteBranch = remoteBranchOutput.trim().length > 0; + } catch { + // No remote branch found or origin doesn't exist + hasRemoteBranch = false; + } + } + + // Check which remotes have a branch matching the current branch name. + // This helps the UI distinguish between "branch exists on tracking remote" vs + // "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin'). + // Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true) + remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes); + + res.json({ + success: true, + result: { + currentBranch, + branches, + aheadCount, + behindCount, + hasRemoteBranch, + hasAnyRemotes, + trackingRemote, + remotesWithBranch, + }, + }); + } catch (error) { + const worktreePath = req.body?.worktreePath; + logWorktreeError(error, 'List branches failed', worktreePath); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/list-dev-servers.ts b/jules_branch/apps/server/src/routes/worktree/routes/list-dev-servers.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1093ea5c612eb1f218e6d10685aae82047853f3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/list-dev-servers.ts @@ -0,0 +1,29 @@ +/** + * POST /list-dev-servers endpoint - List all running dev servers + * + * Returns information about all worktree dev servers currently running, + * including their ports and URLs. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createListDevServersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const devServerService = getDevServerService(); + const result = devServerService.listDevServers(); + + res.json({ + success: true, + result: { + servers: result.result.servers, + }, + }); + } catch (error) { + logError(error, 'List dev servers failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/list-remotes.ts b/jules_branch/apps/server/src/routes/worktree/routes/list-remotes.ts new file mode 100644 index 0000000000000000000000000000000000000000..1180afced15f45eae044eda3c15c2e740cebc0e3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/list-remotes.ts @@ -0,0 +1,127 @@ +/** + * POST /list-remotes endpoint - List all remotes and their branches + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { getErrorMessage, logWorktreeError } from '../common.js'; + +const execAsync = promisify(exec); + +interface RemoteBranch { + name: string; + fullRef: string; +} + +interface RemoteInfo { + name: string; + url: string; + branches: RemoteBranch[]; +} + +export function createListRemotesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Get list of remotes + const { stdout: remotesOutput } = await execAsync('git remote -v', { + cwd: worktreePath, + }); + + // Parse remotes (each remote appears twice - once for fetch, once for push) + const remotesSet = new Map(); + remotesOutput + .trim() + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); + if (match) { + remotesSet.set(match[1], match[2]); + } + }); + + // Fetch latest from all remotes (silently, don't fail if offline) + try { + await execAsync('git fetch --all --quiet', { + cwd: worktreePath, + timeout: 15000, // 15 second timeout + }); + } catch { + // Ignore fetch errors - we'll use cached remote refs + } + + // Get all remote branches + const { stdout: remoteBranchesOutput } = await execAsync( + 'git branch -r --format="%(refname:short)"', + { cwd: worktreePath } + ); + + // Group branches by remote + const remotesBranches = new Map(); + remotesSet.forEach((_, remoteName) => { + remotesBranches.set(remoteName, []); + }); + + remoteBranchesOutput + .trim() + .split('\n') + .filter((line) => line.trim()) + .forEach((line) => { + const cleanLine = line.trim().replace(/^['"]|['"]$/g, ''); + // Skip HEAD pointers like "origin/HEAD" + if (cleanLine.includes('/HEAD')) return; + + // Parse remote name from branch ref (e.g., "origin/main" -> "origin") + const slashIndex = cleanLine.indexOf('/'); + if (slashIndex === -1) return; + + const remoteName = cleanLine.substring(0, slashIndex); + const branchName = cleanLine.substring(slashIndex + 1); + + if (remotesBranches.has(remoteName)) { + remotesBranches.get(remoteName)!.push({ + name: branchName, + fullRef: cleanLine, + }); + } + }); + + // Build final result + const remotes: RemoteInfo[] = []; + remotesSet.forEach((url, name) => { + remotes.push({ + name, + url, + branches: remotesBranches.get(name) || [], + }); + }); + + res.json({ + success: true, + result: { + remotes, + }, + }); + } catch (error) { + const worktreePath = req.body?.worktreePath; + logWorktreeError(error, 'List remotes failed', worktreePath); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/list.ts b/jules_branch/apps/server/src/routes/worktree/routes/list.ts new file mode 100644 index 0000000000000000000000000000000000000000..38712691d9171d49fd003cbc5458bfddcafa4530 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/list.ts @@ -0,0 +1,764 @@ +/** + * POST /list endpoint - List all git worktrees + * + * Returns actual git worktrees from `git worktree list`. + * Also scans .worktrees/ directory to discover worktrees that may have been + * created externally or whose git state was corrupted. + * Does NOT include tracked branches - only real worktrees with separate directories. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { + getErrorMessage, + logError, + normalizePath, + execEnv, + isGhCliAvailable, + execGitCommand, +} from '../common.js'; +import { + readAllWorktreeMetadata, + updateWorktreePRInfo, + type WorktreePRInfo, +} from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; +import { + checkGitHubRemote, + type GitHubRemoteStatus, +} from '../../github/routes/check-github-remote.js'; + +const execAsync = promisify(exec); +const logger = createLogger('Worktree'); + +/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */ +function isSpawnENOENT(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const e = error as { code?: string; errno?: number; syscall?: string }; + // Accept ENOENT with or without syscall so wrapped/reexported errors are handled. + // Node may set syscall to 'spawn' or 'spawn git' (or other command name). + if (e.code === 'ENOENT' || e.errno === -2) { + return ( + e.syscall === 'spawn' || + (typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) || + e.syscall === undefined + ); + } + return false; +} + +/** + * Cache for GitHub remote status per project path. + * This prevents repeated "no git remotes found" warnings when polling + * projects that don't have a GitHub remote configured. + */ +interface GitHubRemoteCacheEntry { + status: GitHubRemoteStatus; + checkedAt: number; +} + +interface GitHubPRCacheEntry { + prs: Map; + fetchedAt: number; +} + +const githubRemoteCache = new Map(); +const githubPRCache = new Map(); +const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + isCurrent: boolean; // Is this the currently checked out branch in main? + hasWorktree: boolean; // Always true for items in this list + hasChanges?: boolean; + changedFilesCount?: number; + pr?: WorktreePRInfo; // PR info if a PR has been created for this branch + /** Whether there are actual unresolved conflict files (conflictFiles.length > 0) */ + hasConflicts?: boolean; + /** Type of git operation in progress (merge/rebase/cherry-pick), set independently of hasConflicts */ + conflictType?: 'merge' | 'rebase' | 'cherry-pick'; + /** List of files with conflicts */ + conflictFiles?: string[]; + /** Source branch involved in merge/rebase/cherry-pick, when resolvable */ + conflictSourceBranch?: string; +} + +/** + * Detect if a merge, rebase, or cherry-pick is in progress for a worktree. + * Checks for the presence of state files/directories that git creates + * during these operations. + */ +async function detectConflictState(worktreePath: string): Promise<{ + hasConflicts: boolean; + conflictType?: 'merge' | 'rebase' | 'cherry-pick'; + conflictFiles?: string[]; + conflictSourceBranch?: string; +}> { + try { + // Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI) + const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + // Check for merge, rebase, and cherry-pick state files/directories + const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] = + await Promise.all([ + secureFs + .access(path.join(gitDir, 'MERGE_HEAD')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'rebase-merge')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'rebase-apply')) + .then(() => true) + .catch(() => false), + secureFs + .access(path.join(gitDir, 'CHERRY_PICK_HEAD')) + .then(() => true) + .catch(() => false), + ]); + + let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined; + if (rebaseMergeExists || rebaseApplyExists) { + conflictType = 'rebase'; + } else if (mergeHeadExists) { + conflictType = 'merge'; + } else if (cherryPickHeadExists) { + conflictType = 'cherry-pick'; + } + + if (!conflictType) { + return { hasConflicts: false }; + } + + // Get list of conflicted files using machine-readable git status + let conflictFiles: string[] = []; + try { + const statusOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + worktreePath + ); + conflictFiles = statusOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // Fall back to empty list if diff fails + } + + // Detect the source branch involved in the conflict + let conflictSourceBranch: string | undefined; + try { + if (conflictType === 'merge' && mergeHeadExists) { + // For merges, resolve MERGE_HEAD to a branch name + const mergeHead = ( + (await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } else if (conflictType === 'rebase') { + // For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name + const headNamePath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto-name') + : path.join(gitDir, 'rebase-apply', 'onto-name'); + try { + const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim(); + if (ontoName) { + conflictSourceBranch = ontoName.replace(/^refs\/heads\//, ''); + } + } catch { + // onto-name may not exist; try to resolve the onto commit + try { + const ontoPath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto') + : path.join(gitDir, 'rebase-apply', 'onto'); + const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim(); + if (ontoCommit) { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } + } catch { + // Could not resolve onto commit + } + } + } else if (conflictType === 'cherry-pick' && cherryPickHeadExists) { + // For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name + const cherryPickHead = ( + (await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } + } catch { + // Ignore source branch detection errors + } + + return { + hasConflicts: conflictFiles.length > 0, + conflictType, + conflictFiles, + conflictSourceBranch, + }; + } catch { + // If anything fails, assume no conflicts + return { hasConflicts: false }; + } +} + +async function getCurrentBranch(cwd: string): Promise { + try { + const stdout = await execGitCommand(['branch', '--show-current'], cwd); + return stdout.trim(); + } catch { + return ''; + } +} + +function normalizeBranchFromHeadRef(headRef: string): string | null { + let normalized = headRef.trim(); + const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/']; + + for (const prefix of prefixes) { + if (normalized.startsWith(prefix)) { + normalized = normalized.slice(prefix.length); + break; + } + } + + // Return the full branch name, including any slashes (e.g., "feature/my-branch") + return normalized || null; +} + +/** + * Attempt to recover the branch name for a worktree in detached HEAD state. + * This happens during rebase operations where git detaches HEAD from the branch. + * We look at git state files (rebase-merge/head-name, rebase-apply/head-name) + * to determine which branch the operation is targeting. + * + * Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain` + * still includes the `branch` line for merge conflicts. This recovery is + * specifically for rebase and cherry-pick operations. + */ +async function recoverBranchForDetachedWorktree(worktreePath: string): Promise { + try { + const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + // During a rebase, the original branch is stored in rebase-merge/head-name + try { + const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name'); + const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string; + const branch = normalizeBranchFromHeadRef(headName); + if (branch) return branch; + } catch { + // Not a rebase-merge + } + + // rebase-apply also stores the original branch in head-name + try { + const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name'); + const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string; + const branch = normalizeBranchFromHeadRef(headName); + if (branch) return branch; + } catch { + // Not a rebase-apply + } + + return null; + } catch { + return null; + } +} + +/** + * Scan the .worktrees directory to discover worktrees that may exist on disk + * but are not registered with git (e.g., created externally or corrupted state). + */ +async function scanWorktreesDirectory( + projectPath: string, + knownWorktreePaths: Set +): Promise> { + const discovered: Array<{ path: string; branch: string }> = []; + const worktreesDir = path.join(projectPath, '.worktrees'); + + try { + // Check if .worktrees directory exists + await secureFs.access(worktreesDir); + } catch { + // .worktrees directory doesn't exist + return discovered; + } + + try { + const entries = await secureFs.readdir(worktreesDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const worktreePath = path.join(worktreesDir, entry.name); + const normalizedPath = normalizePath(worktreePath); + + // Skip if already known from git worktree list + if (knownWorktreePaths.has(normalizedPath)) continue; + + // Check if this is a valid git repository + const gitPath = path.join(worktreePath, '.git'); + try { + const gitStat = await secureFs.stat(gitPath); + + // Git worktrees have a .git FILE (not directory) that points to the parent repo + // Regular repos have a .git DIRECTORY + if (gitStat.isFile() || gitStat.isDirectory()) { + // Try to get the branch name + const branch = await getCurrentBranch(worktreePath); + if (branch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${branch})` + ); + discovered.push({ + path: normalizedPath, + branch, + }); + } else { + // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + let headBranch: string | null = null; + try { + const headRef = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const ref = headRef.trim(); + if (ref && ref !== 'HEAD') { + headBranch = ref; + } + } catch (error) { + // Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery + logger.debug( + `Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}` + ); + } + + // If HEAD is detached (rebase/merge in progress), try recovery from git state files + if (!headBranch) { + headBranch = await recoverBranchForDetachedWorktree(worktreePath); + } + + if (headBranch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); + } + } + } + } catch { + // Not a git repo, skip + } + } + } catch (error) { + logger.warn(`Failed to scan .worktrees directory: ${getErrorMessage(error)}`); + } + + return discovered; +} + +/** + * Get cached GitHub remote status for a project, or check and cache it. + * Returns null if gh CLI is not available. + */ +async function getGitHubRemoteStatus(projectPath: string): Promise { + // Check if gh CLI is available first + const ghAvailable = await isGhCliAvailable(); + if (!ghAvailable) { + return null; + } + + const now = Date.now(); + const cached = githubRemoteCache.get(projectPath); + + // Return cached result if still valid + if (cached && now - cached.checkedAt < GITHUB_REMOTE_CACHE_TTL_MS) { + return cached.status; + } + + // Check GitHub remote and cache the result + const status = await checkGitHubRemote(projectPath); + githubRemoteCache.set(projectPath, { + status, + checkedAt: Date.now(), + }); + + return status; +} + +/** + * Fetch all PRs from GitHub and create a map of branch name to PR info. + * Uses --state all to include merged/closed PRs, allowing detection of + * state changes (e.g., when a PR is merged on GitHub). + * + * This also allows detecting PRs that were created outside the app. + * + * Uses cached GitHub remote status to avoid repeated warnings when the + * project doesn't have a GitHub remote configured. Results are cached + * briefly to avoid hammering GitHub on frequent worktree polls. + */ +async function fetchGitHubPRs( + projectPath: string, + forceRefresh = false +): Promise> { + const now = Date.now(); + const cached = githubPRCache.get(projectPath); + + // Return cached result if valid and not forcing refresh + if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) { + return cached.prs; + } + + const prMap = new Map(); + + try { + // Check GitHub remote status (uses cache to avoid repeated warnings) + const remoteStatus = await getGitHubRemoteStatus(projectPath); + + // If gh CLI not available or no GitHub remote, return empty silently + if (!remoteStatus || !remoteStatus.hasGitHubRemote) { + return prMap; + } + + // Use -R flag with owner/repo for more reliable PR fetching + const repoFlag = + remoteStatus.owner && remoteStatus.repo + ? `-R ${remoteStatus.owner}/${remoteStatus.repo}` + : ''; + + // Fetch all PRs from GitHub (including merged/closed to detect state changes) + const { stdout } = await execAsync( + `gh pr list ${repoFlag} --state all --json number,title,url,state,headRefName,createdAt --limit 1000`, + { cwd: projectPath, env: execEnv, timeout: 15000 } + ); + + const prs = JSON.parse(stdout || '[]') as Array<{ + number: number; + title: string; + url: string; + state: string; + headRefName: string; + createdAt: string; + }>; + + for (const pr of prs) { + prMap.set(pr.headRefName, { + number: pr.number, + url: pr.url, + title: pr.title, + // GitHub CLI returns state as uppercase: OPEN, MERGED, CLOSED + state: validatePRState(pr.state), + createdAt: pr.createdAt, + }); + } + + // Only update cache on successful fetch + githubPRCache.set(projectPath, { + prs: prMap, + fetchedAt: Date.now(), + }); + } catch (error) { + // On fetch failure, return stale cached data if available to avoid + // repeated API calls during GitHub API flakiness or temporary outages + if (cached) { + logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`); + // Extend cache TTL to avoid repeated retries during outages + githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() }); + return cached.prs; + } + // No cache available, log warning and return empty map + logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`); + } + + return prMap; +} + +export function createListHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, includeDetails, forceRefreshGitHub } = req.body as { + projectPath: string; + includeDetails?: boolean; + forceRefreshGitHub?: boolean; + }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath required' }); + return; + } + + // Clear GitHub remote cache if force refresh requested + // This allows users to re-check for GitHub remote after adding one + if (forceRefreshGitHub) { + githubRemoteCache.delete(projectPath); + } + + if (!(await isGitRepo(projectPath))) { + res.json({ success: true, worktrees: [] }); + return; + } + + // Get current branch in main directory + const currentBranch = await getCurrentBranch(projectPath); + + // Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI) + const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath); + + const worktrees: WorktreeInfo[] = []; + const removedWorktrees: Array<{ path: string; branch: string }> = []; + let hasMissingWorktree = false; + const lines = stdout.split('\n'); + let current: { path?: string; branch?: string; isDetached?: boolean } = {}; + let isFirst = true; + + // First pass: detect removed worktrees + for (const line of lines) { + if (line.startsWith('worktree ')) { + current.path = normalizePath(line.slice(9)); + } else if (line.startsWith('branch ')) { + current.branch = line.slice(7).replace('refs/heads/', ''); + } else if (line.startsWith('detached')) { + // Worktree is in detached HEAD state (e.g., during rebase) + current.isDetached = true; + } else if (line === '') { + if (current.path) { + const isMainWorktree = isFirst; + // Check if the worktree directory actually exists + // Skip checking/pruning the main worktree (projectPath itself) + let worktreeExists = false; + try { + await secureFs.access(current.path); + worktreeExists = true; + } catch { + worktreeExists = false; + } + + if (!isMainWorktree && !worktreeExists) { + hasMissingWorktree = true; + // Worktree directory doesn't exist - it was manually deleted + // Only add to removed list if we know the branch name + if (current.branch) { + removedWorktrees.push({ + path: current.path, + branch: current.branch, + }); + } + } else if (current.branch) { + // Normal case: worktree with a known branch + worktrees.push({ + path: current.path, + branch: current.branch, + isMain: isMainWorktree, + isCurrent: current.branch === currentBranch, + hasWorktree: true, + }); + isFirst = false; + } else if (current.isDetached && worktreeExists) { + // Detached HEAD (e.g., rebase in progress) - try to recover branch name. + // This is critical: without this, worktrees undergoing rebase/merge + // operations would silently disappear from the UI. + const recoveredBranch = await recoverBranchForDetachedWorktree(current.path); + worktrees.push({ + path: current.path, + branch: recoveredBranch || `(detached)`, + isMain: isMainWorktree, + isCurrent: false, + hasWorktree: true, + }); + isFirst = false; + } + } + current = {}; + } + } + + // Prune removed worktrees from git (only if any missing worktrees were detected) + if (hasMissingWorktree) { + try { + await execGitCommand(['worktree', 'prune'], projectPath); + } catch { + // Prune failed, but we'll still report the removed worktrees + } + } + + // Scan .worktrees directory to discover worktrees that exist on disk + // but are not registered with git (e.g., created externally) + const knownPaths = new Set(worktrees.map((w) => w.path)); + const discoveredWorktrees = await scanWorktreesDirectory(projectPath, knownPaths); + + // Add discovered worktrees to the list + for (const discovered of discoveredWorktrees) { + worktrees.push({ + path: discovered.path, + branch: discovered.branch, + isMain: false, + isCurrent: discovered.branch === currentBranch, + hasWorktree: true, + }); + } + + // Read all worktree metadata to get PR info + const allMetadata = await readAllWorktreeMetadata(projectPath); + + // If includeDetails is requested, fetch change status and conflict state for each worktree + if (includeDetails) { + for (const worktree of worktrees) { + try { + const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path); + const changedFiles = statusOutput + .trim() + .split('\n') + .filter((line) => line.trim()); + worktree.hasChanges = changedFiles.length > 0; + worktree.changedFilesCount = changedFiles.length; + } catch { + worktree.hasChanges = false; + worktree.changedFilesCount = 0; + } + + // Detect merge/rebase/cherry-pick in progress + try { + const conflictState = await detectConflictState(worktree.path); + // Always propagate conflictType so callers know an operation is in progress, + // even when there are no unresolved conflict files yet. + if (conflictState.conflictType) { + worktree.conflictType = conflictState.conflictType; + } + // hasConflicts is true only when there are actual unresolved files + worktree.hasConflicts = conflictState.hasConflicts; + worktree.conflictFiles = conflictState.conflictFiles; + worktree.conflictSourceBranch = conflictState.conflictSourceBranch; + } catch { + // Ignore conflict detection errors + } + } + } + + // Assign PR info to each worktree. + // Only fetch GitHub PRs if includeDetails is requested (performance optimization). + // Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs. + const githubPRs = includeDetails + ? await fetchGitHubPRs(projectPath, forceRefreshGitHub) + : new Map(); + + for (const worktree of worktrees) { + // Skip PR assignment for the main worktree - it's not meaningful to show + // PRs on the main branch tab, and can be confusing if someone created + // a PR from main to another branch + if (worktree.isMain) { + continue; + } + + const metadata = allMetadata.get(worktree.branch); + const githubPR = githubPRs.get(worktree.branch); + + const metadataPR = metadata?.pr; + // Preserve explicit user-selected PR tracking from metadata when it differs + // from branch-derived GitHub PR lookup. This allows "Change PR Number" to + // persist instead of being overwritten by gh pr list for the branch. + const hasManualOverride = + !!metadataPR && !!githubPR && metadataPR.number !== githubPR.number; + + if (hasManualOverride) { + worktree.pr = metadataPR; + } else if (githubPR) { + // Use fresh GitHub data when there is no explicit override. + worktree.pr = githubPR; + + // Sync metadata when missing or stale so fallback data stays current. + const needsSync = + !metadataPR || + metadataPR.number !== githubPR.number || + metadataPR.state !== githubPR.state || + metadataPR.title !== githubPR.title || + metadataPR.url !== githubPR.url || + metadataPR.createdAt !== githubPR.createdAt; + if (needsSync) { + // Fire and forget - don't block the response + updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => { + logger.warn( + `Failed to update PR info for ${worktree.branch}: ${getErrorMessage(err)}` + ); + }); + } + } else if (metadataPR && metadataPR.state === 'OPEN') { + // Fall back to stored metadata only if the PR is still OPEN + worktree.pr = metadataPR; + } + } + + res.json({ + success: true, + worktrees, + removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, + }); + } catch (error) { + // When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads + if (isSpawnENOENT(error)) { + const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath; + const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined; + if (mainPath) { + res.json({ + success: true, + worktrees: [ + { + path: mainPath, + branch: 'main', + isMain: true, + isCurrent: true, + hasWorktree: true, + }, + ], + }); + return; + } + } + logError(error, 'List worktrees failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/merge.ts b/jules_branch/apps/server/src/routes/worktree/routes/merge.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcd6fbc952a8c2a53b5b5464a392f324055dae15 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/merge.ts @@ -0,0 +1,83 @@ +/** + * POST /merge endpoint - Merge feature (merge worktree branch into a target branch) + * + * Allows merging a worktree branch into any target branch (defaults to 'main'). + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidProject middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performMerge } from '../../../services/merge-service.js'; + +export function createMergeHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as { + projectPath: string; + branchName: string; + worktreePath: string; + targetBranch?: string; // Branch to merge into (defaults to 'main') + options?: { + squash?: boolean; + message?: string; + deleteWorktreeAndBranch?: boolean; + remote?: string; + }; + }; + + if (!projectPath || !branchName || !worktreePath) { + res.status(400).json({ + success: false, + error: 'projectPath, branchName, and worktreePath are required', + }); + return; + } + + // Determine the target branch (default to 'main') + const mergeTo = targetBranch || 'main'; + + // Delegate all merge logic to the service + const result = await performMerge( + projectPath, + branchName, + worktreePath, + mergeTo, + options, + events + ); + + if (!result.success) { + if (result.hasConflicts) { + // Return conflict-specific error message that frontend can detect + res.status(409).json({ + success: false, + error: result.error, + hasConflicts: true, + conflictFiles: result.conflictFiles, + }); + return; + } + + // Non-conflict service errors (e.g. branch not found, invalid name) + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + mergedBranch: result.mergedBranch, + targetBranch: result.targetBranch, + deleted: result.deleted, + }); + } catch (error) { + logError(error, 'Merge worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/migrate.ts b/jules_branch/apps/server/src/routes/worktree/routes/migrate.ts new file mode 100644 index 0000000000000000000000000000000000000000..7165b1762ad306f177c13ca0fd30ce0c865923e6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/migrate.ts @@ -0,0 +1,32 @@ +/** + * POST /migrate endpoint - Migration endpoint (no longer needed) + * + * This endpoint is kept for backwards compatibility but no longer performs + * any migration since .automaker is now stored in the project directory. + */ + +import type { Request, Response } from 'express'; +import { getAutomakerDir } from '@automaker/platform'; + +export function createMigrateHandler() { + return async (req: Request, res: Response): Promise => { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + // Migration is no longer needed - .automaker is stored in project directory + const automakerDir = getAutomakerDir(projectPath); + res.json({ + success: true, + migrated: false, + message: 'No migration needed - .automaker is stored in project directory', + path: automakerDir, + }); + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/open-in-editor.ts b/jules_branch/apps/server/src/routes/worktree/routes/open-in-editor.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0d620d4cd5cdc3664f0983afd1c36e3636b1c86 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/open-in-editor.ts @@ -0,0 +1,142 @@ +/** + * POST /open-in-editor endpoint - Open a worktree directory in the default code editor + * GET /default-editor endpoint - Get the name of the default code editor + * POST /refresh-editors endpoint - Clear editor cache and re-detect available editors + * + * This module uses @automaker/platform for cross-platform editor detection and launching. + */ + +import type { Request, Response } from 'express'; +import { isAbsolute } from 'path'; +import { + clearEditorCache, + detectAllEditors, + detectDefaultEditor, + openInEditor, + openInFileManager, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('open-in-editor'); + +export function createGetAvailableEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const editors = await detectAllEditors(); + res.json({ + success: true, + result: { + editors, + }, + }); + } catch (error) { + logError(error, 'Get available editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createGetDefaultEditorHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const editor = await detectDefaultEditor(); + res.json({ + success: true, + result: { + editorName: editor.name, + editorCommand: editor.command, + }, + }); + } catch (error) { + logError(error, 'Get default editor failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to refresh the editor cache and re-detect available editors + * Useful when the user has installed/uninstalled editors + */ +export function createRefreshEditorsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearEditorCache(); + + // Re-detect editors (this will repopulate the cache) + const editors = await detectAllEditors(); + + logger.info(`Editor cache refreshed, found ${editors.length} editors`); + + res.json({ + success: true, + result: { + editors, + message: `Found ${editors.length} available editors`, + }, + }); + } catch (error) { + logError(error, 'Refresh editors failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createOpenInEditorHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, editorCommand } = req.body as { + worktreePath: string; + editorCommand?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } + + try { + // Use the platform utility to open in editor + const result = await openInEditor(worktreePath, editorCommand); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); + } catch (editorError) { + // If the specified editor fails, try opening in default file manager as fallback + logger.warn( + `Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}` + ); + + const result = await openInFileManager(worktreePath); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.editorName}`, + editorName: result.editorName, + }, + }); + } + } catch (error) { + logError(error, 'Open in editor failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/open-in-terminal.ts b/jules_branch/apps/server/src/routes/worktree/routes/open-in-terminal.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b13101e3b087e4bd3ecc528f8f7cc83f31b84c6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/open-in-terminal.ts @@ -0,0 +1,181 @@ +/** + * Terminal endpoints for opening worktree directories in terminals + * + * POST /open-in-terminal - Open in system default terminal (integrated) + * GET /available-terminals - List all available external terminals + * GET /default-terminal - Get the default external terminal + * POST /refresh-terminals - Clear terminal cache and re-detect + * POST /open-in-external-terminal - Open a directory in an external terminal + */ + +import type { Request, Response } from 'express'; +import { isAbsolute } from 'path'; +import { + openInTerminal, + clearTerminalCache, + detectAllTerminals, + detectDefaultTerminal, + openInExternalTerminal, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('open-in-terminal'); + +/** + * Handler to open in system default terminal (integrated terminal behavior) + */ +export function createOpenInTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath || typeof worktreePath !== 'string') { + res.status(400).json({ + success: false, + error: 'worktreePath required and must be a string', + }); + return; + } + + // Security: Validate that worktreePath is an absolute path + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } + + // Use the platform utility to open in terminal + const result = await openInTerminal(worktreePath); + res.json({ + success: true, + result: { + message: `Opened terminal in ${worktreePath}`, + terminalName: result.terminalName, + }, + }); + } catch (error) { + logError(error, 'Open in terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to get all available external terminals + */ +export function createGetAvailableTerminalsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const terminals = await detectAllTerminals(); + res.json({ + success: true, + result: { + terminals, + }, + }); + } catch (error) { + logError(error, 'Get available terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to get the default external terminal + */ +export function createGetDefaultTerminalHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const terminal = await detectDefaultTerminal(); + res.json({ + success: true, + result: terminal + ? { + terminalId: terminal.id, + terminalName: terminal.name, + terminalCommand: terminal.command, + } + : null, + }); + } catch (error) { + logError(error, 'Get default terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to refresh the terminal cache and re-detect available terminals + * Useful when the user has installed/uninstalled terminals + */ +export function createRefreshTerminalsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // Clear the cache + clearTerminalCache(); + + // Re-detect terminals (this will repopulate the cache) + const terminals = await detectAllTerminals(); + + logger.info(`Terminal cache refreshed, found ${terminals.length} terminals`); + + res.json({ + success: true, + result: { + terminals, + message: `Found ${terminals.length} available external terminals`, + }, + }); + } catch (error) { + logError(error, 'Refresh terminals failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Handler to open a directory in an external terminal + */ +export function createOpenInExternalTerminalHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, terminalId } = req.body as { + worktreePath: string; + terminalId?: string; + }; + + if (!worktreePath || typeof worktreePath !== 'string') { + res.status(400).json({ + success: false, + error: 'worktreePath required and must be a string', + }); + return; + } + + if (!isAbsolute(worktreePath)) { + res.status(400).json({ + success: false, + error: 'worktreePath must be an absolute path', + }); + return; + } + + const result = await openInExternalTerminal(worktreePath, terminalId); + res.json({ + success: true, + result: { + message: `Opened ${worktreePath} in ${result.terminalName}`, + terminalName: result.terminalName, + }, + }); + } catch (error) { + logError(error, 'Open in external terminal failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/pr-info.ts b/jules_branch/apps/server/src/routes/worktree/routes/pr-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d1f6b163d65fb96e4fc5f63a9f8af907cc919bc --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/pr-info.ts @@ -0,0 +1,257 @@ +/** + * POST /pr-info endpoint - Get PR info and comments for a branch + */ + +import type { Request, Response } from 'express'; +import { + getErrorMessage, + logError, + execAsync, + execEnv, + isValidBranchName, + isGhCliAvailable, +} from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('PRInfo'); + +export interface PRComment { + id: number; + author: string; + body: string; + path?: string; + line?: number; + createdAt: string; + isReviewComment: boolean; +} + +export interface PRInfo { + number: number; + title: string; + url: string; + state: string; + author: string; + body: string; + comments: PRComment[]; + reviewComments: PRComment[]; +} + +export function createPRInfoHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, branchName } = req.body as { + worktreePath: string; + branchName: string; + }; + + if (!worktreePath || !branchName) { + res.status(400).json({ + success: false, + error: 'worktreePath and branchName required', + }); + return; + } + + // Validate branch name to prevent command injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: 'Invalid branch name contains unsafe characters', + }); + return; + } + + // Check if gh CLI is available + const ghCliAvailable = await isGhCliAvailable(); + + if (!ghCliAvailable) { + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: false, + error: 'gh CLI not available', + }, + }); + return; + } + + // Detect repository information (supports fork workflows) + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + let originRepo: string | null = null; + + try { + const { stdout: remotes } = await execAsync('git remote -v', { + cwd: worktreePath, + env: execEnv, + }); + + const lines = remotes.split(/\r?\n/); + for (const line of lines) { + let match = + line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); + + if (match) { + const [, remoteName, owner, repo] = match; + if (remoteName === 'upstream') { + upstreamRepo = `${owner}/${repo}`; + } else if (remoteName === 'origin') { + originOwner = owner; + originRepo = repo; + } + } + } + } catch { + // Ignore remote parsing errors + } + + if (!originOwner || !originRepo) { + try { + const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { + cwd: worktreePath, + env: execEnv, + }); + const match = originUrl.trim().match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); + if (match) { + if (!originOwner) { + originOwner = match[1]; + } + if (!originRepo) { + originRepo = match[2]; + } + } + } catch { + // Ignore fallback errors + } + } + + const targetRepo = + upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null); + const repoFlag = targetRepo ? ` --repo "${targetRepo}"` : ''; + const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName; + + // Get PR info for the branch using gh CLI + try { + // First, find the PR associated with this branch + const listCmd = `gh pr list${repoFlag} --head "${headRef}" --json number,title,url,state,author,body --limit 1`; + const { stdout: prListOutput } = await execAsync(listCmd, { + cwd: worktreePath, + env: execEnv, + }); + + const prList = JSON.parse(prListOutput); + + if (prList.length === 0) { + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: true, + }, + }); + return; + } + + const pr = prList[0]; + const prNumber = pr.number; + + // Get regular PR comments (issue comments) + let comments: PRComment[] = []; + try { + const viewCmd = `gh pr view ${prNumber}${repoFlag} --json comments`; + const { stdout: commentsOutput } = await execAsync(viewCmd, { + cwd: worktreePath, + env: execEnv, + }); + const commentsData = JSON.parse(commentsOutput); + comments = (commentsData.comments || []).map( + (c: { id: number; author: { login: string }; body: string; createdAt: string }) => ({ + id: c.id, + author: c.author?.login || 'unknown', + body: c.body, + createdAt: c.createdAt, + isReviewComment: false, + }) + ); + } catch (error) { + logger.warn('Failed to fetch PR comments:', error); + } + + // Get review comments (inline code comments) + let reviewComments: PRComment[] = []; + // Only fetch review comments if we have repository info + if (targetRepo) { + try { + const reviewsEndpoint = `repos/${targetRepo}/pulls/${prNumber}/comments`; + const reviewsCmd = `gh api ${reviewsEndpoint}`; + const { stdout: reviewsOutput } = await execAsync(reviewsCmd, { + cwd: worktreePath, + env: execEnv, + }); + const reviewsData = JSON.parse(reviewsOutput); + reviewComments = reviewsData.map( + (c: { + id: number; + user: { login: string }; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + }) => ({ + id: c.id, + author: c.user?.login || 'unknown', + body: c.body, + path: c.path, + line: c.line || c.original_line, + createdAt: c.created_at, + isReviewComment: true, + }) + ); + } catch (error) { + logger.warn('Failed to fetch review comments:', error); + } + } else { + logger.warn('Cannot fetch review comments: repository info not available'); + } + + const prInfo: PRInfo = { + number: prNumber, + title: pr.title, + url: pr.url, + state: pr.state, + author: pr.author?.login || 'unknown', + body: pr.body || '', + comments, + reviewComments, + }; + + res.json({ + success: true, + result: { + hasPR: true, + ghCliAvailable: true, + prInfo, + }, + }); + } catch (error) { + // gh CLI failed - might not be authenticated or no remote + logError(error, 'Failed to get PR info'); + res.json({ + success: true, + result: { + hasPR: false, + ghCliAvailable: true, + error: getErrorMessage(error), + }, + }); + } + } catch (error) { + logError(error, 'PR info handler failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/pull.ts b/jules_branch/apps/server/src/routes/worktree/routes/pull.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccea76d14526ff7536a47907e5dd7e0e9a4a1e8f --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/pull.ts @@ -0,0 +1,108 @@ +/** + * POST /pull endpoint - Pull latest changes for a worktree/branch + * + * Enhanced pull flow with stash management and conflict detection: + * 1. Checks for uncommitted local changes (staged and unstaged) + * 2. If local changes exist AND stashIfNeeded is true, automatically stashes them + * 3. Performs the git pull + * 4. If changes were stashed, attempts to reapply via git stash pop + * 5. Detects merge conflicts from both pull and stash reapplication + * 6. Returns structured conflict information for AI-assisted resolution + * + * Git business logic is delegated to pull-service.ts. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { performPull } from '../../../services/pull-service.js'; +import type { PullResult } from '../../../services/pull-service.js'; + +export function createPullHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as { + worktreePath: string; + remote?: string; + /** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */ + remoteBranch?: string; + /** When true, automatically stash local changes before pulling and reapply after */ + stashIfNeeded?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Execute the pull via the service + const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded }); + + // Map service result to HTTP response + mapResultToResponse(res, result); + } catch (error) { + logError(error, 'Pull failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Map a PullResult from the service to the appropriate HTTP response. + * + * - Successful results (including local-changes-detected info) → 200 + * - Validation/state errors (detached HEAD, no upstream) → 400 + * - Operational errors (fetch/stash/pull failures) → 500 + */ +function mapResultToResponse(res: Response, result: PullResult): void { + if (!result.success && result.error) { + // Determine the appropriate HTTP status for errors + const statusCode = isClientError(result.error) ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + ...(result.stashRecoveryFailed && { stashRecoveryFailed: true }), + }); + return; + } + + // Success case (includes partial success like local changes detected, conflicts, etc.) + res.json({ + success: true, + result: { + branch: result.branch, + pulled: result.pulled, + hasLocalChanges: result.hasLocalChanges, + localChangedFiles: result.localChangedFiles, + hasConflicts: result.hasConflicts, + conflictSource: result.conflictSource, + conflictFiles: result.conflictFiles, + stashed: result.stashed, + stashRestored: result.stashRestored, + message: result.message, + isMerge: result.isMerge, + isFastForward: result.isFastForward, + mergeAffectedFiles: result.mergeAffectedFiles, + }, + }); +} + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + * + * Client errors are validation issues or invalid git state that the user + * needs to resolve (e.g. detached HEAD, no upstream, no tracking info). + */ +function isClientError(errorMessage: string): boolean { + return ( + errorMessage.includes('detached HEAD') || + errorMessage.includes('has no upstream branch') || + errorMessage.includes('no tracking information') + ); +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/push.ts b/jules_branch/apps/server/src/routes/worktree/routes/push.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bf7bc3c382ad7365da6d0947dbe5b2da456321a --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/push.ts @@ -0,0 +1,73 @@ +/** + * POST /push endpoint - Push a worktree branch to remote + * + * Git business logic is delegated to push-service.ts. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { performPush } from '../../../services/push-service.js'; + +export function createPushHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, force, remote, autoResolve } = req.body as { + worktreePath: string; + force?: boolean; + remote?: string; + autoResolve?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + const result = await performPush(worktreePath, { remote, force, autoResolve }); + + if (!result.success) { + const statusCode = isClientError(result.error ?? '') ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + diverged: result.diverged, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, + }); + return; + } + + res.json({ + success: true, + result: { + branch: result.branch, + pushed: result.pushed, + diverged: result.diverged, + autoResolved: result.autoResolved, + message: result.message, + }, + }); + } catch (error) { + logError(error, 'Push worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + */ +function isClientError(errorMessage: string): boolean { + return ( + errorMessage.includes('detached HEAD') || + errorMessage.includes('rejected') || + errorMessage.includes('diverged') + ); +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/rebase.ts b/jules_branch/apps/server/src/routes/worktree/routes/rebase.ts new file mode 100644 index 0000000000000000000000000000000000000000..05dc1e6a28b69ee07b7b5101a927f2bc71e63791 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/rebase.ts @@ -0,0 +1,135 @@ +/** + * POST /rebase endpoint - Rebase the current branch onto a target branch + * + * Rebases the current worktree branch onto a specified target branch + * (e.g., origin/main) for a linear history. Detects conflicts and + * returns structured conflict information for AI-assisted resolution. + * + * Git business logic is delegated to rebase-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import path from 'path'; +import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { runRebase } from '../../../services/rebase-service.js'; + +export function createRebaseHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, ontoBranch, remote } = req.body as { + worktreePath: string; + /** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */ + ontoBranch: string; + /** Remote name to fetch from before rebasing (defaults to 'origin') */ + remote?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + if (!ontoBranch) { + res.status(400).json({ + success: false, + error: 'ontoBranch is required', + }); + return; + } + + // Normalize the path to prevent path traversal and ensure consistent paths + const resolvedWorktreePath = path.resolve(worktreePath); + + // Validate the branch name (allow remote refs like origin/main) + if (!isValidBranchName(ontoBranch)) { + res.status(400).json({ + success: false, + error: `Invalid branch name: "${ontoBranch}"`, + }); + return; + } + + // Validate optional remote name to reject unsafe characters at the route layer + if (remote !== undefined && !isValidRemoteName(remote)) { + res.status(400).json({ + success: false, + error: `Invalid remote name: "${remote}"`, + }); + return; + } + + // Emit started event + events.emit('rebase:started', { + worktreePath: resolvedWorktreePath, + ontoBranch, + }); + + // Execute the rebase via the service + const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote }); + + if (result.success) { + // Emit success event + events.emit('rebase:success', { + worktreePath: resolvedWorktreePath, + branch: result.branch, + ontoBranch: result.ontoBranch, + }); + + res.json({ + success: true, + result: { + branch: result.branch, + ontoBranch: result.ontoBranch, + message: result.message, + }, + }); + } else if (result.hasConflicts) { + // Emit conflict event + events.emit('rebase:conflict', { + worktreePath: resolvedWorktreePath, + ontoBranch, + conflictFiles: result.conflictFiles, + aborted: result.aborted, + }); + + res.status(409).json({ + success: false, + error: result.error, + hasConflicts: true, + conflictFiles: result.conflictFiles, + aborted: result.aborted, + }); + } else { + // Emit failure event for non-conflict failures + events.emit('rebase:failure', { + worktreePath: resolvedWorktreePath, + branch: result.branch, + ontoBranch: result.ontoBranch, + error: result.error, + }); + + res.status(500).json({ + success: false, + error: result.error ?? 'Rebase failed', + hasConflicts: false, + }); + } + } catch (error) { + // Emit failure event + events.emit('rebase:failure', { + error: getErrorMessage(error), + }); + + logError(error, 'Rebase failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/set-tracking.ts b/jules_branch/apps/server/src/routes/worktree/routes/set-tracking.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d63e013d1c0d6db02f9f9d01d72d51cea475b3a --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/set-tracking.ts @@ -0,0 +1,76 @@ +/** + * POST /set-tracking endpoint - Set the upstream tracking branch for a worktree + * + * Sets `git branch --set-upstream-to=/` for the current branch. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { execGitCommand } from '@automaker/git-utils'; +import { getErrorMessage, logError } from '../common.js'; +import { getCurrentBranch } from '../../../lib/git.js'; + +export function createSetTrackingHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remote, branch } = req.body as { + worktreePath: string; + remote: string; + branch?: string; + }; + + if (!worktreePath) { + res.status(400).json({ success: false, error: 'worktreePath required' }); + return; + } + + if (!remote) { + res.status(400).json({ success: false, error: 'remote required' }); + return; + } + + // Get current branch if not provided + let targetBranch = branch; + if (!targetBranch) { + try { + targetBranch = await getCurrentBranch(worktreePath); + } catch (err) { + res.status(400).json({ + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }); + return; + } + + if (targetBranch === 'HEAD') { + res.status(400).json({ + success: false, + error: 'Cannot set tracking in detached HEAD state.', + }); + return; + } + } + + // Set upstream tracking (pass local branch name as final arg to be explicit) + await execGitCommand( + ['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch], + worktreePath + ); + + res.json({ + success: true, + result: { + branch: targetBranch, + remote, + upstream: `${remote}/${targetBranch}`, + message: `Set tracking branch to ${remote}/${targetBranch}`, + }, + }); + } catch (error) { + logError(error, 'Set tracking branch failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stage-files.ts b/jules_branch/apps/server/src/routes/worktree/routes/stage-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..d04813e7eef6eadbeab64855748549afa12c9fa5 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stage-files.ts @@ -0,0 +1,74 @@ +/** + * POST /stage-files endpoint - Stage or unstage files in a worktree + * + * Supports two operations: + * 1. Stage files: `git add ` (adds files to the staging area) + * 2. Unstage files: `git reset HEAD -- ` (removes files from staging area) + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js'; + +export function createStageFilesHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, files, operation } = req.body as { + worktreePath: string; + files: string[]; + operation: 'stage' | 'unstage'; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (!Array.isArray(files) || files.length === 0) { + res.status(400).json({ + success: false, + error: 'files array required and must not be empty', + }); + return; + } + + for (const file of files) { + if (typeof file !== 'string' || file.trim() === '') { + res.status(400).json({ + success: false, + error: 'Each element of files must be a non-empty string', + }); + return; + } + } + + if (operation !== 'stage' && operation !== 'unstage') { + res.status(400).json({ + success: false, + error: 'operation must be "stage" or "unstage"', + }); + return; + } + + const result = await stageFiles(worktreePath, files, operation); + + res.json({ + success: true, + result, + }); + } catch (error) { + if (error instanceof StageFilesValidationError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/start-dev.ts b/jules_branch/apps/server/src/routes/worktree/routes/start-dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bb111e8691d4aafe4d10f940667993bcdd0229c --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/start-dev.ts @@ -0,0 +1,84 @@ +/** + * POST /start-dev endpoint - Start a dev server for a worktree + * + * Spins up a development server in the worktree directory on a unique port, + * allowing preview of the worktree's changes without affecting the main dev server. + * + * If a custom devCommand is configured in project settings, it will be used. + * Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('start-dev'); + +export function createStartDevHandler(settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, worktreePath } = req.body as { + projectPath: string; + worktreePath: string; + }; + + if (!projectPath) { + res.status(400).json({ + success: false, + error: 'projectPath is required', + }); + return; + } + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + // Get custom dev command from project settings (if configured) + let customCommand: string | undefined; + if (settingsService) { + const projectSettings = await settingsService.getProjectSettings(projectPath); + const devCommand = projectSettings?.devCommand?.trim(); + if (devCommand) { + customCommand = devCommand; + logger.debug(`Using custom dev command from project settings: ${customCommand}`); + } else { + logger.debug('No custom dev command configured, using auto-detection'); + } + } + + const devServerService = getDevServerService(); + const result = await devServerService.startDevServer( + projectPath, + worktreePath, + customCommand + ); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + port: result.result.port, + url: result.result.url, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to start dev server', + }); + } + } catch (error) { + logError(error, 'Start dev server failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/start-tests.ts b/jules_branch/apps/server/src/routes/worktree/routes/start-tests.ts new file mode 100644 index 0000000000000000000000000000000000000000..54837056f4c0771b2b0b5f8ba66bd32286edc6de --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/start-tests.ts @@ -0,0 +1,92 @@ +/** + * POST /start-tests endpoint - Start tests for a worktree + * + * Runs the test command configured in project settings. + * If no testCommand is configured, returns an error. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStartTestsHandler(settingsService?: SettingsService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body; + + // Validate request body + if (!body || typeof body !== 'object') { + res.status(400).json({ + success: false, + error: 'Request body must be an object', + }); + return; + } + + const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined; + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined; + const testFile = typeof body.testFile === 'string' ? body.testFile : undefined; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required and must be a string', + }); + return; + } + + // Get project settings to find the test command + // Use projectPath if provided, otherwise use worktreePath + const settingsPath = projectPath || worktreePath; + + if (!settingsService) { + res.status(500).json({ + success: false, + error: 'Settings service not available', + }); + return; + } + + const projectSettings = await settingsService.getProjectSettings(settingsPath); + const testCommand = projectSettings?.testCommand; + + if (!testCommand) { + res.status(400).json({ + success: false, + error: + 'No test command configured. Please configure a test command in Project Settings > Testing Configuration.', + }); + return; + } + + const testRunnerService = getTestRunnerService(); + const result = await testRunnerService.startTests(worktreePath, { + command: testCommand, + testFile, + }); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + sessionId: result.result.sessionId, + worktreePath: result.result.worktreePath, + command: result.result.command, + status: result.result.status, + testFile: result.result.testFile, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to start tests', + }); + } + } catch (error) { + logError(error, 'Start tests failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stash-apply.ts b/jules_branch/apps/server/src/routes/worktree/routes/stash-apply.ts new file mode 100644 index 0000000000000000000000000000000000000000..f854edd3fa9dc8d64e83bb8cc3680066a1103910 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stash-apply.ts @@ -0,0 +1,78 @@ +/** + * POST /stash-apply endpoint - Apply or pop a stash in a worktree + * + * Applies a specific stash entry to the working directory. + * Can either "apply" (keep stash) or "pop" (remove stash after applying). + * + * All git operations and conflict detection are delegated to StashService. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { applyOrPop } from '../../../services/stash-service.js'; + +export function createStashApplyHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, stashIndex, pop } = req.body as { + worktreePath: string; + stashIndex: number; + pop?: boolean; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (stashIndex === undefined || stashIndex === null) { + res.status(400).json({ + success: false, + error: 'stashIndex required', + }); + return; + } + + const idx = typeof stashIndex === 'string' ? Number(stashIndex) : stashIndex; + + if (!Number.isInteger(idx) || idx < 0) { + res.status(400).json({ + success: false, + error: 'stashIndex must be a non-negative integer', + }); + return; + } + + // Delegate all stash apply/pop logic to the service + const result = await applyOrPop(worktreePath, idx, { pop }, events); + + if (!result.success) { + // applyOrPop already logs the error internally via logError — no need to double-log here + res.status(500).json({ success: false, error: result.error }); + return; + } + + res.json({ + success: true, + result: { + applied: result.applied, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, + operation: result.operation, + stashIndex: result.stashIndex, + message: result.message, + }, + }); + } catch (error) { + logError(error, 'Stash apply failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stash-drop.ts b/jules_branch/apps/server/src/routes/worktree/routes/stash-drop.ts new file mode 100644 index 0000000000000000000000000000000000000000..a05985ee83a913546057b6681bd1a29ac6e92950 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stash-drop.ts @@ -0,0 +1,83 @@ +/** + * POST /stash-drop endpoint - Drop (delete) a stash entry + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { dropStash } from '../../../services/stash-service.js'; + +export function createStashDropHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, stashIndex } = req.body as { + worktreePath: string; + stashIndex: number; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (!Number.isInteger(stashIndex) || stashIndex < 0) { + res.status(400).json({ + success: false, + error: 'stashIndex required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + stashIndex, + stashRef: `stash@{${stashIndex}}`, + operation: 'drop', + }); + + // Delegate all Git work to the service + const result = await dropStash(worktreePath, stashIndex); + + // Emit success event + events.emit('stash:success', { + worktreePath, + stashIndex, + operation: 'drop', + dropped: result.dropped, + }); + + res.json({ + success: true, + result: { + dropped: result.dropped, + stashIndex: result.stashIndex, + message: result.message, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + stashIndex: req.body?.stashIndex, + operation: 'drop', + error: getErrorMessage(error), + }); + + logError(error, 'Stash drop failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stash-list.ts b/jules_branch/apps/server/src/routes/worktree/routes/stash-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..c34b3878300439eec64815ee2e413c4f933e12a6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stash-list.ts @@ -0,0 +1,76 @@ +/** + * POST /stash-list endpoint - List all stashes in a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { listStash } from '../../../services/stash-service.js'; + +export function createStashListHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + operation: 'list', + }); + + // Delegate all Git work to the service + const result = await listStash(worktreePath); + + // Emit progress with stash count + events.emit('stash:progress', { + worktreePath, + operation: 'list', + total: result.total, + }); + + // Emit success event + events.emit('stash:success', { + worktreePath, + operation: 'list', + total: result.total, + }); + + res.json({ + success: true, + result: { + stashes: result.stashes, + total: result.total, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + operation: 'list', + error: getErrorMessage(error), + }); + + logError(error, 'Stash list failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stash-push.ts b/jules_branch/apps/server/src/routes/worktree/routes/stash-push.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2be6701df522a424c1a98f9261e0833d78af287 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stash-push.ts @@ -0,0 +1,81 @@ +/** + * POST /stash-push endpoint - Stash changes in a worktree + * + * The handler only validates input, invokes the service, streams lifecycle + * events via the EventEmitter, and sends the final JSON response. + * + * Git business logic is delegated to stash-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo) is handled by + * the requireGitRepoOnly middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import type { EventEmitter } from '../../../lib/events.js'; +import { getErrorMessage, logError } from '../common.js'; +import { pushStash } from '../../../services/stash-service.js'; + +export function createStashPushHandler(events: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, message, files } = req.body as { + worktreePath: string; + message?: string; + files?: string[]; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + // Emit start event so the frontend can observe progress + events.emit('stash:start', { + worktreePath, + operation: 'push', + }); + + // Delegate all Git work to the service + const result = await pushStash(worktreePath, { message, files }); + + // Emit progress with stash result + events.emit('stash:progress', { + worktreePath, + operation: 'push', + stashed: result.stashed, + branch: result.branch, + }); + + // Emit success event + events.emit('stash:success', { + worktreePath, + operation: 'push', + stashed: result.stashed, + branch: result.branch, + }); + + res.json({ + success: true, + result: { + stashed: result.stashed, + branch: result.branch, + message: result.message, + }, + }); + } catch (error) { + // Emit error event so the frontend can react + events.emit('stash:failure', { + worktreePath: req.body?.worktreePath, + operation: 'push', + error: getErrorMessage(error), + }); + + logError(error, 'Stash push failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/status.ts b/jules_branch/apps/server/src/routes/worktree/routes/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..b44c5ae4366b9fcdcc60a6e1a1417c2865977820 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/status.ts @@ -0,0 +1,73 @@ +/** + * POST /status endpoint - Get worktree status + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import * as secureFs from '../../../lib/secure-fs.js'; +import { getErrorMessage, logError } from '../common.js'; + +const execAsync = promisify(exec); + +export function createStatusHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId } = req.body as { + projectPath: string; + featureId: string; + }; + + if (!projectPath || !featureId) { + res.status(400).json({ + success: false, + error: 'projectPath and featureId required', + }); + return; + } + + // Git worktrees are stored in project directory + // Sanitize featureId the same way it's sanitized when creating worktrees + // (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-')) + const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-'); + const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId); + + try { + await secureFs.access(worktreePath); + const { stdout: status } = await execAsync('git status --porcelain', { + cwd: worktreePath, + }); + const files = status + .split('\n') + .filter(Boolean) + .map((line) => line.slice(3)); + const { stdout: diffStat } = await execAsync('git diff --stat', { + cwd: worktreePath, + }); + const { stdout: logOutput } = await execAsync('git log --oneline -5 --format="%h %s"', { + cwd: worktreePath, + }); + + res.json({ + success: true, + modifiedFiles: files.length, + files, + diffStat: diffStat.trim(), + recentCommits: logOutput.trim().split('\n').filter(Boolean), + }); + } catch { + res.json({ + success: true, + modifiedFiles: 0, + files: [], + diffStat: '', + recentCommits: [], + }); + } + } catch (error) { + logError(error, 'Get worktree status failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stop-dev.ts b/jules_branch/apps/server/src/routes/worktree/routes/stop-dev.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dbc7340b6604d3fd0ecd09651210acfb2ae2e1a --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stop-dev.ts @@ -0,0 +1,49 @@ +/** + * POST /stop-dev endpoint - Stop a dev server for a worktree + * + * Stops the development server running for a specific worktree, + * freeing up the ports for reuse. + */ + +import type { Request, Response } from 'express'; +import { getDevServerService } from '../../../services/dev-server-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStopDevHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as { + worktreePath: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath is required', + }); + return; + } + + const devServerService = getDevServerService(); + const result = await devServerService.stopDevServer(worktreePath); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + worktreePath: result.result.worktreePath, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to stop dev server', + }); + } + } catch (error) { + logError(error, 'Stop dev server failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/stop-tests.ts b/jules_branch/apps/server/src/routes/worktree/routes/stop-tests.ts new file mode 100644 index 0000000000000000000000000000000000000000..48181f242552b2130588ecf2b143fa03ae944918 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/stop-tests.ts @@ -0,0 +1,58 @@ +/** + * POST /stop-tests endpoint - Stop a running test session + * + * Stops the test runner process for a specific session, + * cancelling any ongoing tests and freeing up resources. + */ + +import type { Request, Response } from 'express'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +export function createStopTestsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body; + + // Validate request body + if (!body || typeof body !== 'object') { + res.status(400).json({ + success: false, + error: 'Request body must be an object', + }); + return; + } + + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined; + + if (!sessionId) { + res.status(400).json({ + success: false, + error: 'sessionId is required and must be a string', + }); + return; + } + + const testRunnerService = getTestRunnerService(); + const result = await testRunnerService.stopTests(sessionId); + + if (result.success && result.result) { + res.json({ + success: true, + result: { + sessionId: result.result.sessionId, + message: result.result.message, + }, + }); + } else { + res.status(400).json({ + success: false, + error: result.error || 'Failed to stop tests', + }); + } + } catch (error) { + logError(error, 'Stop tests failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/switch-branch.ts b/jules_branch/apps/server/src/routes/worktree/routes/switch-branch.ts new file mode 100644 index 0000000000000000000000000000000000000000..abcdfdcd3c95fd60de006320b47476038480e9b9 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/switch-branch.ts @@ -0,0 +1,104 @@ +/** + * POST /switch-branch endpoint - Switch to an existing branch + * + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response code so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Also fetches the latest remote refs before switching to ensure accurate branch detection. + * + * Git business logic is delegated to worktree-branch-service.ts. + * Events are emitted at key lifecycle points for WebSocket subscribers. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError, isValidBranchName } from '../common.js'; +import type { EventEmitter } from '../../../lib/events.js'; +import { performSwitchBranch } from '../../../services/worktree-branch-service.js'; + +export function createSwitchBranchHandler(events?: EventEmitter) { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, branchName } = req.body as { + worktreePath: string; + branchName: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + if (!branchName) { + res.status(400).json({ + success: false, + error: 'branchName required', + }); + return; + } + + // Validate branch name using shared allowlist to prevent Git option injection + if (!isValidBranchName(branchName)) { + res.status(400).json({ + success: false, + error: 'Invalid branch name', + }); + return; + } + + // Execute the branch switch via the service + const result = await performSwitchBranch(worktreePath, branchName, events); + + // Map service result to HTTP response + if (!result.success) { + // Determine status code based on error type + const statusCode = isBranchNotFoundError(result.error) ? 400 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + ...(result.stashPopConflicts !== undefined && { + stashPopConflicts: result.stashPopConflicts, + }), + ...(result.stashPopConflictMessage && { + stashPopConflictMessage: result.stashPopConflictMessage, + }), + }); + return; + } + + res.json({ + success: true, + result: result.result, + }); + } catch (error) { + events?.emit('switch:error', { + error: getErrorMessage(error), + }); + + logError(error, 'Switch branch failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +/** + * Determine whether an error message represents a client error (400) + * vs a server error (500). + * + * Client errors are validation issues like non-existent branches or + * unparseable remote branch names. + */ +function isBranchNotFoundError(error?: string): boolean { + if (!error) return false; + return error.includes('does not exist') || error.includes('Failed to parse remote branch name'); +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/sync.ts b/jules_branch/apps/server/src/routes/worktree/routes/sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..acd2ec3b3bc628a736f77036dffadc55c59f79a3 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/sync.ts @@ -0,0 +1,66 @@ +/** + * POST /sync endpoint - Pull then push a worktree branch + * + * Performs a full sync operation: pull latest from remote, then push + * local commits. Handles divergence automatically. + * + * Git business logic is delegated to sync-service.ts. + * + * Note: Git repository validation (isGitRepo, hasCommits) is handled by + * the requireValidWorktree middleware in index.ts + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError } from '../common.js'; +import { performSync } from '../../../services/sync-service.js'; + +export function createSyncHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, remote } = req.body as { + worktreePath: string; + remote?: string; + }; + + if (!worktreePath) { + res.status(400).json({ + success: false, + error: 'worktreePath required', + }); + return; + } + + const result = await performSync(worktreePath, { remote }); + + if (!result.success) { + const statusCode = result.hasConflicts ? 409 : 500; + res.status(statusCode).json({ + success: false, + error: result.error, + hasConflicts: result.hasConflicts, + conflictFiles: result.conflictFiles, + conflictSource: result.conflictSource, + pulled: result.pulled, + pushed: result.pushed, + }); + return; + } + + res.json({ + success: true, + result: { + branch: result.branch, + pulled: result.pulled, + pushed: result.pushed, + isFastForward: result.isFastForward, + isMerge: result.isMerge, + autoResolved: result.autoResolved, + message: result.message, + }, + }); + } catch (error) { + logError(error, 'Sync worktree failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/test-logs.ts b/jules_branch/apps/server/src/routes/worktree/routes/test-logs.ts new file mode 100644 index 0000000000000000000000000000000000000000..724730cc3db100c4589c724849c98f04e4bec309 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/test-logs.ts @@ -0,0 +1,160 @@ +/** + * GET /test-logs endpoint - Get buffered logs for a test runner session + * + * Returns the scrollback buffer containing historical log output for a test run. + * Used by clients to populate the log panel on initial connection + * before subscribing to real-time updates via WebSocket. + * + * Query parameters: + * - worktreePath: Path to the worktree (optional if sessionId provided) + * - sessionId: Specific test session ID (optional, uses active session if not provided) + */ + +import type { Request, Response } from 'express'; +import { getTestRunnerService } from '../../../services/test-runner-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface SessionInfo { + sessionId: string; + worktreePath?: string; + command?: string; + testFile?: string; + exitCode?: number | null; +} + +interface OutputResult { + sessionId: string; + status: string; + output: string; + startedAt: string; + finishedAt?: string | null; +} + +function buildLogsResponse(session: SessionInfo, output: OutputResult) { + return { + success: true, + result: { + sessionId: session.sessionId, + worktreePath: session.worktreePath, + command: session.command, + status: output.status, + testFile: session.testFile, + logs: output.output, + startedAt: output.startedAt, + finishedAt: output.finishedAt, + exitCode: session.exitCode ?? null, + }, + }; +} + +export function createGetTestLogsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, sessionId } = req.query as { + worktreePath?: string; + sessionId?: string; + }; + + const testRunnerService = getTestRunnerService(); + + // If sessionId is provided, get logs for that specific session + if (sessionId) { + const result = testRunnerService.getSessionOutput(sessionId); + + if (result.success && result.result) { + const session = testRunnerService.getSession(sessionId); + res.json( + buildLogsResponse( + { + sessionId: result.result.sessionId, + worktreePath: session?.worktreePath, + command: session?.command, + testFile: session?.testFile, + exitCode: session?.exitCode, + }, + result.result + ) + ); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get test logs', + }); + } + return; + } + + // If worktreePath is provided, get logs for the active session + if (worktreePath) { + const activeSession = testRunnerService.getActiveSession(worktreePath); + + if (activeSession) { + const result = testRunnerService.getSessionOutput(activeSession.id); + + if (result.success && result.result) { + res.json( + buildLogsResponse( + { + sessionId: activeSession.id, + worktreePath: activeSession.worktreePath, + command: activeSession.command, + testFile: activeSession.testFile, + exitCode: activeSession.exitCode, + }, + result.result + ) + ); + } else { + res.status(404).json({ + success: false, + error: result.error || 'Failed to get test logs', + }); + } + } else { + // No active session - check for most recent session for this worktree + const sessions = testRunnerService.listSessions(worktreePath); + if (sessions.result.sessions.length > 0) { + // Get the most recent session (list is not sorted, so find it) + const mostRecent = sessions.result.sessions.reduce((latest, current) => { + const latestTime = new Date(latest.startedAt).getTime(); + const currentTime = new Date(current.startedAt).getTime(); + return currentTime > latestTime ? current : latest; + }); + + const result = testRunnerService.getSessionOutput(mostRecent.sessionId); + if (result.success && result.result) { + res.json( + buildLogsResponse( + { + sessionId: mostRecent.sessionId, + worktreePath: mostRecent.worktreePath, + command: mostRecent.command, + testFile: mostRecent.testFile, + exitCode: mostRecent.exitCode, + }, + result.result + ) + ); + return; + } + } + + res.status(404).json({ + success: false, + error: 'No test sessions found for this worktree', + }); + } + return; + } + + // Neither sessionId nor worktreePath provided + res.status(400).json({ + success: false, + error: 'Either worktreePath or sessionId query parameter is required', + }); + } catch (error) { + logError(error, 'Get test logs failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/worktree/routes/update-pr-number.ts b/jules_branch/apps/server/src/routes/worktree/routes/update-pr-number.ts new file mode 100644 index 0000000000000000000000000000000000000000..b39508f9e55e9bf8e160546b47bf5cde51187e64 --- /dev/null +++ b/jules_branch/apps/server/src/routes/worktree/routes/update-pr-number.ts @@ -0,0 +1,163 @@ +/** + * POST /update-pr-number endpoint - Update the tracked PR number for a worktree + * + * Allows users to manually change which PR number is tracked for a worktree branch. + * Fetches updated PR info from GitHub when available, or updates metadata with the + * provided number only if GitHub CLI is unavailable. + */ + +import type { Request, Response } from 'express'; +import { getErrorMessage, logError, execAsync, execEnv, isGhCliAvailable } from '../common.js'; +import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; +import { validatePRState } from '@automaker/types'; + +const logger = createLogger('UpdatePRNumber'); + +export function createUpdatePRNumberHandler() { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath, projectPath, prNumber } = req.body as { + worktreePath: string; + projectPath?: string; + prNumber: number; + }; + + if (!worktreePath) { + res.status(400).json({ success: false, error: 'worktreePath required' }); + return; + } + + if ( + !prNumber || + typeof prNumber !== 'number' || + prNumber <= 0 || + !Number.isInteger(prNumber) + ) { + res.status(400).json({ success: false, error: 'prNumber must be a positive integer' }); + return; + } + + const effectiveProjectPath = projectPath || worktreePath; + + // Get current branch name + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + env: execEnv, + }); + const branchName = branchOutput.trim(); + + if (!branchName || branchName === 'HEAD') { + res.status(400).json({ + success: false, + error: 'Cannot update PR number in detached HEAD state', + }); + return; + } + + // Try to fetch PR info from GitHub for the given PR number + const ghCliAvailable = await isGhCliAvailable(); + + if (ghCliAvailable) { + try { + // Detect repository for gh CLI + let repoFlag = ''; + try { + const { stdout: remotes } = await execAsync('git remote -v', { + cwd: worktreePath, + env: execEnv, + }); + const lines = remotes.split(/\r?\n/); + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + let originRepo: string | null = null; + + for (const line of lines) { + const match = + line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) || + line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/); + + if (match) { + const [, remoteName, owner, repo] = match; + if (remoteName === 'upstream') { + upstreamRepo = `${owner}/${repo}`; + } else if (remoteName === 'origin') { + originOwner = owner; + originRepo = repo; + } + } + } + + const targetRepo = + upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null); + if (targetRepo) { + repoFlag = ` --repo "${targetRepo}"`; + } + } catch { + // Ignore remote parsing errors + } + + // Fetch PR info from GitHub using the PR number + const viewCmd = `gh pr view ${prNumber}${repoFlag} --json number,title,url,state,createdAt`; + const { stdout: prOutput } = await execAsync(viewCmd, { + cwd: worktreePath, + env: execEnv, + }); + + const prData = JSON.parse(prOutput); + + const prInfo = { + number: prData.number, + url: prData.url, + title: prData.title, + state: validatePRState(prData.state), + createdAt: prData.createdAt || new Date().toISOString(), + }; + + await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo); + + logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName}`); + + res.json({ + success: true, + result: { + branch: branchName, + prInfo, + }, + }); + return; + } catch (error) { + logger.warn(`Failed to fetch PR #${prNumber} from GitHub:`, error); + // Fall through to simple update below + } + } + + // Fallback: update with just the number, preserving existing PR info structure + // or creating minimal info if no GitHub data available + const prInfo = { + number: prNumber, + url: `https://github.com/pulls/${prNumber}`, + title: `PR #${prNumber}`, + state: validatePRState('OPEN'), + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo); + + logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName} (no GitHub data)`); + + res.json({ + success: true, + result: { + branch: branchName, + prInfo, + ghCliUnavailable: !ghCliAvailable, + }, + }); + } catch (error) { + logError(error, 'Update PR number failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/jules_branch/apps/server/src/routes/zai/index.ts b/jules_branch/apps/server/src/routes/zai/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e5b874cc3843a9bf8c15a14ab8ed79c527106c6 --- /dev/null +++ b/jules_branch/apps/server/src/routes/zai/index.ts @@ -0,0 +1,159 @@ +import { Router, Request, Response } from 'express'; +import { ZaiUsageService } from '../../services/zai-usage-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('Zai'); + +export function createZaiRoutes( + usageService: ZaiUsageService, + settingsService: SettingsService +): Router { + const router = Router(); + + // Initialize z.ai API token from credentials on startup + (async () => { + try { + const credentials = await settingsService.getCredentials(); + if (credentials.apiKeys?.zai) { + usageService.setApiToken(credentials.apiKeys.zai); + logger.info('[init] Loaded z.ai API key from credentials'); + } + } catch (error) { + logger.error('[init] Failed to load z.ai API key from credentials:', error); + } + })(); + + // Get current usage (fetches from z.ai API) + router.get('/usage', async (_req: Request, res: Response) => { + try { + // Check if z.ai API is configured + const isAvailable = usageService.isAvailable(); + if (!isAvailable) { + // Use a 200 + error payload so the UI doesn't interpret it as session auth error + res.status(200).json({ + error: 'z.ai API not configured', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + return; + } + + const usage = await usageService.fetchUsageData(); + res.json(usage); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + + if (message.includes('not configured') || message.includes('API token')) { + res.status(200).json({ + error: 'API token required', + message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking', + }); + } else if (message.includes('failed') || message.includes('request')) { + res.status(200).json({ + error: 'API request failed', + message: message, + }); + } else { + logger.error('Error fetching z.ai usage:', error); + res.status(500).json({ error: message }); + } + } + }); + + // Configure API token (for settings page) + router.post('/configure', async (req: Request, res: Response) => { + try { + const { apiToken, apiHost } = req.body; + + // Validate apiToken: must be present and a string + if (apiToken === undefined || apiToken === null || typeof apiToken !== 'string') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiToken is required and must be a string', + }); + return; + } + + // Validate apiHost if provided: must be a string and a well-formed URL + if (apiHost !== undefined && apiHost !== null) { + if (typeof apiHost !== 'string') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a string', + }); + return; + } + // Validate that apiHost is a well-formed URL + try { + const parsedUrl = new URL(apiHost); + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a valid HTTP or HTTPS URL', + }); + return; + } + } catch { + res.status(400).json({ + success: false, + error: 'Invalid request: apiHost must be a well-formed URL', + }); + return; + } + } + + // Pass only the sanitized values to the service + const sanitizedToken = apiToken.trim(); + const sanitizedHost = typeof apiHost === 'string' ? apiHost.trim() : undefined; + + const result = await usageService.configure( + { apiToken: sanitizedToken, apiHost: sanitizedHost }, + settingsService + ); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error configuring z.ai:', error); + res.status(500).json({ error: message }); + } + }); + + // Verify API key without storing it (for testing in settings) + router.post('/verify', async (req: Request, res: Response) => { + try { + const { apiKey } = req.body; + const result = await usageService.verifyApiKey(apiKey); + res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + res.json({ + success: false, + authenticated: false, + error: `Network error: ${message}`, + }); + } + }); + + // Check if z.ai is available + router.get('/status', async (_req: Request, res: Response) => { + try { + const isAvailable = usageService.isAvailable(); + const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY); + const hasApiKey = usageService.getApiToken() !== null; + + res.json({ + success: true, + available: isAvailable, + hasApiKey, + hasEnvApiKey, + message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured', + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } + }); + + return router; +} diff --git a/jules_branch/apps/server/src/services/agent-executor-types.ts b/jules_branch/apps/server/src/services/agent-executor-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef0dcb68c3cfda5f10a2e53389d3642101833e33 --- /dev/null +++ b/jules_branch/apps/server/src/services/agent-executor-types.ts @@ -0,0 +1,89 @@ +/** + * AgentExecutor Types - Type definitions for agent execution + */ + +import type { + PlanningMode, + ThinkingLevel, + ReasoningEffort, + ParsedTask, + ClaudeCompatibleProvider, + Credentials, +} from '@automaker/types'; +import type { BaseProvider } from '../providers/base-provider.js'; + +export interface AgentExecutionOptions { + workDir: string; + featureId: string; + prompt: string; + projectPath: string; + abortController: AbortController; + imagePaths?: string[]; + model?: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + branchName?: string | null; + credentials?: Credentials; + claudeCompatibleProvider?: ClaudeCompatibleProvider; + mcpServers?: Record; + sdkSessionId?: string; + sdkOptions?: { + maxTurns?: number; + allowedTools?: string[]; + systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; + settingSources?: Array<'user' | 'project' | 'local'>; + }; + provider: BaseProvider; + effectiveBareModel: string; + specAlreadyDetected?: boolean; + existingApprovedPlanContent?: string; + persistedTasks?: ParsedTask[]; + /** Feature status - used to check if pipeline summary extraction is required */ + status?: string; +} + +export interface AgentExecutionResult { + responseText: string; + specDetected: boolean; + tasksCompleted: number; + aborted: boolean; +} + +export type WaitForApprovalFn = ( + featureId: string, + projectPath: string +) => Promise<{ approved: boolean; feedback?: string; editedPlan?: string }>; + +export type SaveFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +export type UpdateFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +export type BuildTaskPromptFn = ( + task: ParsedTask, + allTasks: ParsedTask[], + taskIndex: number, + planContent: string, + taskPromptTemplate: string, + userFeedback?: string +) => string; + +export interface AgentExecutorCallbacks { + waitForApproval: WaitForApprovalFn; + saveFeatureSummary: SaveFeatureSummaryFn; + updateFeatureSummary: UpdateFeatureSummaryFn; + buildTaskPrompt: BuildTaskPromptFn; +} diff --git a/jules_branch/apps/server/src/services/agent-executor.ts b/jules_branch/apps/server/src/services/agent-executor.ts new file mode 100644 index 0000000000000000000000000000000000000000..15731b9285e7b003b0302775945c2c6e4e047526 --- /dev/null +++ b/jules_branch/apps/server/src/services/agent-executor.ts @@ -0,0 +1,877 @@ +/** + * AgentExecutor - Core agent execution engine with streaming support + */ + +import path from 'path'; +import type { ExecuteOptions, ParsedTask } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; +import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import { TypedEventBus } from './typed-event-bus.js'; +import { FeatureStateManager } from './feature-state-manager.js'; +import { PlanApprovalService } from './plan-approval-service.js'; +import type { SettingsService } from './settings-service.js'; +import { + parseTasksFromSpec, + detectTaskStartMarker, + detectTaskCompleteMarker, + detectPhaseCompleteMarker, + detectSpecFallback, + extractSummary, +} from './spec-parser.js'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; +import type { + AgentExecutionOptions, + AgentExecutionResult, + AgentExecutorCallbacks, +} from './agent-executor-types.js'; + +// Re-export types for backward compatibility +export type { + AgentExecutionOptions, + AgentExecutionResult, + WaitForApprovalFn, + SaveFeatureSummaryFn, + UpdateFeatureSummaryFn, + BuildTaskPromptFn, +} from './agent-executor-types.js'; + +const logger = createLogger('AgentExecutor'); + +const DEFAULT_MAX_TURNS = 10000; + +export class AgentExecutor { + private static readonly WRITE_DEBOUNCE_MS = 500; + private static readonly STREAM_HEARTBEAT_MS = 15_000; + + /** + * Sanitize a provider error value into clean text. + * Coalesces to string, removes ANSI codes, strips leading "Error:" prefix, + * trims, and returns 'Unknown error' when empty. + */ + private static sanitizeProviderError(input: string | { error?: string } | undefined): string { + let raw: string; + if (typeof input === 'string') { + raw = input; + } else if (input && typeof input === 'object' && typeof input.error === 'string') { + raw = input.error; + } else { + raw = ''; + } + const cleaned = raw + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim(); + return cleaned || 'Unknown error'; + } + + constructor( + private eventBus: TypedEventBus, + private featureStateManager: FeatureStateManager, + private planApprovalService: PlanApprovalService, + private settingsService: SettingsService | null = null + ) {} + + async execute( + options: AgentExecutionOptions, + callbacks: AgentExecutorCallbacks + ): Promise { + const { + workDir, + featureId, + projectPath, + abortController, + branchName = null, + provider, + effectiveBareModel, + previousContent, + planningMode = 'skip', + requirePlanApproval = false, + specAlreadyDetected = false, + existingApprovedPlanContent, + persistedTasks, + credentials, + status, // Feature status for pipeline summary check + claudeCompatibleProvider, + mcpServers, + sdkSessionId, + sdkOptions, + } = options; + const { content: promptContent } = await buildPromptWithImages( + options.prompt, + options.imagePaths, + workDir, + false + ); + const resolvedMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; + if (sdkOptions?.maxTurns == null) { + logger.info( + `[execute] Feature ${featureId}: sdkOptions.maxTurns is not set, defaulting to ${resolvedMaxTurns}. ` + + `Model: ${effectiveBareModel}` + ); + } else { + logger.info( + `[execute] Feature ${featureId}: maxTurns=${resolvedMaxTurns}, model=${effectiveBareModel}` + ); + } + + const executeOptions: ExecuteOptions = { + prompt: promptContent, + model: effectiveBareModel, + maxTurns: resolvedMaxTurns, + cwd: workDir, + allowedTools: sdkOptions?.allowedTools as string[] | undefined, + abortController, + systemPrompt: sdkOptions?.systemPrompt, + settingSources: sdkOptions?.settingSources, + mcpServers: + mcpServers && Object.keys(mcpServers).length > 0 + ? (mcpServers as Record) + : undefined, + thinkingLevel: options.thinkingLevel, + reasoningEffort: options.reasoningEffort, + credentials, + claudeCompatibleProvider, + sdkSessionId, + }; + const featureDirForOutput = getFeatureDir(projectPath, featureId); + const outputPath = path.join(featureDirForOutput, 'agent-output.md'); + const rawOutputPath = path.join(featureDirForOutput, 'raw-output.jsonl'); + const enableRawOutput = + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || + process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; + let responseText = previousContent + ? `${previousContent}\n\n---\n\n## Follow-up Session\n\n` + : ''; + let specDetected = specAlreadyDetected, + tasksCompleted = 0, + aborted = false; + let writeTimeout: ReturnType | null = null, + rawOutputLines: string[] = [], + rawWriteTimeout: ReturnType | null = null; + + const writeToFile = async (): Promise => { + try { + await secureFs.mkdir(path.dirname(outputPath), { recursive: true }); + await secureFs.writeFile(outputPath, responseText); + } catch (error) { + logger.error(`Failed to write agent output for ${featureId}:`, error); + } + }; + const scheduleWrite = (): void => { + if (writeTimeout) clearTimeout(writeTimeout); + writeTimeout = setTimeout(() => writeToFile(), AgentExecutor.WRITE_DEBOUNCE_MS); + }; + const appendRawEvent = (event: unknown): void => { + if (!enableRawOutput) return; + try { + rawOutputLines.push(JSON.stringify({ timestamp: new Date().toISOString(), event })); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + rawWriteTimeout = setTimeout(async () => { + try { + await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); + await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); + rawOutputLines = []; + } catch { + /* ignore */ + } + }, AgentExecutor.WRITE_DEBOUNCE_MS); + } catch { + /* ignore */ + } + }; + + const streamStartTime = Date.now(); + let receivedAnyStreamMessage = false; + const streamHeartbeat = setInterval(() => { + if (!receivedAnyStreamMessage) + logger.info( + `Waiting for first model response for feature ${featureId} (${Math.round((Date.now() - streamStartTime) / 1000)}s elapsed)...` + ); + }, AgentExecutor.STREAM_HEARTBEAT_MS); + const planningModeRequiresApproval = + planningMode === 'spec' || + planningMode === 'full' || + (planningMode === 'lite' && requirePlanApproval); + const requiresApproval = planningModeRequiresApproval && requirePlanApproval; + + if (existingApprovedPlanContent && persistedTasks && persistedTasks.length > 0) { + const result = await this.executeTasksLoop( + options, + persistedTasks, + existingApprovedPlanContent, + responseText, + scheduleWrite, + callbacks + ); + clearInterval(streamHeartbeat); + if (writeTimeout) clearTimeout(writeTimeout); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + await writeToFile(); + + // Extract and save summary from the new content generated in this session + await this.extractAndSaveSessionSummary( + projectPath, + featureId, + result.responseText, + previousContent, + callbacks, + status + ); + + return { + responseText: result.responseText, + specDetected: true, + tasksCompleted: result.tasksCompleted, + aborted: result.aborted, + }; + } + + logger.info(`Starting stream for feature ${featureId}...`); + + try { + const stream = provider.executeQuery(executeOptions); + streamLoop: for await (const msg of stream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + receivedAnyStreamMessage = true; + appendRawEvent(msg); + if (abortController.signal.aborted) { + aborted = true; + throw new Error('Feature execution aborted'); + } + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + const newText = block.text || ''; + if (!newText) continue; + if (responseText.length > 0 && newText.length > 0) { + const endsWithSentence = /[.!?:]\s*$/.test(responseText), + endsWithNewline = /\n\s*$/.test(responseText); + if ( + !endsWithNewline && + (endsWithSentence || /^[\n#\-*>]/.test(newText)) && + !/[a-zA-Z0-9]/.test(responseText.slice(-1)) + ) + responseText += '\n\n'; + } + responseText += newText; + // Check for authentication errors using provider-agnostic utility + if (block.text && isAuthenticationError(block.text)) + throw new Error( + 'Authentication failed: Invalid or expired API key. Please check your API key configuration or re-authenticate with your provider.' + ); + scheduleWrite(); + const hasExplicitMarker = responseText.includes('[SPEC_GENERATED]'), + hasFallbackSpec = !hasExplicitMarker && detectSpecFallback(responseText); + if ( + planningModeRequiresApproval && + !specDetected && + (hasExplicitMarker || hasFallbackSpec) + ) { + specDetected = true; + const planContent = hasExplicitMarker + ? responseText.substring(0, responseText.indexOf('[SPEC_GENERATED]')).trim() + : responseText.trim(); + if (!hasExplicitMarker) + logger.info(`Using fallback spec detection for feature ${featureId}`); + const result = await this.handleSpecGenerated( + options, + planContent, + responseText, + requiresApproval, + scheduleWrite, + callbacks + ); + responseText = result.responseText; + tasksCompleted = result.tasksCompleted; + break streamLoop; + } + if (!specDetected) + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: block.text, + }); + } else if (block.type === 'tool_use') { + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: block.name, + input: block.input, + }); + if (responseText.length > 0 && !responseText.endsWith('\n')) responseText += '\n'; + responseText += `\n🔧 Tool: ${block.name}\n`; + if (block.input) responseText += `Input: ${JSON.stringify(block.input, null, 2)}\n`; + scheduleWrite(); + } + } + } else if (msg.type === 'error') { + const sanitized = AgentExecutor.sanitizeProviderError(msg.error); + logger.error( + `[execute] Feature ${featureId} received error from provider. ` + + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(sanitized); + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + scheduleWrite(); + } else if (msg.subtype?.startsWith('error')) { + // Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.) + logger.error( + `[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` + + `session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(`Agent execution ended with: ${msg.subtype}`); + } else { + logger.warn( + `[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}` + ); + } + } + } + } finally { + clearInterval(streamHeartbeat); + if (writeTimeout) clearTimeout(writeTimeout); + if (rawWriteTimeout) clearTimeout(rawWriteTimeout); + + const streamElapsedMs = Date.now() - streamStartTime; + logger.info( + `[execute] Stream ended for feature ${featureId} after ${Math.round(streamElapsedMs / 1000)}s. ` + + `aborted=${aborted}, specDetected=${specDetected}, responseLength=${responseText.length}` + ); + + await writeToFile(); + if (enableRawOutput && rawOutputLines.length > 0) { + try { + await secureFs.mkdir(path.dirname(rawOutputPath), { recursive: true }); + await secureFs.appendFile(rawOutputPath, rawOutputLines.join('\n') + '\n'); + } catch { + /* ignore */ + } + } + } + + // Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop + // or if we're in a simple execution mode (planningMode='skip') + await this.extractAndSaveSessionSummary( + projectPath, + featureId, + responseText, + previousContent, + callbacks, + status + ); + + return { responseText, specDetected, tasksCompleted, aborted }; + } + + /** + * Strip the follow-up session scaffold marker from content. + * The scaffold is added when resuming a session with previous content: + * "\n\n---\n\n## Follow-up Session\n\n" + * This ensures fallback summaries don't include the scaffold header. + * + * The regex pattern handles variations in whitespace while matching the + * scaffold structure: dashes followed by "## Follow-up Session" at the + * start of the content. + */ + private static stripFollowUpScaffold(content: string): string { + // Pattern matches: ^\s*---\s*##\s*Follow-up Session\s* + // - ^ = start of content (scaffold is always at the beginning of sessionContent) + // - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers) + // - --- = literal dashes + // - \s* = whitespace between dashes and heading + // - ## = heading marker + // - \s* = whitespace before "Follow-up" + // - Follow-up Session = literal heading text + // - \s* = trailing whitespace/newlines after heading + const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/; + return content.replace(scaffoldPattern, ''); + } + + /** + * Extract summary ONLY from the new content generated in this session + * and save it via the provided callback. + */ + private async extractAndSaveSessionSummary( + projectPath: string, + featureId: string, + responseText: string, + previousContent: string | undefined, + callbacks: AgentExecutorCallbacks, + status?: string + ): Promise { + const sessionContent = responseText.substring(previousContent ? previousContent.length : 0); + const summary = extractSummary(sessionContent); + if (summary) { + await callbacks.saveFeatureSummary(projectPath, featureId, summary); + return; + } + + // If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails. + if (isPipelineStatus(status)) { + // Strip any follow-up session scaffold before using as fallback + const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent); + const fallback = cleanSessionContent.trim(); + if (fallback) { + await callbacks.saveFeatureSummary(projectPath, featureId, fallback); + } + logger.warn( + `[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")` + ); + } + } + + private async executeTasksLoop( + options: AgentExecutionOptions, + tasks: ParsedTask[], + planContent: string, + initialResponseText: string, + scheduleWrite: () => void, + callbacks: AgentExecutorCallbacks, + userFeedback?: string + ): Promise<{ responseText: string; tasksCompleted: number; aborted: boolean }> { + const { + featureId, + projectPath, + abortController, + branchName = null, + provider, + sdkOptions, + } = options; + logger.info(`Starting task execution for feature ${featureId} with ${tasks.length} tasks`); + const taskPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + let responseText = initialResponseText, + tasksCompleted = 0; + + for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) { + const task = tasks[taskIndex]; + if (task.status === 'completed') { + tasksCompleted++; + continue; + } + if (abortController.signal.aborted) return { responseText, tasksCompleted, aborted: true }; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + task.id, + 'in_progress' + ); + this.eventBus.emitAutoModeEvent('auto_mode_task_started', { + featureId, + projectPath, + branchName, + taskId: task.id, + taskDescription: task.description, + taskIndex, + tasksTotal: tasks.length, + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + currentTaskId: task.id, + }); + const taskPrompt = callbacks.buildTaskPrompt( + task, + tasks, + taskIndex, + planContent, + taskPrompts.taskExecution.taskPromptTemplate, + userFeedback + ); + const taskMaxTurns = sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS; + logger.info( + `[executeTasksLoop] Feature ${featureId}, task ${task.id} (${taskIndex + 1}/${tasks.length}): ` + + `maxTurns=${taskMaxTurns} (sdkOptions.maxTurns=${sdkOptions?.maxTurns ?? 'undefined'})` + ); + const taskStream = provider.executeQuery( + this.buildExecOpts(options, taskPrompt, taskMaxTurns) + ); + let taskOutput = '', + taskStartDetected = false, + taskCompleteDetected = false; + + for await (const msg of taskStream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) { + for (const b of msg.message.content) { + if (b.type === 'text') { + const text = b.text || ''; + taskOutput += text; + responseText += text; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: text, + }); + scheduleWrite(); + if (!taskStartDetected) { + const sid = detectTaskStartMarker(taskOutput); + if (sid) { + taskStartDetected = true; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + sid, + 'in_progress' + ); + } + } + if (!taskCompleteDetected) { + const completeMarker = detectTaskCompleteMarker(taskOutput); + if (completeMarker) { + taskCompleteDetected = true; + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + completeMarker.id, + 'completed', + completeMarker.summary + ); + } + } + const pn = detectPhaseCompleteMarker(text); + if (pn !== null) + this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { + featureId, + projectPath, + branchName, + phaseNumber: pn, + }); + } else if (b.type === 'tool_use') + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: b.name, + input: b.input, + }); + } + } else if (msg.type === 'error') { + const fallback = `Error during task ${task.id}`; + const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback); + logger.error( + `[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` + + `raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(sanitized); + } else if (msg.type === 'result') { + if (msg.subtype === 'success') { + taskOutput += msg.result || ''; + responseText += msg.result || ''; + } else if (msg.subtype?.startsWith('error')) { + logger.error( + `[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` + + `session_id=${msg.session_id ?? 'none'}` + ); + throw new Error(`Agent execution ended with: ${msg.subtype}`); + } else { + logger.warn( + `[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}` + ); + } + } + } + if (!taskCompleteDetected) + await this.featureStateManager.updateTaskStatus( + projectPath, + featureId, + task.id, + 'completed' + ); + tasksCompleted = taskIndex + 1; + this.eventBus.emitAutoModeEvent('auto_mode_task_complete', { + featureId, + projectPath, + branchName, + taskId: task.id, + tasksCompleted, + tasksTotal: tasks.length, + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + tasksCompleted, + }); + if (task.phase) { + const next = tasks[taskIndex + 1]; + if (!next || next.phase !== task.phase) { + const m = task.phase.match(/Phase\s*(\d+)/i); + if (m) + this.eventBus.emitAutoModeEvent('auto_mode_phase_complete', { + featureId, + projectPath, + branchName, + phaseNumber: parseInt(m[1], 10), + }); + } + } + } + return { responseText, tasksCompleted, aborted: false }; + } + + private async handleSpecGenerated( + options: AgentExecutionOptions, + planContent: string, + initialResponseText: string, + requiresApproval: boolean, + scheduleWrite: () => void, + callbacks: AgentExecutorCallbacks + ): Promise<{ responseText: string; tasksCompleted: number }> { + const { + featureId, + projectPath, + branchName = null, + planningMode = 'skip', + provider, + sdkOptions, + } = options; + let responseText = initialResponseText, + parsedTasks = parseTasksFromSpec(planContent); + logger.info(`Parsed ${parsedTasks.length} tasks from spec for feature ${featureId}`); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: planContent, + version: 1, + generatedAt: new Date().toISOString(), + reviewedByUser: false, + tasks: parsedTasks, + tasksTotal: parsedTasks.length, + tasksCompleted: 0, + }); + const planSummary = extractSummary(planContent); + if (planSummary) await callbacks.updateFeatureSummary(projectPath, featureId, planSummary); + let approvedPlanContent = planContent, + userFeedback: string | undefined, + currentPlanContent = planContent, + planVersion = 1; + + if (requiresApproval) { + let planApproved = false; + while (!planApproved) { + logger.info( + `Spec v${planVersion} generated for feature ${featureId}, waiting for approval` + ); + this.eventBus.emitAutoModeEvent('plan_approval_required', { + featureId, + projectPath, + branchName, + planContent: currentPlanContent, + planningMode, + planVersion, + }); + const approvalResult = await callbacks.waitForApproval(featureId, projectPath); + if (approvalResult.approved) { + planApproved = true; + userFeedback = approvalResult.feedback; + approvedPlanContent = approvalResult.editedPlan || currentPlanContent; + if (approvalResult.editedPlan) { + // Re-parse tasks from edited plan to ensure we execute the updated tasks + const editedTasks = parseTasksFromSpec(approvalResult.editedPlan); + parsedTasks = editedTasks; + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + content: approvalResult.editedPlan, + tasks: editedTasks, + tasksTotal: editedTasks.length, + tasksCompleted: 0, + }); + } + this.eventBus.emitAutoModeEvent('plan_approved', { + featureId, + projectPath, + branchName, + hasEdits: !!approvalResult.editedPlan, + planVersion, + }); + } else { + const hasFeedback = approvalResult.feedback?.trim().length, + hasEdits = approvalResult.editedPlan?.trim().length; + if (!hasFeedback && !hasEdits) throw new Error('Plan cancelled by user'); + planVersion++; + this.eventBus.emitAutoModeEvent('plan_revision_requested', { + featureId, + projectPath, + branchName, + feedback: approvalResult.feedback, + hasEdits: !!hasEdits, + planVersion, + }); + const revPrompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const taskEx = + planningMode === 'full' + ? '```tasks\n## Phase 1: Foundation\n- [ ] T001: [Description] | File: [path/to/file]\n```' + : '```tasks\n- [ ] T001: [Description] | File: [path/to/file]\n```'; + let revPrompt = revPrompts.taskExecution.planRevisionTemplate + .replace(/\{\{planVersion\}\}/g, String(planVersion - 1)) + .replace( + /\{\{previousPlan\}\}/g, + hasEdits ? approvalResult.editedPlan || currentPlanContent : currentPlanContent + ) + .replace( + /\{\{userFeedback\}\}/g, + approvalResult.feedback || 'Please revise the plan based on the edits above.' + ) + .replace(/\{\{planningMode\}\}/g, planningMode) + .replace(/\{\{taskFormatExample\}\}/g, taskEx); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generating', + version: planVersion, + }); + let revText = ''; + for await (const msg of provider.executeQuery( + this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) + )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) + for (const b of msg.message.content) + if (b.type === 'text') { + revText += b.text || ''; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: b.text, + }); + } + if (msg.type === 'error') { + const cleanedError = + (msg.error || 'Error during plan revision') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Error during plan revision'; + throw new Error(cleanedError); + } + if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || ''; + } + const mi = revText.indexOf('[SPEC_GENERATED]'); + currentPlanContent = mi > 0 ? revText.substring(0, mi).trim() : revText.trim(); + const revisedTasks = parseTasksFromSpec(currentPlanContent); + if (revisedTasks.length === 0 && (planningMode === 'spec' || planningMode === 'full')) + this.eventBus.emitAutoModeEvent('plan_revision_warning', { + featureId, + projectPath, + branchName, + planningMode, + warning: 'Revised plan missing tasks block', + }); + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'generated', + content: currentPlanContent, + version: planVersion, + tasks: revisedTasks, + tasksTotal: revisedTasks.length, + tasksCompleted: 0, + }); + parsedTasks = revisedTasks; + responseText += revText; + } + } + } else { + this.eventBus.emitAutoModeEvent('plan_auto_approved', { + featureId, + projectPath, + branchName, + planContent, + planningMode, + }); + } + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: requiresApproval, + }); + let tasksCompleted = 0; + if (parsedTasks.length > 0) { + const r = await this.executeTasksLoop( + options, + parsedTasks, + approvedPlanContent, + responseText, + scheduleWrite, + callbacks, + userFeedback + ); + responseText = r.responseText; + tasksCompleted = r.tasksCompleted; + } else { + const r = await this.executeSingleAgentContinuation( + options, + approvedPlanContent, + userFeedback, + responseText + ); + responseText = r.responseText; + } + return { responseText, tasksCompleted }; + } + + private buildExecOpts(o: AgentExecutionOptions, prompt: string, maxTurns: number) { + return { + prompt, + model: o.effectiveBareModel, + maxTurns, + cwd: o.workDir, + allowedTools: o.sdkOptions?.allowedTools as string[] | undefined, + abortController: o.abortController, + thinkingLevel: o.thinkingLevel, + reasoningEffort: o.reasoningEffort, + mcpServers: + o.mcpServers && Object.keys(o.mcpServers).length > 0 + ? (o.mcpServers as Record) + : undefined, + credentials: o.credentials, + claudeCompatibleProvider: o.claudeCompatibleProvider, + sdkSessionId: o.sdkSessionId, + }; + } + + private async executeSingleAgentContinuation( + options: AgentExecutionOptions, + planContent: string, + userFeedback: string | undefined, + initialResponseText: string + ): Promise<{ responseText: string }> { + const { featureId, branchName = null, provider } = options; + logger.info(`No parsed tasks, using single-agent execution for feature ${featureId}`); + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const contPrompt = prompts.taskExecution.continuationAfterApprovalTemplate + .replace(/\{\{userFeedback\}\}/g, userFeedback || '') + .replace(/\{\{approvedPlan\}\}/g, planContent); + let responseText = initialResponseText; + for await (const msg of provider.executeQuery( + this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) + )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } + if (msg.type === 'assistant' && msg.message?.content) + for (const b of msg.message.content) { + if (b.type === 'text') { + responseText += b.text || ''; + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName, + content: b.text, + }); + } else if (b.type === 'tool_use') + this.eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId, + branchName, + tool: b.name, + input: b.input, + }); + } + else if (msg.type === 'error') { + const cleanedError = + (msg.error || 'Unknown error during implementation') + .replace(/\x1b\[[0-9;]*m/g, '') + .replace(/^Error:\s*/i, '') + .trim() || 'Unknown error during implementation'; + throw new Error(cleanedError); + } else if (msg.type === 'result' && msg.subtype === 'success') + responseText += msg.result || ''; + } + return { responseText }; + } +} diff --git a/jules_branch/apps/server/src/services/agent-service.ts b/jules_branch/apps/server/src/services/agent-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..443fff040b250c6f99db93dfbe994446c6255b72 --- /dev/null +++ b/jules_branch/apps/server/src/services/agent-service.ts @@ -0,0 +1,1249 @@ +/** + * Agent Service - Runs AI agents via provider architecture + * Manages conversation sessions and streams responses via WebSocket + */ + +import path from 'path'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import { stripProviderPrefix } from '@automaker/types'; +import { + readImageAsBase64, + buildPromptWithImages, + isAbortError, + loadContextFiles, + createLogger, + classifyError, +} from '@automaker/utils'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import type { SettingsService } from './settings-service.js'; +import { + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, + getMCPServersFromSettings, + getPromptCustomization, + getSkillsConfiguration, + getSubagentsConfiguration, + getCustomSubagents, + getProviderByModelId, + getDefaultMaxTurnsSetting, +} from '../lib/settings-helpers.js'; + +interface Message { + id: string; + role: 'user' | 'assistant'; + content: string; + images?: Array<{ + data: string; + mimeType: string; + filename: string; + }>; + timestamp: string; + isError?: boolean; +} + +interface QueuedPrompt { + id: string; + message: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + addedAt: string; +} + +interface Session { + messages: Message[]; + isRunning: boolean; + abortController: AbortController | null; + workingDirectory: string; + model?: string; + thinkingLevel?: ThinkingLevel; // Thinking level for Claude models + reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models + sdkSessionId?: string; // Claude SDK session ID for conversation continuity + promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task +} + +interface SessionMetadata { + id: string; + name: string; + projectPath?: string; + workingDirectory: string; + createdAt: string; + updatedAt: string; + archived?: boolean; + tags?: string[]; + model?: string; + sdkSessionId?: string; // Claude SDK session ID for conversation continuity +} + +export class AgentService { + private sessions = new Map(); + private stateDir: string; + private metadataFile: string; + private events: EventEmitter; + private settingsService: SettingsService | null = null; + private logger = createLogger('AgentService'); + + constructor(dataDir: string, events: EventEmitter, settingsService?: SettingsService) { + this.stateDir = path.join(dataDir, 'agent-sessions'); + this.metadataFile = path.join(dataDir, 'sessions-metadata.json'); + this.events = events; + this.settingsService = settingsService ?? null; + } + + async initialize(): Promise { + await secureFs.mkdir(this.stateDir, { recursive: true }); + } + + /** + * Detect provider-side session errors (session not found, expired, etc.). + * Used to decide whether to clear a stale sdkSessionId. + */ + private isStaleSessionError(rawErrorText: string): boolean { + const errorLower = rawErrorText.toLowerCase(); + return ( + errorLower.includes('session not found') || + errorLower.includes('session expired') || + errorLower.includes('invalid session') || + errorLower.includes('no such session') + ); + } + + /** + * Start or resume a conversation + */ + async startConversation({ + sessionId, + workingDirectory, + }: { + sessionId: string; + workingDirectory?: string; + }) { + // ensureSession handles loading from disk if not in memory. + // For startConversation, we always want to create a session even if + // metadata doesn't exist yet (new session), so we fall back to creating one. + let session = await this.ensureSession(sessionId, workingDirectory); + if (!session) { + // Session doesn't exist on disk either — create a fresh in-memory session. + const effectiveWorkingDirectory = workingDirectory || process.cwd(); + const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); + validateWorkingDirectory(resolvedWorkingDirectory); + + session = { + messages: [], + isRunning: false, + abortController: null, + workingDirectory: resolvedWorkingDirectory, + promptQueue: [], + }; + this.sessions.set(sessionId, session); + } + + return { + success: true, + messages: session.messages, + sessionId, + }; + } + + /** + * Ensure a session is loaded into memory. + * + * Sessions may exist on disk (in metadata and session files) but not be + * present in the in-memory Map — for example after a server restart, or + * when a client calls sendMessage before explicitly calling startConversation. + * + * This helper transparently loads the session from disk when it is missing + * from memory, eliminating "session not found" errors for sessions that + * were previously created but not yet initialized in memory. + * + * If both metadata and session files are missing, the session truly doesn't + * exist. A detailed diagnostic log is emitted so developers can track down + * how the invalid session ID was generated. + * + * @returns The in-memory Session object, or null if the session doesn't exist at all + */ + private async ensureSession( + sessionId: string, + workingDirectory?: string + ): Promise { + const existing = this.sessions.get(sessionId); + if (existing) { + return existing; + } + + // Try to load from disk — the session may have been created earlier + // (e.g. via createSession) but never initialized in memory. + let metadata: Record; + let messages: Message[]; + try { + [metadata, messages] = await Promise.all([this.loadMetadata(), this.loadSession(sessionId)]); + } catch (error) { + // Disk read failure should not be treated as "session not found" — + // it's a transient I/O problem. Log and return null so callers can + // surface an appropriate error message. + this.logger.error( + `Failed to load session ${sessionId} from disk (I/O error — NOT a missing session):`, + error + ); + return null; + } + + const sessionMetadata = metadata[sessionId]; + + // If there's no metadata AND no persisted messages, the session truly doesn't exist. + // Log diagnostic info to help track down how we ended up with an invalid session ID. + if (!sessionMetadata && messages.length === 0) { + this.logger.warn( + `Session "${sessionId}" not found: no metadata and no persisted messages. ` + + `This can happen when a session ID references a deleted/expired session, ` + + `or when the server restarted and the session was never persisted to disk. ` + + `Available session IDs in metadata: [${Object.keys(metadata).slice(0, 10).join(', ')}${Object.keys(metadata).length > 10 ? '...' : ''}]` + ); + return null; + } + + const effectiveWorkingDirectory = + workingDirectory || sessionMetadata?.workingDirectory || process.cwd(); + const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); + + // Validate that the working directory is allowed using centralized validation + try { + validateWorkingDirectory(resolvedWorkingDirectory); + } catch (validationError) { + this.logger.warn( + `Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` + + `returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}` + ); + return null; + } + + // Load persisted queue + const promptQueue = await this.loadQueueState(sessionId); + + const session: Session = { + messages, + isRunning: false, + abortController: null, + workingDirectory: resolvedWorkingDirectory, + sdkSessionId: sessionMetadata?.sdkSessionId, + promptQueue, + }; + + this.sessions.set(sessionId, session); + this.logger.info( + `Auto-initialized session ${sessionId} from disk ` + + `(${messages.length} messages, sdkSessionId: ${sessionMetadata?.sdkSessionId ? 'present' : 'none'})` + ); + return session; + } + + /** + * Send a message to the agent and stream responses + */ + async sendMessage({ + sessionId, + message, + workingDirectory, + imagePaths, + model, + thinkingLevel, + reasoningEffort, + }: { + sessionId: string; + message: string; + workingDirectory?: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + }) { + const session = await this.ensureSession(sessionId, workingDirectory); + if (!session) { + this.logger.error( + `Session not found: ${sessionId}. ` + + `The session may have been deleted, never created, or lost after a server restart. ` + + `In-memory sessions: ${this.sessions.size}, requested ID: ${sessionId}` + ); + throw new Error( + `Session ${sessionId} not found. ` + + `The session may have been deleted or expired. ` + + `Please create a new session and try again.` + ); + } + + if (session.isRunning) { + this.logger.error('ERROR: Agent already running for session:', sessionId); + throw new Error('Agent is already processing a message'); + } + + // Update session model, thinking level, and reasoning effort if provided + if (model) { + session.model = model; + await this.updateSession(sessionId, { model }); + } + if (thinkingLevel !== undefined) { + session.thinkingLevel = thinkingLevel; + } + if (reasoningEffort !== undefined) { + session.reasoningEffort = reasoningEffort; + } + + // Validate vision support before processing images + const effectiveModel = model || session.model; + if (imagePaths && imagePaths.length > 0 && effectiveModel) { + const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel); + if (!supportsVision) { + throw new Error( + `This model (${effectiveModel}) does not support image input. ` + + `Please switch to a model that supports vision, or remove the images and try again.` + ); + } + } + + // Read images and convert to base64 + const images: Message['images'] = []; + if (imagePaths && imagePaths.length > 0) { + for (const imagePath of imagePaths) { + try { + const imageData = await readImageAsBase64(imagePath); + images.push({ + data: imageData.base64, + mimeType: imageData.mimeType, + filename: imageData.filename, + }); + } catch (error) { + this.logger.error(`Failed to load image ${imagePath}:`, error); + } + } + } + + // Add user message + const userMessage: Message = { + id: this.generateId(), + role: 'user', + content: message, + images: images.length > 0 ? images : undefined, + timestamp: new Date().toISOString(), + }; + + session.messages.push(userMessage); + session.isRunning = true; + session.abortController = new AbortController(); + + // Emit started event so UI can show thinking indicator + this.emitAgentEvent(sessionId, { + type: 'started', + }); + + // Emit user message event + this.emitAgentEvent(sessionId, { + type: 'message', + message: userMessage, + }); + + await this.saveSession(sessionId, session.messages); + + try { + // Determine the effective working directory for context loading + const effectiveWorkDir = workingDirectory || session.workingDirectory; + + // Load autoLoadClaudeMd setting (project setting takes precedence over global) + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + effectiveWorkDir, + this.settingsService, + '[AgentService]' + ); + + // Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global) + // Wrap in try/catch so transient settingsService errors don't abort message processing + let useClaudeCodeSystemPrompt = true; + try { + useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + effectiveWorkDir, + this.settingsService, + '[AgentService]' + ); + } catch (err) { + this.logger.error( + '[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true', + err + ); + } + + // Load MCP servers from settings (global setting only) + const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); + + // Get Skills configuration from settings + const skillsConfig = this.settingsService + ? await getSkillsConfiguration(this.settingsService) + : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; + + // Get Subagents configuration from settings + const subagentsConfig = this.settingsService + ? await getSubagentsConfiguration(this.settingsService) + : { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false }; + + // Get custom subagents from settings (merge global + project-level) only if enabled + const customSubagents = + this.settingsService && subagentsConfig.enabled + ? await getCustomSubagents(this.settingsService, effectiveWorkDir) + : undefined; + + // Get credentials for API calls + const credentials = await this.settingsService?.getCredentials(); + + // Try to find a provider for the model (if it's a provider model like "GLM-4.7") + // This allows users to select provider models in the Agent Runner UI + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let providerResolvedModel: string | undefined; + const requestedModel = model || session.model; + if (requestedModel && this.settingsService) { + const providerResult = await getProviderByModelId( + requestedModel, + this.settingsService, + '[AgentService]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + providerResolvedModel = providerResult.resolvedModel; + this.logger.info( + `[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` + + (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') + ); + } + } + + let combinedSystemPrompt: string | undefined; + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files + // Use the user's message as task context for smart memory selection + const contextResult = await loadContextFiles({ + projectPath: effectiveWorkDir, + fsModule: secureFs as Parameters[0]['fsModule'], + taskContext: { + title: message.substring(0, 200), // Use first 200 chars as title + description: message, + }, + }); + + // When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication + // (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md + const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + + // Build combined system prompt with base prompt and context files + const baseSystemPrompt = await this.getSystemPrompt(); + combinedSystemPrompt = contextFilesPrompt + ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` + : baseSystemPrompt; + + // Build SDK options using centralized configuration + // Use thinking level and reasoning effort from request, or fall back to session's stored values + const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel; + const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort; + + // When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config + // (thinking level budgets, allowedTools) but we MUST pass the provider's model ID + // (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-6" which causes "model not found" + const modelForSdk = providerResolvedModel || model; + const sessionModelForSdk = providerResolvedModel ? undefined : session.model; + + // Read user-configured max turns from settings + const userMaxTurns = await getDefaultMaxTurnsSetting(this.settingsService, '[AgentService]'); + + const sdkOptions = createChatOptions({ + cwd: effectiveWorkDir, + model: modelForSdk, + sessionModel: sessionModelForSdk, + systemPrompt: combinedSystemPrompt, + abortController: session.abortController!, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models + maxTurns: userMaxTurns, // User-configured max turns from settings + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + }); + + // Extract model, maxTurns, and allowedTools from SDK options + const effectiveModel = sdkOptions.model!; + const maxTurns = sdkOptions.maxTurns; + let allowedTools = sdkOptions.allowedTools as string[] | undefined; + + // Build merged settingSources array using Set for automatic deduplication + const sdkSettingSources = (sdkOptions.settingSources ?? []).filter( + (source): source is 'user' | 'project' => source === 'user' || source === 'project' + ); + const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : []; + const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])]; + + // Enhance allowedTools with Skills and Subagents tools + // These tools are not in the provider's default set - they're added dynamically based on settings + const needsSkillTool = skillsConfig.shouldIncludeInTools; + const needsTaskTool = + subagentsConfig.shouldIncludeInTools && + customSubagents && + Object.keys(customSubagents).length > 0; + + // Base tools that match the provider's default set + const baseTools = [ + 'Read', + 'Write', + 'Edit', + 'MultiEdit', + 'Glob', + 'Grep', + 'LS', + 'Bash', + 'WebSearch', + 'WebFetch', + 'TodoWrite', + ]; + + if (allowedTools) { + allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options + // Add Skill tool if skills are enabled + if (needsSkillTool && !allowedTools.includes('Skill')) { + allowedTools.push('Skill'); + } + // Add Task tool if custom subagents are configured + if (needsTaskTool && !allowedTools.includes('Task')) { + allowedTools.push('Task'); + } + } else if (needsSkillTool || needsTaskTool) { + // If no allowedTools specified but we need to add Skill/Task tools, + // build the full list including base tools + allowedTools = [...baseTools]; + if (needsSkillTool) { + allowedTools.push('Skill'); + } + if (needsTaskTool) { + allowedTools.push('Task'); + } + } + + // Get provider for this model (with prefix) + // When using custom provider (GLM, MiniMax), requestedModel routes to Claude provider + const modelForProvider = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) + : effectiveModel; + const provider = ProviderFactory.getProviderForModel(modelForProvider); + + // Strip provider prefix - providers should receive bare model IDs + // CRITICAL: For custom providers (GLM, MiniMax), pass the provider's model ID (e.g. "GLM-4.7") + // to the API, NOT the resolved Claude model - otherwise we get "model not found" + const bareModel: string = claudeCompatibleProvider + ? (requestedModel ?? effectiveModel) + : stripProviderPrefix(effectiveModel); + + // Build options for provider + const conversationHistory = session.messages + .slice(0, -1) + .map((msg) => ({ + role: msg.role, + content: msg.content, + })) + .filter((msg) => msg.content.trim().length > 0); + + const options: ExecuteOptions = { + prompt: '', // Will be set below based on images + model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") + originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max") + cwd: effectiveWorkDir, + systemPrompt: sdkOptions.systemPrompt, + maxTurns: maxTurns, + allowedTools: allowedTools, + abortController: session.abortController!, + conversationHistory: + conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined, + settingSources: settingSources.length > 0 ? settingSources : undefined, + sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration + agents: customSubagents, // Pass custom subagents for task delegation + thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models + reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models + credentials, // Pass credentials for resolving 'credentials' apiKeySource + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.) + }; + + // Build prompt content with images + const { content: promptContent } = await buildPromptWithImages( + message, + imagePaths, + undefined, // no workDir for agent service + true // include image paths in text + ); + + // Set the prompt in options + options.prompt = promptContent; + + // Execute via provider + const stream = provider.executeQuery(options); + + let currentAssistantMessage: Message | null = null; + let responseText = ''; + const toolUses: Array<{ name: string; input: unknown }> = []; + const toolNamesById = new Map(); + + for await (const msg of stream) { + // Capture SDK session ID from any message and persist it. + // Update when: + // - No session ID set yet (first message in a new session) + // - The provider returned a *different* session ID (e.g., after a + // "Session not found" recovery where the provider started a fresh + // session — the stale ID must be replaced with the new one) + if (msg.session_id && msg.session_id !== session.sdkSessionId) { + session.sdkSessionId = msg.session_id; + // Persist the SDK session ID to ensure conversation continuity across server restarts + await this.updateSession(sessionId, { sdkSessionId: msg.session_id }); + } + + if (msg.type === 'assistant') { + if (msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + + if (!currentAssistantMessage) { + currentAssistantMessage = { + id: this.generateId(), + role: 'assistant', + content: responseText, + timestamp: new Date().toISOString(), + }; + session.messages.push(currentAssistantMessage); + } else { + currentAssistantMessage.content = responseText; + } + + this.emitAgentEvent(sessionId, { + type: 'stream', + messageId: currentAssistantMessage.id, + content: responseText, + isComplete: false, + }); + } else if (block.type === 'tool_use') { + const toolUse = { + name: block.name || 'unknown', + input: block.input, + }; + toolUses.push(toolUse); + if (block.tool_use_id) { + toolNamesById.set(block.tool_use_id, toolUse.name); + } + + this.emitAgentEvent(sessionId, { + type: 'tool_use', + tool: toolUse, + }); + } else if (block.type === 'tool_result') { + const toolUseId = block.tool_use_id; + const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined; + + // Normalize block.content to a string for the emitted event + const rawContent: unknown = block.content; + let contentString: string; + if (typeof rawContent === 'string') { + contentString = rawContent; + } else if (Array.isArray(rawContent)) { + // Extract text from content blocks (TextBlock, ImageBlock, etc.) + contentString = rawContent + .map((part: { text?: string; type?: string }) => { + if (typeof part === 'string') return part; + if (part.text) return part.text; + // For non-text blocks (e.g., images), represent as type indicator + if (part.type) return `[${part.type}]`; + return JSON.stringify(part); + }) + .join('\n'); + } else if (rawContent !== undefined && rawContent !== null) { + contentString = JSON.stringify(rawContent); + } else { + contentString = ''; + } + + this.emitAgentEvent(sessionId, { + type: 'tool_result', + tool: { + name: toolName || 'unknown', + input: { + toolUseId, + content: contentString, + }, + }, + }); + } + } + } + } else if (msg.type === 'result') { + if (msg.subtype === 'success' && msg.result) { + if (currentAssistantMessage) { + currentAssistantMessage.content = msg.result; + responseText = msg.result; + } + } + + this.emitAgentEvent(sessionId, { + type: 'complete', + messageId: currentAssistantMessage?.id, + content: responseText, + toolUses, + }); + } else if (msg.type === 'error') { + // Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as + // streamed error messages instead of throwing. Handle these here so the + // Agent Runner UX matches the Claude/Cursor behavior without changing + // their provider implementations. + + // Clean error text: strip ANSI escape codes and the redundant "Error: " + // prefix that CLI providers (especially OpenCode) add to stderr output. + // The OpenCode provider strips these in normalizeEvent/executeQuery, but + // we also strip here as a defense-in-depth measure. + // + // Without stripping the "Error: " prefix, the wrapping at line ~647 + // (`content: \`Error: ${enhancedText}\``) produces double-prefixed text: + // "Error: Error: Session not found" — confusing for the user. + const rawMsgError = + (typeof msg.error === 'string' && msg.error.trim()) || + 'Unexpected error from provider during agent execution.'; + let rawErrorText = rawMsgError.replace(/\x1b\[[0-9;]*m/g, '').trim() || rawMsgError; + // Remove the CLI's "Error: " prefix to prevent double-wrapping + rawErrorText = rawErrorText.replace(/^Error:\s*/i, '').trim() || rawErrorText; + + const errorInfo = classifyError(new Error(rawErrorText)); + + // Detect provider-side session errors and proactively clear the stale + // sdkSessionId so the next attempt starts a fresh provider session. + // This handles providers that don't have built-in session recovery + // (unlike OpenCode which auto-retries without the session flag). + if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) { + this.logger.info( + `Clearing stale sdkSessionId for session ${sessionId} after provider session error` + ); + session.sdkSessionId = undefined; + await this.clearSdkSessionId(sessionId); + } + + // Keep the provider-supplied text intact (Codex already includes helpful tips), + // only add a small rate-limit hint when we can detect it. + const enhancedText = errorInfo.isRateLimit + ? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.` + : rawErrorText; + + this.logger.error('Provider error during agent execution:', { + type: errorInfo.type, + message: errorInfo.message, + }); + + // Mark session as no longer running so the UI and queue stay in sync + session.isRunning = false; + session.abortController = null; + + const errorMessage: Message = { + id: this.generateId(), + role: 'assistant', + content: `Error: ${enhancedText}`, + timestamp: new Date().toISOString(), + isError: true, + }; + + session.messages.push(errorMessage); + await this.saveSession(sessionId, session.messages); + + this.emitAgentEvent(sessionId, { + type: 'error', + error: enhancedText, + message: errorMessage, + }); + + // Don't continue streaming after an error message + return { + success: false, + }; + } + } + + await this.saveSession(sessionId, session.messages); + + session.isRunning = false; + session.abortController = null; + + // Process next item in queue after completion + setImmediate(() => this.processNextInQueue(sessionId)); + + return { + success: true, + message: currentAssistantMessage, + }; + } catch (error) { + if (isAbortError(error)) { + session.isRunning = false; + session.abortController = null; + return { success: false, aborted: true }; + } + + this.logger.error('Error:', error); + + // Strip ANSI escape codes and the "Error: " prefix from thrown error + // messages so the UI receives clean text without double-prefixing. + let rawThrownMsg = ((error as Error).message || '').replace(/\x1b\[[0-9;]*m/g, '').trim(); + rawThrownMsg = rawThrownMsg.replace(/^Error:\s*/i, '').trim() || rawThrownMsg; + const thrownErrorMsg = rawThrownMsg.toLowerCase(); + + // Check if the thrown error is a provider-side session error. + // Clear the stale sdkSessionId so the next retry starts fresh. + if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) { + this.logger.info( + `Clearing stale sdkSessionId for session ${sessionId} after thrown session error` + ); + session.sdkSessionId = undefined; + await this.clearSdkSessionId(sessionId); + } + + session.isRunning = false; + session.abortController = null; + + const cleanErrorMsg = rawThrownMsg || (error as Error).message; + const errorMessage: Message = { + id: this.generateId(), + role: 'assistant', + content: `Error: ${cleanErrorMsg}`, + timestamp: new Date().toISOString(), + isError: true, + }; + + session.messages.push(errorMessage); + await this.saveSession(sessionId, session.messages); + + this.emitAgentEvent(sessionId, { + type: 'error', + error: cleanErrorMsg, + message: errorMessage, + }); + + throw error; + } + } + + /** + * Get conversation history + */ + async getHistory(sessionId: string) { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + return { + success: true, + messages: session.messages, + isRunning: session.isRunning, + }; + } + + /** + * Stop current agent execution + */ + async stopExecution(sessionId: string) { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + if (session.abortController) { + session.abortController.abort(); + session.isRunning = false; + session.abortController = null; + } + + return { success: true }; + } + + /** + * Clear conversation history + */ + async clearSession(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + session.messages = []; + session.isRunning = false; + session.sdkSessionId = undefined; // Clear stale provider session ID to prevent "Session not found" errors + await this.saveSession(sessionId, []); + } + + // Clear the sdkSessionId from persisted metadata so it doesn't get + // reloaded by ensureSession() after a server restart. + // This prevents "Session not found" errors when the provider-side session + // no longer exists (e.g., OpenCode CLI sessions expire on disk). + await this.clearSdkSessionId(sessionId); + + return { success: true }; + } + + // Session management + + async loadSession(sessionId: string): Promise { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + + try { + const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string; + return JSON.parse(data); + } catch { + return []; + } + } + + async saveSession(sessionId: string, messages: Message[]): Promise { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + + try { + await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8'); + await this.updateSessionTimestamp(sessionId); + } catch (error) { + this.logger.error('Failed to save session:', error); + } + } + + async loadMetadata(): Promise> { + try { + const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string; + return JSON.parse(data); + } catch { + return {}; + } + } + + async saveMetadata(metadata: Record): Promise { + await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8'); + } + + async updateSessionTimestamp(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (metadata[sessionId]) { + metadata[sessionId].updatedAt = new Date().toISOString(); + await this.saveMetadata(metadata); + } + } + + async listSessions(includeArchived = false): Promise { + const metadata = await this.loadMetadata(); + let sessions = Object.values(metadata); + + if (!includeArchived) { + sessions = sessions.filter((s) => !s.archived); + } + + return sessions.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } + + async createSession( + name: string, + projectPath?: string, + workingDirectory?: string, + model?: string + ): Promise { + const sessionId = this.generateId(); + const metadata = await this.loadMetadata(); + + // Determine the effective working directory + const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd(); + const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory); + + // Validate that the working directory is allowed using centralized validation + validateWorkingDirectory(resolvedWorkingDirectory); + + // Validate that projectPath is allowed if provided + if (projectPath) { + validateWorkingDirectory(projectPath); + } + + const session: SessionMetadata = { + id: sessionId, + name, + projectPath, + workingDirectory: resolvedWorkingDirectory, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + model, + }; + + metadata[sessionId] = session; + await this.saveMetadata(metadata); + + return session; + } + + async setSessionModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.model = model; + await this.updateSession(sessionId, { model }); + return true; + } + return false; + } + + async updateSession( + sessionId: string, + updates: Partial + ): Promise { + const metadata = await this.loadMetadata(); + if (!metadata[sessionId]) return null; + + metadata[sessionId] = { + ...metadata[sessionId], + ...updates, + updatedAt: new Date().toISOString(), + }; + + await this.saveMetadata(metadata); + return metadata[sessionId]; + } + + async archiveSession(sessionId: string): Promise { + const result = await this.updateSession(sessionId, { archived: true }); + return result !== null; + } + + async unarchiveSession(sessionId: string): Promise { + const result = await this.updateSession(sessionId, { archived: false }); + return result !== null; + } + + async deleteSession(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (!metadata[sessionId]) return false; + + delete metadata[sessionId]; + await this.saveMetadata(metadata); + + // Delete session file + try { + const sessionFile = path.join(this.stateDir, `${sessionId}.json`); + await secureFs.unlink(sessionFile); + } catch { + // File may not exist + } + + // Clear from memory + this.sessions.delete(sessionId); + + return true; + } + + /** + * Clear the sdkSessionId from persisted metadata. + * + * This removes the provider-side session ID so that the next message + * starts a fresh provider session instead of trying to resume a stale one. + * Prevents "Session not found" errors from CLI providers like OpenCode + * when the provider-side session has been deleted or expired. + */ + async clearSdkSessionId(sessionId: string): Promise { + const metadata = await this.loadMetadata(); + if (metadata[sessionId] && metadata[sessionId].sdkSessionId) { + delete metadata[sessionId].sdkSessionId; + metadata[sessionId].updatedAt = new Date().toISOString(); + await this.saveMetadata(metadata); + } + } + + // Queue management methods + + /** + * Add a prompt to the queue for later execution + */ + async addToQueue( + sessionId: string, + prompt: { + message: string; + imagePaths?: string[]; + model?: string; + thinkingLevel?: ThinkingLevel; + } + ): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + const queuedPrompt: QueuedPrompt = { + id: this.generateId(), + message: prompt.message, + imagePaths: prompt.imagePaths, + model: prompt.model, + thinkingLevel: prompt.thinkingLevel, + addedAt: new Date().toISOString(), + }; + + session.promptQueue.push(queuedPrompt); + await this.saveQueueState(sessionId, session.promptQueue); + + // Emit queue update event + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + return { success: true, queuedPrompt }; + } + + /** + * Get the current queue for a session + */ + async getQueue( + sessionId: string + ): Promise<{ success: boolean; queue?: QueuedPrompt[]; error?: string }> { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + return { success: true, queue: session.promptQueue }; + } + + /** + * Remove a specific prompt from the queue + */ + async removeFromQueue( + sessionId: string, + promptId: string + ): Promise<{ success: boolean; error?: string }> { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + const index = session.promptQueue.findIndex((p) => p.id === promptId); + if (index === -1) { + return { success: false, error: 'Prompt not found in queue' }; + } + + session.promptQueue.splice(index, 1); + await this.saveQueueState(sessionId, session.promptQueue); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + return { success: true }; + } + + /** + * Clear all prompts from the queue + */ + async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> { + const session = await this.ensureSession(sessionId); + if (!session) { + return { success: false, error: 'Session not found' }; + } + + session.promptQueue = []; + await this.saveQueueState(sessionId, []); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: [], + }); + + return { success: true }; + } + + /** + * Save queue state to disk for persistence + */ + private async saveQueueState(sessionId: string, queue: QueuedPrompt[]): Promise { + const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); + try { + await secureFs.writeFile(queueFile, JSON.stringify(queue, null, 2), 'utf-8'); + } catch (error) { + this.logger.error('Failed to save queue state:', error); + } + } + + /** + * Load queue state from disk + */ + private async loadQueueState(sessionId: string): Promise { + const queueFile = path.join(this.stateDir, `${sessionId}-queue.json`); + try { + const data = (await secureFs.readFile(queueFile, 'utf-8')) as string; + return JSON.parse(data); + } catch { + return []; + } + } + + /** + * Process the next item in the queue (called after task completion) + */ + private async processNextInQueue(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || session.promptQueue.length === 0) { + return; + } + + // Don't process if already running + if (session.isRunning) { + return; + } + + const nextPrompt = session.promptQueue.shift(); + if (!nextPrompt) return; + + await this.saveQueueState(sessionId, session.promptQueue); + + this.emitAgentEvent(sessionId, { + type: 'queue_updated', + queue: session.promptQueue, + }); + + try { + await this.sendMessage({ + sessionId, + message: nextPrompt.message, + imagePaths: nextPrompt.imagePaths, + model: nextPrompt.model, + thinkingLevel: nextPrompt.thinkingLevel, + }); + } catch (error) { + this.logger.error('Failed to process queued prompt:', error); + this.emitAgentEvent(sessionId, { + type: 'queue_error', + error: (error as Error).message, + promptId: nextPrompt.id, + }); + } + } + + /** + * Emit an event to the agent stream (private, used internally). + */ + private emitAgentEvent(sessionId: string, data: Record): void { + this.events.emit('agent:stream', { sessionId, ...data }); + } + + /** + * Emit an error event for a session. + * + * Public method so that route handlers can surface errors to the UI + * even when sendMessage() throws before it can emit its own error event + * (e.g., when the session is not found and no in-memory session exists). + */ + emitSessionError(sessionId: string, error: string): void { + this.events.emit('agent:stream', { sessionId, type: 'error', error }); + } + + private async getSystemPrompt(): Promise { + // Load from settings (no caching - allows hot reload of custom prompts) + const prompts = await getPromptCustomization(this.settingsService, '[AgentService]'); + return prompts.agent.systemPrompt; + } + + private generateId(): string { + return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/jules_branch/apps/server/src/services/auto-loop-coordinator.ts b/jules_branch/apps/server/src/services/auto-loop-coordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef4a91557866a5bcbdeb3402bb5af00aadcf1b4b --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-loop-coordinator.ts @@ -0,0 +1,527 @@ +/** + * AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking + */ + +import type { Feature } from '@automaker/types'; +import { createLogger, classifyError } from '@automaker/utils'; +import { areDependenciesSatisfied } from '@automaker/dependency-resolver'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager } from './concurrency-manager.js'; +import type { SettingsService } from './settings-service.js'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; + +const logger = createLogger('AutoLoopCoordinator'); + +const CONSECUTIVE_FAILURE_THRESHOLD = 3; +const FAILURE_WINDOW_MS = 60000; + +// Sleep intervals for the auto-loop (in milliseconds) +const SLEEP_INTERVAL_CAPACITY_MS = 5000; +const SLEEP_INTERVAL_IDLE_MS = 10000; +const SLEEP_INTERVAL_NORMAL_MS = 2000; +const SLEEP_INTERVAL_ERROR_MS = 5000; + +export interface AutoModeConfig { + maxConcurrency: number; + useWorktrees: boolean; + projectPath: string; + branchName: string | null; +} + +export interface ProjectAutoLoopState { + abortController: AbortController; + config: AutoModeConfig; + isRunning: boolean; + consecutiveFailures: { timestamp: number; error: string }[]; + pausedDueToFailures: boolean; + hasEmittedIdleEvent: boolean; + branchName: string | null; +} + +/** + * Generate a unique key for a worktree auto-loop instance. + * + * When branchName is null, this represents the main worktree (uses '__main__' sentinel). + * The string 'main' is also normalized to '__main__' for consistency. + * Named branches always use their exact name. + */ +export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string { + const normalizedBranch = branchName === 'main' ? null : branchName; + return `${projectPath}::${normalizedBranch ?? '__main__'}`; +} + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean +) => Promise; +export type LoadPendingFeaturesFn = ( + projectPath: string, + branchName: string | null +) => Promise; +export type SaveExecutionStateFn = ( + projectPath: string, + branchName: string | null, + maxConcurrency: number +) => Promise; +export type ClearExecutionStateFn = ( + projectPath: string, + branchName: string | null +) => Promise; +export type ResetStuckFeaturesFn = (projectPath: string) => Promise; +export type IsFeatureFinishedFn = (feature: Feature) => boolean; +export type LoadAllFeaturesFn = (projectPath: string) => Promise; + +export class AutoLoopCoordinator { + private autoLoopsByProject = new Map(); + + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private executeFeatureFn: ExecuteFeatureFn, + private loadPendingFeaturesFn: LoadPendingFeaturesFn, + private saveExecutionStateFn: SaveExecutionStateFn, + private clearExecutionStateFn: ClearExecutionStateFn, + private resetStuckFeaturesFn: ResetStuckFeaturesFn, + private isFeatureFinishedFn: IsFeatureFinishedFn, + private isFeatureRunningFn: (featureId: string) => boolean, + private loadAllFeaturesFn?: LoadAllFeaturesFn + ) {} + + /** + * Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees) + * @param projectPath - The project to start auto mode for + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY) + */ + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const resolvedMaxConcurrency = await this.resolveMaxConcurrency( + projectPath, + branchName, + maxConcurrency + ); + + // Use worktree-scoped key + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + + // Check if this project/worktree already has an active autoloop + const existingState = this.autoLoopsByProject.get(worktreeKey); + if (existingState?.isRunning) { + const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree'; + throw new Error( + `Auto mode is already running for ${worktreeDesc} in project: ${projectPath}` + ); + } + + // Create new project/worktree autoloop state + const abortController = new AbortController(); + const config: AutoModeConfig = { + maxConcurrency: resolvedMaxConcurrency, + useWorktrees: true, + projectPath, + branchName, + }; + + const projectState: ProjectAutoLoopState = { + abortController, + config, + isRunning: true, + consecutiveFailures: [], + pausedDueToFailures: false, + hasEmittedIdleEvent: false, + branchName, + }; + + this.autoLoopsByProject.set(worktreeKey, projectState); + try { + await this.resetStuckFeaturesFn(projectPath); + } catch { + /* ignore */ + } + this.eventBus.emitAutoModeEvent('auto_mode_started', { + message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`, + projectPath, + branchName, + maxConcurrency: resolvedMaxConcurrency, + }); + await this.saveExecutionStateFn(projectPath, branchName, resolvedMaxConcurrency); + this.runAutoLoopForProject(worktreeKey).catch((error) => { + const errorInfo = classifyError(error); + this.eventBus.emitAutoModeEvent('auto_mode_error', { + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + branchName, + }); + }); + return resolvedMaxConcurrency; + } + + private async runAutoLoopForProject(worktreeKey: string): Promise { + const projectState = this.autoLoopsByProject.get(worktreeKey); + if (!projectState) return; + const { projectPath, branchName } = projectState.config; + while (projectState.isRunning && !projectState.abortController.signal.aborted) { + try { + // Count ALL running features (both auto and manual) against the concurrency limit. + // This ensures auto mode is aware of the total system load and does not over-subscribe + // resources. Manual tasks always bypass the limit and run immediately, but their + // presence is accounted for when deciding whether to dispatch new auto-mode tasks. + const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); + if (runningCount >= projectState.config.maxConcurrency) { + await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal); + continue; + } + const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName); + if (pendingFeatures.length === 0) { + if (runningCount === 0 && !projectState.hasEmittedIdleEvent) { + // Double-check that we have no features in 'in_progress' state that might + // have been released from the concurrency manager but not yet updated to + // their final status. This prevents auto_mode_idle from firing prematurely + // when features are transitioning states (e.g., during status update). + const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree( + projectPath, + branchName + ); + + // Only emit auto_mode_idle if we're truly done with all features + if (!hasInProgressFeatures) { + this.eventBus.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + } + } + await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal); + continue; + } + + // Load all features for dependency checking (if callback provided) + const allFeatures = this.loadAllFeaturesFn + ? await this.loadAllFeaturesFn(projectPath) + : undefined; + + // Filter to eligible features: not running, not finished, and dependencies satisfied. + // When loadAllFeaturesFn is not provided, allFeatures is undefined and we bypass + // dependency checks (returning true) to avoid false negatives caused by completed + // features being absent from pendingFeatures. + const eligibleFeatures = pendingFeatures.filter( + (f) => + !this.isFeatureRunningFn(f.id) && + !this.isFeatureFinishedFn(f) && + (this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true) + ); + + // Sort eligible features by priority (lower number = higher priority, default 2) + eligibleFeatures.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2)); + + const nextFeature = eligibleFeatures[0] ?? null; + + if (nextFeature) { + logger.info( + `Auto-loop selected feature "${nextFeature.title || nextFeature.id}" ` + + `(priority=${nextFeature.priority ?? 2}) from ${eligibleFeatures.length} eligible features` + ); + } + if (nextFeature) { + projectState.hasEmittedIdleEvent = false; + this.executeFeatureFn( + projectPath, + nextFeature.id, + projectState.config.useWorktrees, + true + ).catch((error) => { + const errorInfo = classifyError(error); + logger.error(`Auto-loop feature ${nextFeature.id} failed:`, errorInfo.message); + if (this.trackFailureAndCheckPauseForProject(projectPath, branchName, errorInfo)) { + this.signalShouldPauseForProject(projectPath, branchName, errorInfo); + } + }); + } + await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal); + } catch { + if (projectState.abortController.signal.aborted) break; + await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal); + } + } + projectState.isRunning = false; + } + + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + if (!projectState) return 0; + const wasRunning = projectState.isRunning; + projectState.isRunning = false; + projectState.abortController.abort(); + await this.clearExecutionStateFn(projectPath, branchName); + if (wasRunning) + this.eventBus.emitAutoModeEvent('auto_mode_stopped', { + message: 'Auto mode stopped', + projectPath, + branchName, + }); + this.autoLoopsByProject.delete(worktreeKey); + return await this.getRunningCountForWorktree(projectPath, branchName); + } + + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + return projectState?.isRunning ?? false; + } + + /** + * Get auto loop config for a specific project/worktree + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + */ + getAutoLoopConfigForProject( + projectPath: string, + branchName: string | null = null + ): AutoModeConfig | null { + const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); + const projectState = this.autoLoopsByProject.get(worktreeKey); + return projectState?.config ?? null; + } + + /** + * Get all active auto loop worktrees with their project paths and branch names + */ + getActiveWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + const activeWorktrees: Array<{ projectPath: string; branchName: string | null }> = []; + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) { + activeWorktrees.push({ + projectPath: state.config.projectPath, + branchName: state.branchName, + }); + } + } + return activeWorktrees; + } + + getActiveProjects(): string[] { + const activeProjects = new Set(); + for (const [, state] of this.autoLoopsByProject) { + if (state.isRunning) activeProjects.add(state.config.projectPath); + } + return Array.from(activeProjects); + } + + /** + * Get the number of running features for a worktree. + * By default counts ALL running features (both auto-mode and manual). + * Pass `autoModeOnly: true` to count only auto-mode features. + */ + async getRunningCountForWorktree( + projectPath: string, + branchName: string | null, + options?: { autoModeOnly?: boolean } + ): Promise { + return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options); + } + + trackFailureAndCheckPauseForProject( + projectPath: string, + branchNameOrError: string | null | { type: string; message: string }, + errorInfo?: { type: string; message: string } + ): boolean { + // Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures + let branchName: string | null; + let actualErrorInfo: { type: string; message: string }; + if ( + typeof branchNameOrError === 'object' && + branchNameOrError !== null && + 'type' in branchNameOrError + ) { + // Old signature: (projectPath, errorInfo) + branchName = null; + actualErrorInfo = branchNameOrError; + } else { + // New signature: (projectPath, branchName, errorInfo) + branchName = branchNameOrError; + actualErrorInfo = errorInfo!; + } + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (!projectState) return false; + const now = Date.now(); + projectState.consecutiveFailures.push({ timestamp: now, error: actualErrorInfo.message }); + projectState.consecutiveFailures = projectState.consecutiveFailures.filter( + (f) => now - f.timestamp < FAILURE_WINDOW_MS + ); + return ( + projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD || + actualErrorInfo.type === 'quota_exhausted' || + actualErrorInfo.type === 'rate_limit' + ); + } + + signalShouldPauseForProject( + projectPath: string, + branchNameOrError: string | null | { type: string; message: string }, + errorInfo?: { type: string; message: string } + ): void { + // Support both old (projectPath, errorInfo) and new (projectPath, branchName, errorInfo) signatures + let branchName: string | null; + let actualErrorInfo: { type: string; message: string }; + if ( + typeof branchNameOrError === 'object' && + branchNameOrError !== null && + 'type' in branchNameOrError + ) { + branchName = null; + actualErrorInfo = branchNameOrError; + } else { + branchName = branchNameOrError; + actualErrorInfo = errorInfo!; + } + + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (!projectState || projectState.pausedDueToFailures) return; + projectState.pausedDueToFailures = true; + const failureCount = projectState.consecutiveFailures.length; + this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', { + message: + failureCount >= CONSECUTIVE_FAILURE_THRESHOLD + ? `Auto Mode paused: ${failureCount} consecutive failures detected.` + : 'Auto Mode paused: Usage limit or API error detected.', + errorType: actualErrorInfo.type, + originalError: actualErrorInfo.message, + failureCount, + projectPath, + branchName, + }); + this.stopAutoLoopForProject(projectPath, branchName); + } + + resetFailureTrackingForProject(projectPath: string, branchName: string | null = null): void { + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (projectState) { + projectState.consecutiveFailures = []; + projectState.pausedDueToFailures = false; + } + } + + recordSuccessForProject(projectPath: string, branchName: string | null = null): void { + const projectState = this.autoLoopsByProject.get( + getWorktreeAutoLoopKey(projectPath, branchName) + ); + if (projectState) projectState.consecutiveFailures = []; + } + + async resolveMaxConcurrency( + projectPath: string, + branchName: string | null, + provided?: number + ): Promise { + if (typeof provided === 'number' && Number.isFinite(provided)) return provided; + if (!this.settingsService) return DEFAULT_MAX_CONCURRENCY; + try { + const settings = await this.settingsService.getGlobalSettings(); + const globalMax = + typeof settings.maxConcurrency === 'number' + ? settings.maxConcurrency + : DEFAULT_MAX_CONCURRENCY; + const projectId = settings.projects?.find((p) => p.path === projectPath)?.id; + const autoModeByWorktree = settings.autoModeByWorktree; + if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') { + // Normalize both null and 'main' to '__main__' to match the same + // canonicalization used by getWorktreeAutoLoopKey, ensuring that + // lookups for the primary branch always use the '__main__' sentinel + // regardless of whether the caller passed null or the string 'main'. + const normalizedBranch = + branchName === null || branchName === 'main' ? '__main__' : branchName; + const worktreeId = `${projectId}::${normalizedBranch}`; + if ( + worktreeId in autoModeByWorktree && + typeof autoModeByWorktree[worktreeId]?.maxConcurrency === 'number' + ) { + return autoModeByWorktree[worktreeId].maxConcurrency; + } + } + return globalMax; + } catch { + return DEFAULT_MAX_CONCURRENCY; + } + } + + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Aborted')); + return; + } + const onAbort = () => { + clearTimeout(timeout); + reject(new Error('Aborted')); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort); + }); + } + + /** + * Check if a feature belongs to the current worktree based on branch name. + * For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'. + * For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName. + */ + private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean { + const isMainWorktree = branchName === null || branchName === 'main'; + if (isMainWorktree) { + // Main worktree: include features with no branchName or branchName === 'main' + return !feature.branchName || feature.branchName === 'main'; + } else { + // Feature worktree: only include exact branch match + return feature.branchName === branchName; + } + } + + /** + * Check if there are features in 'in_progress' status for the current worktree. + * This prevents auto_mode_idle from firing prematurely when features are + * transitioning states (e.g., during status update from in_progress to completed). + */ + private async hasInProgressFeaturesForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + if (!this.loadAllFeaturesFn) { + return false; + } + + try { + const allFeatures = await this.loadAllFeaturesFn(projectPath); + return allFeatures.some( + (f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName) + ); + } catch (error) { + const errorInfo = classifyError(error); + logger.warn( + `Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`, + error + ); + return false; + } + } +} diff --git a/jules_branch/apps/server/src/services/auto-mode/compat.ts b/jules_branch/apps/server/src/services/auto-mode/compat.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea911d9b8c995f81769566c1dd033345f79ae0f0 --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-mode/compat.ts @@ -0,0 +1,241 @@ +/** + * Compatibility Shim - Provides AutoModeService-like interface using the new architecture + * + * This allows existing routes to work without major changes during the transition. + * Routes receive this shim which delegates to GlobalAutoModeService and facades. + * + * This is a TEMPORARY shim - routes should be updated to use the new interface directly. + */ + +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '../../lib/events.js'; +import { GlobalAutoModeService } from './global-service.js'; +import { AutoModeServiceFacade } from './facade.js'; +import type { SettingsService } from '../settings-service.js'; +import type { FeatureLoader } from '../feature-loader.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; +import type { FacadeOptions, AutoModeStatus, RunningAgentInfo } from './types.js'; + +/** + * AutoModeServiceCompat wraps GlobalAutoModeService and facades to provide + * the old AutoModeService interface that routes expect. + */ +export class AutoModeServiceCompat { + private readonly globalService: GlobalAutoModeService; + private readonly facadeOptions: FacadeOptions; + private readonly facadeCache = new Map(); + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader, + claudeUsageService?: ClaudeUsageService | null + ) { + this.globalService = new GlobalAutoModeService(events, settingsService, featureLoader); + const sharedServices = this.globalService.getSharedServices(); + + this.facadeOptions = { + events, + settingsService, + featureLoader, + sharedServices, + claudeUsageService: claudeUsageService ?? null, + }; + } + + /** + * Get the global service for direct access + */ + getGlobalService(): GlobalAutoModeService { + return this.globalService; + } + + /** + * Get or create a facade for a specific project. + * Facades are cached by project path so that auto loop state + * (stored in the facade's AutoLoopCoordinator) persists across API calls. + */ + createFacade(projectPath: string): AutoModeServiceFacade { + let facade = this.facadeCache.get(projectPath); + if (!facade) { + facade = AutoModeServiceFacade.create(projectPath, this.facadeOptions); + this.facadeCache.set(projectPath, facade); + } + return facade; + } + + // =========================================================================== + // GLOBAL OPERATIONS (delegated to GlobalAutoModeService) + // =========================================================================== + + getStatus(): AutoModeStatus { + return this.globalService.getStatus(); + } + + getActiveAutoLoopProjects(): string[] { + return this.globalService.getActiveAutoLoopProjects(); + } + + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.globalService.getActiveAutoLoopWorktrees(); + } + + async getRunningAgents(): Promise { + return this.globalService.getRunningAgents(); + } + + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + return this.globalService.markAllRunningFeaturesInterrupted(reason); + } + + async reconcileFeatureStates(projectPath: string): Promise { + return this.globalService.reconcileFeatureStates(projectPath); + } + + // =========================================================================== + // PER-PROJECT OPERATIONS (delegated to facades) + // =========================================================================== + + async getStatusForProject( + projectPath: string, + branchName: string | null = null + ): Promise<{ + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + branchName: string | null; + }> { + const facade = this.createFacade(projectPath); + return facade.getStatusForProject(branchName); + } + + isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean { + const facade = this.createFacade(projectPath); + return facade.isAutoLoopRunning(branchName); + } + + async startAutoLoopForProject( + projectPath: string, + branchName: string | null = null, + maxConcurrency?: number + ): Promise { + const facade = this.createFacade(projectPath); + return facade.startAutoLoop(branchName, maxConcurrency); + } + + async stopAutoLoopForProject( + projectPath: string, + branchName: string | null = null + ): Promise { + const facade = this.createFacade(projectPath); + return facade.stopAutoLoop(branchName); + } + + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } + ): Promise { + const facade = this.createFacade(projectPath); + return facade.executeFeature( + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } + + async stopFeature(featureId: string): Promise { + // Stop feature is tricky - we need to find which project the feature is running in + // The concurrency manager tracks this + const runningAgents = await this.getRunningAgents(); + const agent = runningAgents.find((a) => a.featureId === featureId); + if (agent) { + const facade = this.createFacade(agent.projectPath); + return facade.stopFeature(featureId); + } + return false; + } + + async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeFeature(featureId, useWorktrees); + } + + async followUpFeature( + projectPath: string, + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + const facade = this.createFacade(projectPath); + return facade.followUpFeature(featureId, prompt, imagePaths, useWorktrees); + } + + async verifyFeature(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.verifyFeature(featureId); + } + + async commitFeature( + projectPath: string, + featureId: string, + providedWorktreePath?: string + ): Promise { + const facade = this.createFacade(projectPath); + return facade.commitFeature(featureId, providedWorktreePath); + } + + async contextExists(projectPath: string, featureId: string): Promise { + const facade = this.createFacade(projectPath); + return facade.contextExists(featureId); + } + + async analyzeProject(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.analyzeProject(); + } + + async resolvePlanApproval( + projectPath: string, + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const facade = this.createFacade(projectPath); + return facade.resolvePlanApproval(featureId, approved, editedPlan, feedback); + } + + async resumeInterruptedFeatures(projectPath: string): Promise { + const facade = this.createFacade(projectPath); + return facade.resumeInterruptedFeatures(); + } + + async checkWorktreeCapacity( + projectPath: string, + featureId: string + ): Promise<{ + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; + }> { + const facade = this.createFacade(projectPath); + return facade.checkWorktreeCapacity(featureId); + } + + async detectOrphanedFeatures( + projectPath: string, + preloadedFeatures?: Feature[] + ): Promise> { + const facade = this.createFacade(projectPath); + return facade.detectOrphanedFeatures(preloadedFeatures); + } +} diff --git a/jules_branch/apps/server/src/services/auto-mode/facade.ts b/jules_branch/apps/server/src/services/auto-mode/facade.ts new file mode 100644 index 0000000000000000000000000000000000000000..db4dccdc9baa511f0ad0cd9c8c4127ee5169300b --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-mode/facade.ts @@ -0,0 +1,1257 @@ +/** + * AutoModeServiceFacade - Clean interface for auto-mode functionality + * + * This facade provides a thin delegation layer over the extracted services, + * exposing all 23 public methods that routes currently call on AutoModeService. + * + * Key design decisions: + * - Per-project factory pattern (projectPath is implicit in method calls) + * - Clean method names (e.g., startAutoLoop instead of startAutoLoopForProject) + * - Thin delegation to underlying services - no new business logic + * - Maintains backward compatibility during transition period + */ + +import path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import { + DEFAULT_MAX_CONCURRENCY, + DEFAULT_MODELS, + stripProviderPrefix, + isPipelineStatus, +} from '@automaker/types'; +import { resolveModelString } from '@automaker/model-resolver'; +import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import * as secureFs from '../../lib/secure-fs.js'; +import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js'; +import { + getPromptCustomization, + resolveProviderContext, + getMCPServersFromSettings, + getDefaultMaxTurnsSetting, +} from '../../lib/settings-helpers.js'; +import { execGitCommand } from '@automaker/git-utils'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { PlanApprovalService } from '../plan-approval-service.js'; +import { AutoLoopCoordinator, type AutoModeConfig } from '../auto-loop-coordinator.js'; +import { ExecutionService } from '../execution-service.js'; +import { RecoveryService } from '../recovery-service.js'; +import { PipelineOrchestrator } from '../pipeline-orchestrator.js'; +import { AgentExecutor } from '../agent-executor.js'; +import { TestRunnerService } from '../test-runner-service.js'; +import { ProviderFactory } from '../../providers/provider-factory.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { EventEmitter } from '../../lib/events.js'; +import type { + FacadeOptions, + FacadeError, + AutoModeStatus, + ProjectAutoModeStatus, + WorktreeCapacityInfo, + RunningAgentInfo, + OrphanedFeatureInfo, +} from './types.js'; + +const execAsync = promisify(exec); +const logger = createLogger('AutoModeServiceFacade'); + +/** + * AutoModeServiceFacade provides a clean interface for auto-mode functionality. + * + * Created via factory pattern with a specific projectPath, allowing methods + * to use clean names without requiring projectPath as a parameter. + */ +export class AutoModeServiceFacade { + private constructor( + private readonly projectPath: string, + private readonly events: EventEmitter, + private readonly eventBus: TypedEventBus, + private readonly concurrencyManager: ConcurrencyManager, + private readonly worktreeResolver: WorktreeResolver, + private readonly featureStateManager: FeatureStateManager, + private readonly featureLoader: FeatureLoader, + private readonly planApprovalService: PlanApprovalService, + private readonly autoLoopCoordinator: AutoLoopCoordinator, + private readonly executionService: ExecutionService, + private readonly recoveryService: RecoveryService, + private readonly pipelineOrchestrator: PipelineOrchestrator, + private readonly settingsService: SettingsService | null + ) {} + + /** + * Determine if a feature is eligible to be picked up by the auto-mode loop. + * + * @param feature - The feature to check + * @param branchName - The current worktree branch name (null for main) + * @param primaryBranch - The resolved primary branch name for the project + * @returns True if the feature is eligible for auto-dispatch + */ + public static isFeatureEligibleForAutoMode( + feature: Feature, + branchName: string | null, + primaryBranch: string | null + ): boolean { + const isEligibleStatus = + feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted' || + isPipelineStatus(feature.status); + + if (!isEligibleStatus) return false; + + // Filter by branch/worktree alignment + if (branchName === null) { + // For main worktree, include features with no branch or matching primary branch + return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch); + } else { + // For named worktrees, only include features matching that branch + return feature.branchName === branchName; + } + } + + /** + * Classify and log an error at the facade boundary. + * Emits an error event to the UI so failures are surfaced to the user. + * + * @param error - The caught error + * @param method - The facade method name where the error occurred + * @param featureId - Optional feature ID for context + * @returns The classified FacadeError for structured consumption + */ + private handleFacadeError(error: unknown, method: string, featureId?: string): FacadeError { + const errorInfo = classifyError(error); + + // Log at the facade boundary for debugging + logger.error( + `[${method}] ${featureId ? `Feature ${featureId}: ` : ''}${errorInfo.message}`, + error + ); + + // Emit error event to UI unless it's an abort/cancellation + if (!errorInfo.isAbort && !errorInfo.isCancellation) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId: featureId ?? null, + featureName: undefined, + branchName: null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + + return { + method, + errorType: errorInfo.type, + message: errorInfo.message, + featureId, + projectPath: this.projectPath, + }; + } + + /** + * Create a new AutoModeServiceFacade instance for a specific project. + * + * @param projectPath - The project path this facade operates on + * @param options - Configuration options including events, settingsService, featureLoader + */ + static create(projectPath: string, options: FacadeOptions): AutoModeServiceFacade { + const { + events, + settingsService = null, + featureLoader = new FeatureLoader(), + sharedServices, + } = options; + + // Use shared services if provided, otherwise create new ones + // Shared services allow multiple facades to share state (e.g., running features, auto loops) + const eventBus = sharedServices?.eventBus ?? new TypedEventBus(events); + const worktreeResolver = sharedServices?.worktreeResolver ?? new WorktreeResolver(); + const concurrencyManager = + sharedServices?.concurrencyManager ?? + new ConcurrencyManager((p) => worktreeResolver.getCurrentBranch(p)); + const featureStateManager = new FeatureStateManager(events, featureLoader); + const planApprovalService = new PlanApprovalService( + eventBus, + featureStateManager, + settingsService + ); + const agentExecutor = new AgentExecutor( + eventBus, + featureStateManager, + planApprovalService, + settingsService + ); + const testRunnerService = new TestRunnerService(); + + // Helper for building feature prompts (used by pipeline orchestrator) + const buildFeaturePrompt = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } + ): string => { + const title = + feature.title || feature.description?.split('\n')[0]?.substring(0, 60) || 'Untitled'; + let prompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${title}\n**Description:** ${feature.description}\n`; + if (feature.spec) { + prompt += `\n**Specification:**\n${feature.spec}\n`; + } + if (!feature.skipTests) { + prompt += `\n${prompts.implementationInstructions}\n\n${prompts.playwrightVerificationInstructions}`; + } else { + prompt += `\n${prompts.implementationInstructions}`; + } + return prompt; + }; + + // Create placeholder callbacks - will be bound to facade methods after creation. + // These use closures to capture the facade instance once created. + // INVARIANT: All callbacks passed to PipelineOrchestrator, AutoLoopCoordinator, + // and ExecutionService are invoked asynchronously (never during construction), + // so facadeInstance is guaranteed to be assigned before any callback runs. + let facadeInstance: AutoModeServiceFacade | null = null; + const getFacade = (): AutoModeServiceFacade => { + if (!facadeInstance) { + throw new Error( + 'AutoModeServiceFacade not yet initialized — callback invoked during construction' + ); + } + return facadeInstance; + }; + + /** + * Shared agent-run helper used by both PipelineOrchestrator and ExecutionService. + * + * Resolves provider/model context, then delegates to agentExecutor.execute with the + * full payload. The opts parameter uses an index-signature union so it + * accepts both the typed ExecutionService opts object and the looser + * Record used by PipelineOrchestrator without requiring + * type casts at the call sites. + */ + const createRunAgentFn = + () => + async ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + pPath: string, + imagePaths?: string[], + model?: string, + opts?: { + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + branchName?: string | null; + status?: string; // Feature status for pipeline summary check + [key: string]: unknown; + } + ): Promise => { + const resolvedModel = resolveModelString(model, DEFAULT_MODELS.claude); + const provider = ProviderFactory.getProviderForModel(resolvedModel); + const effectiveBareModel = stripProviderPrefix(resolvedModel); + + // Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials + let claudeCompatibleProvider: + | import('@automaker/types').ClaudeCompatibleProvider + | undefined; + let credentials: import('@automaker/types').Credentials | undefined; + let providerResolvedModel: string | undefined; + + if (settingsService) { + const providerId = opts?.providerId as string | undefined; + const result = await resolveProviderContext( + settingsService, + resolvedModel, + providerId, + '[AutoModeFacade]' + ); + claudeCompatibleProvider = result.provider; + credentials = result.credentials; + providerResolvedModel = result.resolvedModel; + } + + // Build sdkOptions with proper maxTurns and allowedTools for auto-mode. + // Without this, maxTurns would be undefined, causing providers to use their + // internal defaults which may be much lower than intended (e.g., Codex CLI's + // default turn limit can cause feature runs to stop prematurely). + const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false; + const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true; + let mcpServers: Record | undefined; + try { + if (settingsService) { + const servers = await getMCPServersFromSettings(settingsService, '[AutoModeFacade]'); + if (Object.keys(servers).length > 0) { + mcpServers = servers; + } + } + } catch { + // MCP servers are optional - continue without them + } + + // Read user-configured max turns from settings + const userMaxTurns = await getDefaultMaxTurnsSetting(settingsService, '[AutoModeFacade]'); + + const sdkOpts = createAutoModeOptions({ + cwd: workDir, + model: providerResolvedModel || resolvedModel, + systemPrompt: opts?.systemPrompt, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: opts?.thinkingLevel, + maxTurns: userMaxTurns, + mcpServers: mcpServers as + | Record + | undefined, + }); + + if (!sdkOpts) { + logger.error( + `[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}` + ); + } + + logger.info( + `[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` + + `maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` + + `provider=${provider.getName()}` + ); + + await agentExecutor.execute( + { + workDir, + featureId, + prompt, + projectPath: pPath, + abortController, + imagePaths, + model: resolvedModel, + planningMode: opts?.planningMode as PlanningMode | undefined, + requirePlanApproval: opts?.requirePlanApproval as boolean | undefined, + previousContent: opts?.previousContent as string | undefined, + systemPrompt: opts?.systemPrompt as string | undefined, + autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined, + useClaudeCodeSystemPrompt, + thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, + reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined, + branchName: opts?.branchName as string | null | undefined, + status: opts?.status as string | undefined, + provider, + effectiveBareModel, + credentials, + claudeCompatibleProvider, + mcpServers, + sdkOptions: { + maxTurns: sdkOpts.maxTurns, + allowedTools: sdkOpts.allowedTools as string[] | undefined, + systemPrompt: sdkOpts.systemPrompt, + settingSources: sdkOpts.settingSources as + | Array<'user' | 'project' | 'local'> + | undefined, + }, + }, + { + waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath), + saveFeatureSummary: (projPath, fId, summary) => + featureStateManager.saveFeatureSummary(projPath, fId, summary), + updateFeatureSummary: (projPath, fId, summary) => + featureStateManager.saveFeatureSummary(projPath, fId, summary), + buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => { + let taskPrompt = template + .replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`) + .replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1)) + .replace(/\{\{totalTasks\}\}/g, String(allTasks.length)) + .replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`); + if (feedback) { + taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback); + } + return taskPrompt; + }, + } + ); + }; + + // PipelineOrchestrator - runAgentFn delegates to AgentExecutor via shared helper + const pipelineOrchestrator = new PipelineOrchestrator( + eventBus, + featureStateManager, + agentExecutor, + testRunnerService, + worktreeResolver, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + loadContextFiles, + buildFeaturePrompt, + (pPath, featureId, useWorktrees, _isAutoMode, _model, opts) => + getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts), + createRunAgentFn() + ); + + // AutoLoopCoordinator - ALWAYS create new with proper execution callbacks + // NOTE: We don't use sharedServices.autoLoopCoordinator because it doesn't have + // execution callbacks. Each facade needs its own coordinator to execute features. + // The shared coordinator in GlobalAutoModeService is for monitoring only. + const autoLoopCoordinator = new AutoLoopCoordinator( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode) => + getFacade().executeFeature(featureId, useWorktrees, isAutoMode), + async (pPath, branchName) => { + const features = await featureLoader.getAll(pPath); + // For main worktree (branchName === null), resolve the actual primary branch name + // so features with branchName matching the primary branch are included + let primaryBranch: string | null = null; + if (branchName === null) { + primaryBranch = await worktreeResolver.getCurrentBranch(pPath); + } + return features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch) + ); + }, + (pPath, branchName, maxConcurrency) => + getFacade().saveExecutionStateForProject(branchName, maxConcurrency), + (pPath, branchName) => getFacade().clearExecutionState(branchName), + (pPath) => featureStateManager.resetStuckFeatures(pPath), + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + (featureId) => concurrencyManager.isRunning(featureId), + async (pPath) => featureLoader.getAll(pPath) + ); + + /** + * Iterate all active worktrees for this project, falling back to the + * main worktree (null) when none are active. + */ + const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => { + const projectWorktrees = autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === projectPath); + if (projectWorktrees.length === 0) { + fn(null); + } else { + for (const w of projectWorktrees) { + fn(w.branchName); + } + } + }; + + // ExecutionService - runAgentFn delegates to AgentExecutor via shared helper + const executionService = new ExecutionService( + eventBus, + concurrencyManager, + worktreeResolver, + settingsService, + createRunAgentFn(), + (context) => pipelineOrchestrator.executePipeline(context), + (pPath, featureId, status) => + featureStateManager.updateFeatureStatus(pPath, featureId, status), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + async (feature) => { + // getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode + if (!feature.planningMode || feature.planningMode === 'skip') { + return ''; + } + const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]'); + const autoModePrompts = prompts.autoMode; + switch (feature.planningMode) { + case 'lite': + return feature.requirePlanApproval + ? autoModePrompts.planningLiteWithApproval + '\n\n' + : autoModePrompts.planningLite + '\n\n'; + case 'spec': + return autoModePrompts.planningSpec + '\n\n'; + case 'full': + return autoModePrompts.planningFull + '\n\n'; + default: + return ''; + } + }, + (pPath, featureId, summary) => + featureStateManager.saveFeatureSummary(pPath, featureId, summary), + async () => { + /* recordLearnings - stub */ + }, + (pPath, featureId) => getFacade().contextExists(featureId), + (pPath, featureId, useWorktrees, _calledInternally) => + getFacade().resumeFeature(featureId, useWorktrees, _calledInternally), + (errorInfo) => { + // Track failure against ALL active worktrees for this project. + // The ExecutionService callbacks don't receive branchName, so we + // iterate all active worktrees. Uses a for-of loop (not .some()) to + // ensure every worktree's failure counter is incremented. + let shouldPause = false; + forEachProjectWorktree((branchName) => { + if ( + autoLoopCoordinator.trackFailureAndCheckPauseForProject( + projectPath, + branchName, + errorInfo + ) + ) { + shouldPause = true; + } + }); + return shouldPause; + }, + (errorInfo) => { + forEachProjectWorktree((branchName) => + autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo) + ); + }, + () => { + // Record success to clear failure tracking. This prevents failures + // from accumulating over time and incorrectly pausing auto mode. + forEachProjectWorktree((branchName) => + autoLoopCoordinator.recordSuccessForProject(projectPath, branchName) + ); + }, + (_pPath) => getFacade().saveExecutionState(), + loadContextFiles + ); + + // RecoveryService + const recoveryService = new RecoveryService( + eventBus, + concurrencyManager, + settingsService, + // Callbacks + (pPath, featureId, useWorktrees, isAutoMode, providedWorktreePath, opts) => + getFacade().executeFeature(featureId, useWorktrees, isAutoMode, providedWorktreePath, opts), + (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), + (pPath, featureId, status) => + pipelineOrchestrator.detectPipelineStatus(pPath, featureId, status), + (pPath, feature, useWorktrees, pipelineInfo) => + pipelineOrchestrator.resumePipeline(pPath, feature, useWorktrees, pipelineInfo), + (featureId) => concurrencyManager.isRunning(featureId), + (opts) => concurrencyManager.acquire(opts), + (featureId) => concurrencyManager.release(featureId) + ); + + // Create the facade instance + facadeInstance = new AutoModeServiceFacade( + projectPath, + events, + eventBus, + concurrencyManager, + worktreeResolver, + featureStateManager, + featureLoader, + planApprovalService, + autoLoopCoordinator, + executionService, + recoveryService, + pipelineOrchestrator, + settingsService + ); + + return facadeInstance; + } + + // =========================================================================== + // AUTO LOOP CONTROL (4 methods) + // =========================================================================== + + /** + * Start the auto mode loop for this project/worktree + * @param branchName - The branch name for worktree scoping, null for main worktree + * @param maxConcurrency - Maximum concurrent features + */ + async startAutoLoop(branchName: string | null = null, maxConcurrency?: number): Promise { + try { + return await this.autoLoopCoordinator.startAutoLoopForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } catch (error) { + this.handleFacadeError(error, 'startAutoLoop'); + throw error; + } + } + + /** + * Stop the auto mode loop for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + async stopAutoLoop(branchName: string | null = null): Promise { + try { + return await this.autoLoopCoordinator.stopAutoLoopForProject(this.projectPath, branchName); + } catch (error) { + this.handleFacadeError(error, 'stopAutoLoop'); + throw error; + } + } + + /** + * Check if auto mode is running for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + isAutoLoopRunning(branchName: string | null = null): boolean { + return this.autoLoopCoordinator.isAutoLoopRunningForProject(this.projectPath, branchName); + } + + /** + * Get auto loop config for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + getAutoLoopConfig(branchName: string | null = null): AutoModeConfig | null { + return this.autoLoopCoordinator.getAutoLoopConfigForProject(this.projectPath, branchName); + } + + // =========================================================================== + // FEATURE EXECUTION (6 methods) + // =========================================================================== + + /** + * Execute a single feature + * @param featureId - The feature ID to execute + * @param useWorktrees - Whether to use worktrees for isolation + * @param isAutoMode - Whether this is running in auto mode + * @param providedWorktreePath - Optional pre-resolved worktree path + * @param options - Additional execution options + */ + async executeFeature( + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { + continuationPrompt?: string; + _calledInternally?: boolean; + } + ): Promise { + try { + return await this.executionService.executeFeature( + this.projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + options + ); + } catch (error) { + this.handleFacadeError(error, 'executeFeature', featureId); + throw error; + } + } + + /** + * Stop a specific feature + * @param featureId - ID of the feature to stop + */ + async stopFeature(featureId: string): Promise { + try { + // Cancel any pending plan approval for this feature + this.cancelPlanApproval(featureId); + return await this.executionService.stopFeature(featureId); + } catch (error) { + this.handleFacadeError(error, 'stopFeature', featureId); + throw error; + } + } + + /** + * Resume a feature (continues from saved context or starts fresh) + * @param featureId - ID of the feature to resume + * @param useWorktrees - Whether to use git worktrees + * @param _calledInternally - Internal flag for nested calls + */ + async resumeFeature( + featureId: string, + useWorktrees = false, + _calledInternally = false + ): Promise { + // Note: ExecutionService.executeFeature catches its own errors internally and + // does NOT re-throw them (it emits auto_mode_error and returns normally). + // Therefore, errors that reach this catch block are pre-execution failures + // (e.g., feature not found, context read error) that ExecutionService never + // handled — so calling handleFacadeError here does NOT produce duplicate events. + try { + return await this.recoveryService.resumeFeature( + this.projectPath, + featureId, + useWorktrees, + _calledInternally + ); + } catch (error) { + this.handleFacadeError(error, 'resumeFeature', featureId); + throw error; + } + } + + /** + * Follow up on a feature with additional instructions + * @param featureId - The feature ID + * @param prompt - Follow-up prompt + * @param imagePaths - Optional image paths + * @param useWorktrees - Whether to use worktrees + */ + async followUpFeature( + featureId: string, + prompt: string, + imagePaths?: string[], + useWorktrees = true + ): Promise { + validateWorkingDirectory(this.projectPath); + + try { + // Load feature to build the prompt context + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + + // Read previous agent output as context + const featureDir = getFeatureDir(this.projectPath, featureId); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile( + path.join(featureDir, 'agent-output.md'), + 'utf-8' + )) as string; + } catch { + // No previous context available - that's OK + } + + // Build the feature prompt section + const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; + + // Get the follow-up prompt template and build the continuation prompt + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + let continuationPrompt = prompts.autoMode.followUpPromptTemplate; + continuationPrompt = continuationPrompt + .replace(/\{\{featurePrompt\}\}/g, featurePrompt) + .replace(/\{\{previousContext\}\}/g, previousContext) + .replace(/\{\{followUpInstructions\}\}/g, prompt); + + // Store image paths on the feature so executeFeature can pick them up + if (imagePaths && imagePaths.length > 0) { + feature.imagePaths = imagePaths.map((p) => ({ + path: p, + filename: p.split('/').pop() || p, + mimeType: 'image/*', + })); + await this.featureStateManager.updateFeatureStatus( + this.projectPath, + featureId, + feature.status || 'in_progress' + ); + } + + // Delegate to executeFeature with the built continuation prompt + await this.executeFeature(featureId, useWorktrees, false, undefined, { + continuationPrompt, + }); + } catch (error) { + const errorInfo = classifyError(error); + if (!errorInfo.isAbort) { + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: undefined, + branchName: null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath: this.projectPath, + }); + } + throw error; + } + } + + /** + * Verify a feature's implementation + * @param featureId - The feature ID to verify + */ + async verifyFeature(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + let workDir = this.projectPath; + + // Use worktreeResolver to find worktree path (consistent with commitFeature) + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + try { + await secureFs.access(resolved); + workDir = resolved; + } catch { + // Fall back to project path + } + } + } + + const verificationChecks = [ + { cmd: 'npm run lint', name: 'Lint' }, + { cmd: 'npm run typecheck', name: 'Type check' }, + { cmd: 'npm test', name: 'Tests' }, + { cmd: 'npm run build', name: 'Build' }, + ]; + + let allPassed = true; + const results: Array<{ check: string; passed: boolean; output?: string }> = []; + + for (const check of verificationChecks) { + try { + const { stdout, stderr } = await execAsync(check.cmd, { cwd: workDir, timeout: 120000 }); + results.push({ check: check.name, passed: true, output: stdout || stderr }); + } catch (error) { + allPassed = false; + results.push({ check: check.name, passed: false, output: (error as Error).message }); + break; + } + } + + const runningEntryForVerify = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForVerify?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: allPassed, + message: allPassed + ? 'All verification checks passed' + : `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`, + projectPath: this.projectPath, + }); + } + + return allPassed; + } + + /** + * Commit feature changes + * @param featureId - The feature ID to commit + * @param providedWorktreePath - Optional worktree path + */ + async commitFeature(featureId: string, providedWorktreePath?: string): Promise { + let workDir = this.projectPath; + + if (providedWorktreePath) { + try { + await secureFs.access(providedWorktreePath); + workDir = providedWorktreePath; + } catch { + // Use project path + } + } else { + // Use worktreeResolver instead of manual .worktrees lookup + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const branchName = feature?.branchName; + if (branchName) { + const resolved = await this.worktreeResolver.findWorktreeForBranch( + this.projectPath, + branchName + ); + if (resolved) { + workDir = resolved; + } + } + } + + try { + const status = await execGitCommand(['status', '--porcelain'], workDir); + if (!status.trim()) { + return null; + } + + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const title = + feature?.description?.split('\n')[0]?.substring(0, 60) || `Feature ${featureId}`; + const commitMessage = `feat: ${title}\n\nImplemented by Automaker auto-mode`; + + await execGitCommand(['add', '-A'], workDir); + await execGitCommand(['commit', '-m', commitMessage], workDir); + const hash = await execGitCommand(['rev-parse', 'HEAD'], workDir); + + const runningEntryForCommit = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForCommit?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: true, + message: `Changes committed: ${hash.trim().substring(0, 8)}`, + projectPath: this.projectPath, + }); + } + + return hash.trim(); + } catch (error) { + logger.error(`Commit failed for ${featureId}:`, error); + return null; + } + } + + // =========================================================================== + // STATUS AND QUERIES (7 methods) + // =========================================================================== + + /** + * Get current status (global across all projects) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get status for this project/worktree + * @param branchName - The branch name, or null for main worktree + */ + async getStatusForProject(branchName: string | null = null): Promise { + const isAutoLoopRunning = this.autoLoopCoordinator.isAutoLoopRunningForProject( + this.projectPath, + branchName + ); + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + // Use branchName-normalized filter so features with branchName "main" + // are correctly matched when querying for the main worktree (null) + const runningFeatures = await this.concurrencyManager.getRunningFeaturesForWorktree( + this.projectPath, + branchName + ); + + return { + isAutoLoopRunning, + runningFeatures, + runningCount: runningFeatures.length, + maxConcurrency: config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + branchName, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + return this.autoLoopCoordinator.getActiveProjects(); + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.autoLoopCoordinator.getActiveWorktrees(); + } + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + const agents = await Promise.all( + this.concurrencyManager.getAllRunning().map(async (rf) => { + let title: string | undefined; + let description: string | undefined; + let branchName: string | undefined; + + try { + const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); + if (feature) { + title = feature.title; + description = feature.description; + branchName = feature.branchName ?? undefined; + } + } catch { + // Silently ignore + } + + return { + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + model: rf.model, + provider: rf.provider, + title, + description, + branchName, + }; + }) + ); + return agents; + } + + /** + * Check if there's capacity to start a feature on a worktree + * @param featureId - The feature ID to check capacity for + */ + async checkWorktreeCapacity(featureId: string): Promise { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + const rawBranchName = feature?.branchName ?? null; + // Normalize primary branch to null (works for main, master, or any default branch) + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + const branchName = rawBranchName === primaryBranch ? null : rawBranchName; + + const maxAgents = await this.autoLoopCoordinator.resolveMaxConcurrency( + this.projectPath, + branchName + ); + const currentAgents = await this.concurrencyManager.getRunningCountForWorktree( + this.projectPath, + branchName + ); + + return { + hasCapacity: currentAgents < maxAgents, + currentAgents, + maxAgents, + branchName, + }; + } + + /** + * Check if context exists for a feature + * @param featureId - The feature ID + */ + async contextExists(featureId: string): Promise { + return this.recoveryService.contextExists(this.projectPath, featureId); + } + + // =========================================================================== + // PLAN APPROVAL (4 methods) + // =========================================================================== + + /** + * Resolve a pending plan approval + * @param featureId - The feature ID + * @param approved - Whether the plan was approved + * @param editedPlan - Optional edited plan content + * @param feedback - Optional feedback + */ + async resolvePlanApproval( + featureId: string, + approved: boolean, + editedPlan?: string, + feedback?: string + ): Promise<{ success: boolean; error?: string }> { + const result = await this.planApprovalService.resolveApproval(featureId, approved, { + editedPlan, + feedback, + projectPath: this.projectPath, + }); + + // Handle recovery case + if (result.success && result.needsRecovery) { + const feature = await this.featureStateManager.loadFeature(this.projectPath, featureId); + if (feature) { + const prompts = await getPromptCustomization(this.settingsService, '[Facade]'); + const planContent = editedPlan || feature.planSpec?.content || ''; + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt.replace(/\{\{userFeedback\}\}/g, feedback || ''); + continuationPrompt = continuationPrompt.replace(/\{\{approvedPlan\}\}/g, planContent); + + // Start execution async + this.executeFeature(featureId, true, false, undefined, { continuationPrompt }).catch( + (error) => { + logger.error(`Recovery execution failed for feature ${featureId}:`, error); + } + ); + } + } + + return { success: result.success, error: result.error }; + } + + /** + * Wait for plan approval + * @param featureId - The feature ID + */ + waitForPlanApproval( + featureId: string + ): Promise<{ approved: boolean; editedPlan?: string; feedback?: string }> { + return this.planApprovalService.waitForApproval(featureId, this.projectPath); + } + + /** + * Check if a feature has a pending plan approval + * @param featureId - The feature ID + */ + hasPendingApproval(featureId: string): boolean { + return this.planApprovalService.hasPendingApproval(featureId, this.projectPath); + } + + /** + * Cancel a pending plan approval + * @param featureId - The feature ID + */ + cancelPlanApproval(featureId: string): void { + this.planApprovalService.cancelApproval(featureId, this.projectPath); + } + + // =========================================================================== + // ANALYSIS AND RECOVERY (3 methods) + // =========================================================================== + + /** + * Analyze project to gather context + * + * NOTE: This method requires complex provider integration that is only available + * in AutoModeService. The facade exposes the method signature for API compatibility, + * but routes should use AutoModeService.analyzeProject() until migration is complete. + */ + async analyzeProject(): Promise { + // analyzeProject requires provider.execute which is complex to wire up + // For now, throw to indicate routes should use AutoModeService + throw new Error( + 'analyzeProject not fully implemented in facade - use AutoModeService.analyzeProject instead' + ); + } + + /** + * Resume interrupted features after server restart + */ + async resumeInterruptedFeatures(): Promise { + return this.recoveryService.resumeInterruptedFeatures(this.projectPath); + } + + /** + * Detect orphaned features (features with missing branches) + * @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads + */ + async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise { + const orphanedFeatures: OrphanedFeatureInfo[] = []; + + try { + const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath)); + const featuresWithBranches = allFeatures.filter( + (f) => f.branchName && f.branchName.trim() !== '' + ); + + if (featuresWithBranches.length === 0) { + return orphanedFeatures; + } + + // Get existing branches (using safe array-based command) + const stdout = await execGitCommand( + ['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], + this.projectPath + ); + const existingBranches = new Set( + stdout + .trim() + .split('\n') + .map((b) => b.trim()) + .filter(Boolean) + ); + + const primaryBranch = await this.worktreeResolver.getCurrentBranch(this.projectPath); + + for (const feature of featuresWithBranches) { + const branchName = feature.branchName!; + if (primaryBranch && branchName === primaryBranch) { + continue; + } + if (!existingBranches.has(branchName)) { + orphanedFeatures.push({ feature, missingBranch: branchName }); + } + } + + return orphanedFeatures; + } catch (error) { + logger.error('[detectOrphanedFeatures] Error:', error); + return orphanedFeatures; + } + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + const allRunning = this.concurrencyManager.getAllRunning(); + + for (const rf of allRunning) { + await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); + } + + if (allRunning.length > 0) { + logger.info( + `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` + ); + } + } + + // =========================================================================== + // INTERNAL HELPERS + // =========================================================================== + + /** + * Save execution state for recovery. + * + * Uses the active auto-loop config for each worktree so that the persisted + * state reflects the real branch and maxConcurrency values rather than the + * hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY). + */ + private async saveExecutionState(): Promise { + const projectWorktrees = this.autoLoopCoordinator + .getActiveWorktrees() + .filter((w) => w.projectPath === this.projectPath); + + if (projectWorktrees.length === 0) { + // No active auto loops — save with defaults as a best-effort fallback. + return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY); + } + + // Save state for every active worktree using its real config values. + for (const { branchName } of projectWorktrees) { + const config = this.autoLoopCoordinator.getAutoLoopConfigForProject( + this.projectPath, + branchName + ); + const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY; + await this.saveExecutionStateForProject(branchName, maxConcurrency); + } + } + + /** + * Save execution state for a specific worktree + */ + private async saveExecutionStateForProject( + branchName: string | null, + maxConcurrency: number + ): Promise { + return this.recoveryService.saveExecutionStateForProject( + this.projectPath, + branchName, + maxConcurrency + ); + } + + /** + * Clear execution state + */ + private async clearExecutionState(branchName: string | null = null): Promise { + return this.recoveryService.clearExecutionState(this.projectPath, branchName); + } +} diff --git a/jules_branch/apps/server/src/services/auto-mode/global-service.ts b/jules_branch/apps/server/src/services/auto-mode/global-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..478f48b3fcf56188da50f9de2353668f1e7befb2 --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-mode/global-service.ts @@ -0,0 +1,224 @@ +/** + * GlobalAutoModeService - Global operations for auto-mode that span across all projects + * + * This service manages global state and operations that are not project-specific: + * - Overall status (all running features across all projects) + * - Active auto loop projects and worktrees + * - Graceful shutdown (mark all features as interrupted) + * + * Per-project operations should use AutoModeServiceFacade instead. + */ + +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../../lib/events.js'; +import { TypedEventBus } from '../typed-event-bus.js'; +import { ConcurrencyManager } from '../concurrency-manager.js'; +import { WorktreeResolver } from '../worktree-resolver.js'; +import { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import { FeatureStateManager } from '../feature-state-manager.js'; +import { FeatureLoader } from '../feature-loader.js'; +import type { SettingsService } from '../settings-service.js'; +import type { SharedServices, AutoModeStatus, RunningAgentInfo } from './types.js'; + +const logger = createLogger('GlobalAutoModeService'); + +/** + * GlobalAutoModeService provides global operations for auto-mode. + * + * Created once at server startup, shared across all facades. + */ +export class GlobalAutoModeService { + private readonly eventBus: TypedEventBus; + private readonly concurrencyManager: ConcurrencyManager; + private readonly autoLoopCoordinator: AutoLoopCoordinator; + private readonly worktreeResolver: WorktreeResolver; + private readonly featureStateManager: FeatureStateManager; + private readonly featureLoader: FeatureLoader; + + constructor( + events: EventEmitter, + settingsService: SettingsService | null, + featureLoader: FeatureLoader = new FeatureLoader() + ) { + this.featureLoader = featureLoader; + this.eventBus = new TypedEventBus(events); + this.worktreeResolver = new WorktreeResolver(); + this.concurrencyManager = new ConcurrencyManager((p) => + this.worktreeResolver.getCurrentBranch(p) + ); + this.featureStateManager = new FeatureStateManager(events, featureLoader); + + // Create AutoLoopCoordinator with callbacks + // IMPORTANT: This coordinator is for MONITORING ONLY (getActiveProjects, getActiveWorktrees). + // Facades MUST create their own AutoLoopCoordinator for actual execution. + // The executeFeatureFn here is a safety guard - it should never be called. + this.autoLoopCoordinator = new AutoLoopCoordinator( + this.eventBus, + this.concurrencyManager, + settingsService, + // executeFeatureFn - throws because facades must use their own coordinator for execution + async () => { + throw new Error( + 'executeFeatureFn not available in GlobalAutoModeService. ' + + 'Facades must create their own AutoLoopCoordinator for execution.' + ); + }, + // getBacklogFeaturesFn + async (pPath, branchName) => { + const features = await featureLoader.getAll(pPath); + // For main worktree (branchName === null), resolve the actual primary branch name + // so features with branchName matching the primary branch are included + let primaryBranch: string | null = null; + if (branchName === null) { + primaryBranch = await this.worktreeResolver.getCurrentBranch(pPath); + } + return features.filter( + (f) => + (f.status === 'backlog' || f.status === 'ready') && + (branchName === null + ? !f.branchName || (primaryBranch && f.branchName === primaryBranch) + : f.branchName === branchName) + ); + }, + // saveExecutionStateFn - placeholder + async () => {}, + // clearExecutionStateFn - placeholder + async () => {}, + // resetStuckFeaturesFn + (pPath) => this.featureStateManager.resetStuckFeatures(pPath), + // isFeatureDoneFn + (feature) => + feature.status === 'completed' || + feature.status === 'verified' || + feature.status === 'waiting_approval', + // isFeatureRunningFn + (featureId) => this.concurrencyManager.isRunning(featureId) + ); + } + + /** + * Get the shared services for use by facades. + * This allows facades to share state with the global service. + */ + getSharedServices(): SharedServices { + return { + eventBus: this.eventBus, + concurrencyManager: this.concurrencyManager, + autoLoopCoordinator: this.autoLoopCoordinator, + worktreeResolver: this.worktreeResolver, + }; + } + + // =========================================================================== + // GLOBAL STATUS (3 methods) + // =========================================================================== + + /** + * Get global status (all projects combined) + */ + getStatus(): AutoModeStatus { + const allRunning = this.concurrencyManager.getAllRunning(); + return { + isRunning: allRunning.length > 0, + runningFeatures: allRunning.map((rf) => rf.featureId), + runningCount: allRunning.length, + }; + } + + /** + * Get all active auto loop projects (unique project paths) + */ + getActiveAutoLoopProjects(): string[] { + return this.autoLoopCoordinator.getActiveProjects(); + } + + /** + * Get all active auto loop worktrees + */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }> { + return this.autoLoopCoordinator.getActiveWorktrees(); + } + + // =========================================================================== + // RUNNING AGENTS (1 method) + // =========================================================================== + + /** + * Get detailed info about all running agents + */ + async getRunningAgents(): Promise { + const agents = await Promise.all( + this.concurrencyManager.getAllRunning().map(async (rf) => { + let title: string | undefined; + let description: string | undefined; + let branchName: string | undefined; + + try { + const feature = await this.featureLoader.get(rf.projectPath, rf.featureId); + if (feature) { + title = feature.title; + description = feature.description; + branchName = feature.branchName ?? undefined; + } + } catch { + // Silently ignore + } + + return { + featureId: rf.featureId, + projectPath: rf.projectPath, + projectName: path.basename(rf.projectPath), + isAutoMode: rf.isAutoMode, + model: rf.model, + provider: rf.provider, + title, + description, + branchName, + }; + }) + ); + return agents; + } + + // =========================================================================== + // LIFECYCLE (1 method) + // =========================================================================== + + /** + * Mark all running features as interrupted. + * Called during graceful shutdown. + * + * @param reason - Optional reason for the interruption + */ + async markAllRunningFeaturesInterrupted(reason?: string): Promise { + const allRunning = this.concurrencyManager.getAllRunning(); + + for (const rf of allRunning) { + await this.featureStateManager.markFeatureInterrupted(rf.projectPath, rf.featureId, reason); + } + + if (allRunning.length > 0) { + logger.info( + `Marked ${allRunning.length} running feature(s) as interrupted: ${reason || 'no reason provided'}` + ); + } + } + + /** + * Reconcile all feature states for a project on server startup. + * + * Resets features stuck in transient states (in_progress, interrupted, pipeline_*) + * back to a resting state and emits events so the UI reflects corrected states. + * + * This should be called during server initialization to handle: + * - Clean shutdown: features already marked as interrupted + * - Forced kill / crash: features left in in_progress or pipeline_* states + * + * @param projectPath - The project path to reconcile + * @returns The number of features that were reconciled + */ + async reconcileFeatureStates(projectPath: string): Promise { + return this.featureStateManager.reconcileAllFeatureStates(projectPath); + } +} diff --git a/jules_branch/apps/server/src/services/auto-mode/index.ts b/jules_branch/apps/server/src/services/auto-mode/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..40e0ee8489b3f65076aee523ae9130620102e44b --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-mode/index.ts @@ -0,0 +1,77 @@ +/** + * Auto Mode Service Module + * + * Entry point for auto-mode functionality. Exports: + * - GlobalAutoModeService: Global operations that span all projects + * - AutoModeServiceFacade: Per-project facade for auto-mode operations + * - createAutoModeFacade: Convenience factory function + * - Types for route consumption + */ + +// Main exports +export { GlobalAutoModeService } from './global-service.js'; +export { AutoModeServiceFacade } from './facade.js'; +export { AutoModeServiceCompat } from './compat.js'; + +// Convenience factory function +import { AutoModeServiceFacade } from './facade.js'; +import type { FacadeOptions } from './types.js'; + +/** + * Create an AutoModeServiceFacade instance for a specific project. + * + * This is a convenience wrapper around AutoModeServiceFacade.create(). + * + * @param projectPath - The project path this facade operates on + * @param options - Configuration options including events, settingsService, featureLoader + * @returns A new AutoModeServiceFacade instance + * + * @example + * ```typescript + * import { createAutoModeFacade } from './services/auto-mode'; + * + * const facade = createAutoModeFacade('/path/to/project', { + * events: eventEmitter, + * settingsService, + * }); + * + * // Start auto mode + * await facade.startAutoLoop(null, 3); + * + * // Check status + * const status = facade.getStatusForProject(); + * ``` + */ +export function createAutoModeFacade( + projectPath: string, + options: FacadeOptions +): AutoModeServiceFacade { + return AutoModeServiceFacade.create(projectPath, options); +} + +// Type exports from types.ts +export type { + FacadeOptions, + SharedServices, + AutoModeStatus, + ProjectAutoModeStatus, + WorktreeCapacityInfo, + RunningAgentInfo, + OrphanedFeatureInfo, + FacadeError, + GlobalAutoModeOperations, +} from './types.js'; + +// Re-export types from extracted services for route convenience +export type { + AutoModeConfig, + ProjectAutoLoopState, + RunningFeature, + AcquireParams, + WorktreeInfo, + PipelineContext, + PipelineStatusInfo, + PlanApprovalResult, + ResolveApprovalResult, + ExecutionState, +} from './types.js'; diff --git a/jules_branch/apps/server/src/services/auto-mode/types.ts b/jules_branch/apps/server/src/services/auto-mode/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc82cb1396cd59afbce0bb40853c2facf97601fb --- /dev/null +++ b/jules_branch/apps/server/src/services/auto-mode/types.ts @@ -0,0 +1,148 @@ +/** + * Facade Types - Type definitions for AutoModeServiceFacade + * + * Contains: + * - FacadeOptions interface for factory configuration + * - Re-exports of types from extracted services that routes might need + * - Additional types for facade method signatures + */ + +import type { EventEmitter } from '../../lib/events.js'; +import type { Feature, ModelProvider } from '@automaker/types'; +import type { SettingsService } from '../settings-service.js'; +import type { FeatureLoader } from '../feature-loader.js'; +import type { ConcurrencyManager } from '../concurrency-manager.js'; +import type { AutoLoopCoordinator } from '../auto-loop-coordinator.js'; +import type { WorktreeResolver } from '../worktree-resolver.js'; +import type { TypedEventBus } from '../typed-event-bus.js'; +import type { ClaudeUsageService } from '../claude-usage-service.js'; + +// Re-export types from extracted services for route consumption +export type { AutoModeConfig, ProjectAutoLoopState } from '../auto-loop-coordinator.js'; + +export type { RunningFeature, AcquireParams } from '../concurrency-manager.js'; + +export type { WorktreeInfo } from '../worktree-resolver.js'; + +export type { PipelineContext, PipelineStatusInfo } from '../pipeline-orchestrator.js'; + +export type { PlanApprovalResult, ResolveApprovalResult } from '../plan-approval-service.js'; + +export type { ExecutionState } from '../recovery-service.js'; + +/** + * Shared services that can be passed to facades to enable state sharing + */ +export interface SharedServices { + /** TypedEventBus for typed event emission */ + eventBus: TypedEventBus; + /** ConcurrencyManager for tracking running features across all projects */ + concurrencyManager: ConcurrencyManager; + /** AutoLoopCoordinator for managing auto loop state across all projects */ + autoLoopCoordinator: AutoLoopCoordinator; + /** WorktreeResolver for git worktree operations */ + worktreeResolver: WorktreeResolver; +} + +/** + * Options for creating an AutoModeServiceFacade instance + */ +export interface FacadeOptions { + /** EventEmitter for broadcasting events to clients */ + events: EventEmitter; + /** SettingsService for reading project/global settings (optional) */ + settingsService?: SettingsService | null; + /** FeatureLoader for loading feature data (optional, defaults to new FeatureLoader()) */ + featureLoader?: FeatureLoader; + /** Shared services for state sharing across facades (optional) */ + sharedServices?: SharedServices; + /** ClaudeUsageService for checking usage limits before picking up features (optional) */ + claudeUsageService?: ClaudeUsageService | null; +} + +/** + * Status returned by getStatus() + */ +export interface AutoModeStatus { + isRunning: boolean; + runningFeatures: string[]; + runningCount: number; +} + +/** + * Status returned by getStatusForProject() + */ +export interface ProjectAutoModeStatus { + isAutoLoopRunning: boolean; + runningFeatures: string[]; + runningCount: number; + maxConcurrency: number; + branchName: string | null; +} + +/** + * Capacity info returned by checkWorktreeCapacity() + */ +export interface WorktreeCapacityInfo { + hasCapacity: boolean; + currentAgents: number; + maxAgents: number; + branchName: string | null; +} + +/** + * Running agent info returned by getRunningAgents() + */ +export interface RunningAgentInfo { + featureId: string; + projectPath: string; + projectName: string; + isAutoMode: boolean; + model?: string; + provider?: ModelProvider; + title?: string; + description?: string; + branchName?: string; +} + +/** + * Orphaned feature info returned by detectOrphanedFeatures() + */ +export interface OrphanedFeatureInfo { + feature: Feature; + missingBranch: string; +} + +/** + * Structured error object returned/emitted by facade methods. + * Provides consistent error information for callers and UI consumers. + */ +export interface FacadeError { + /** The facade method where the error originated */ + method: string; + /** Classified error type from the error handler */ + errorType: import('@automaker/types').ErrorType; + /** Human-readable error message */ + message: string; + /** Feature ID if the error is associated with a specific feature */ + featureId?: string; + /** Project path where the error occurred */ + projectPath: string; +} + +/** + * Interface describing global auto-mode operations (not project-specific). + * Used by routes that need global state access. + */ +export interface GlobalAutoModeOperations { + /** Get global status (all projects combined) */ + getStatus(): AutoModeStatus; + /** Get all active auto loop projects (unique project paths) */ + getActiveAutoLoopProjects(): string[]; + /** Get all active auto loop worktrees */ + getActiveAutoLoopWorktrees(): Array<{ projectPath: string; branchName: string | null }>; + /** Get detailed info about all running agents */ + getRunningAgents(): Promise; + /** Mark all running features as interrupted (for graceful shutdown) */ + markAllRunningFeaturesInterrupted(reason?: string): Promise; +} diff --git a/jules_branch/apps/server/src/services/branch-commit-log-service.ts b/jules_branch/apps/server/src/services/branch-commit-log-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9666f98cfe22adde081e87bd1cf1232bf3064e28 --- /dev/null +++ b/jules_branch/apps/server/src/services/branch-commit-log-service.ts @@ -0,0 +1,172 @@ +/** + * Service for fetching branch commit log data. + * + * Extracts the heavy Git command execution and parsing logic from the + * branch-commit-log route handler so the handler only validates input, + * invokes this service, streams lifecycle events, and sends the response. + */ + +import { execGitCommand } from '../lib/git.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BranchCommit { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + subject: string; + body: string; + files: string[]; +} + +export interface BranchCommitLogResult { + branch: string; + commits: BranchCommit[]; + total: number; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Fetch the commit log for a specific branch (or HEAD). + * + * Runs a single `git log --name-only` invocation (plus `git rev-parse` + * when branchName is omitted) inside the given worktree path and + * returns a structured result. + * + * @param worktreePath - Absolute path to the worktree / repository + * @param branchName - Branch to query (omit or pass undefined for HEAD) + * @param limit - Maximum number of commits to return (clamped 1-100) + */ +export async function getBranchCommitLog( + worktreePath: string, + branchName: string | undefined, + limit: number +): Promise { + // Clamp limit to a reasonable range + const parsedLimit = Number(limit); + const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100); + + // Use the specified branch or default to HEAD + const targetRef = branchName || 'HEAD'; + + // Fetch commit metadata AND file lists in a single git call. + // Uses custom record separators so we can parse both metadata and + // --name-only output from one invocation, eliminating the previous + // N+1 pattern that spawned a separate `git diff-tree` per commit. + // + // -m causes merge commits to be diffed against each parent so all + // files touched by the merge are listed (without -m, --name-only + // produces no file output for merge commits because they have 2+ parents). + // This means merge commits appear multiple times in the output (once per + // parent), so we deduplicate by hash below and merge their file lists. + // We over-fetch (2× the limit) to compensate for -m duplicating merge + // commit entries, then trim the result to the requested limit. + // Use ASCII control characters as record separators – these cannot appear in + // git commit messages, so these delimiters are safe regardless of commit + // body content. %x00 and %x01 in git's format string emit literal NUL / + // SOH bytes respectively. + // + // COMMIT_SEP (\x00) – marks the start of each commit record. + // META_END (\x01) – separates commit metadata from the --name-only file list. + // + // Full per-commit layout emitted by git: + // \x00\n\n\n...\n\n\x01 + const COMMIT_SEP = '\x00'; + const META_END = '\x01'; + const fetchLimit = commitLimit * 2; + + const logOutput = await execGitCommand( + [ + 'log', + targetRef, + `--max-count=${fetchLimit}`, + '-m', + '--name-only', + `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`, + ], + worktreePath + ); + + // Split output into per-commit blocks and drop the empty first chunk + // (the output starts with a NUL commit separator). + const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim()); + + // Use a Map to deduplicate merge commit entries (which appear once per + // parent when -m is used) while preserving insertion order. + const commitMap = new Map(); + + for (const block of commitBlocks) { + const metaEndIdx = block.indexOf(META_END); + if (metaEndIdx === -1) continue; // malformed block, skip + + // --- Parse metadata (everything before the META_END delimiter) --- + const metaRaw = block.substring(0, metaEndIdx); + const metaLines = metaRaw.split('\n'); + + // The first line may be empty (newline right after COMMIT_SEP), skip it + const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== ''); + if (nonEmptyStart === -1) continue; + + const fields = metaLines.slice(nonEmptyStart); + if (fields.length < 6) continue; // need at least hash..subject + + const hash = fields[0].trim(); + if (!hash) continue; // defensive: skip if hash is empty + const shortHash = fields[1]?.trim() ?? ''; + const author = fields[2]?.trim() ?? ''; + const authorEmail = fields[3]?.trim() ?? ''; + const date = fields[4]?.trim() ?? ''; + const subject = fields[5]?.trim() ?? ''; + const body = fields.slice(6).join('\n').trim(); + + // --- Parse file list (everything after the META_END delimiter) --- + const filesRaw = block.substring(metaEndIdx + META_END.length); + const blockFiles = filesRaw + .trim() + .split('\n') + .filter((f) => f.trim()); + + // Merge file lists for duplicate entries (merge commits with -m) + const existing = commitMap.get(hash); + if (existing) { + // Add new files to the existing entry's file set + const fileSet = new Set(existing.files); + for (const f of blockFiles) fileSet.add(f); + existing.files = [...fileSet]; + } else { + commitMap.set(hash, { + hash, + shortHash, + author, + authorEmail, + date, + subject, + body, + files: [...new Set(blockFiles)], + }); + } + } + + // Trim to the requested limit (we over-fetched to account for -m duplicates) + const commits = [...commitMap.values()].slice(0, commitLimit); + + // If branchName wasn't specified, get current branch for display + let displayBranch = branchName; + if (!displayBranch) { + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + displayBranch = branchOutput.trim(); + } + + return { + branch: displayBranch, + commits, + total: commits.length, + }; +} diff --git a/jules_branch/apps/server/src/services/branch-sync-service.ts b/jules_branch/apps/server/src/services/branch-sync-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b9d48c358242a66f5b249a99443d4097e4d9def --- /dev/null +++ b/jules_branch/apps/server/src/services/branch-sync-service.ts @@ -0,0 +1,426 @@ +/** + * branch-sync-service - Sync a local base branch with its remote tracking branch + * + * Provides logic to detect remote tracking branches, check whether a branch + * is checked out in any worktree, and fast-forward a local branch to match + * its remote counterpart. Extracted from the worktree create route so + * the git logic is decoupled from HTTP request/response handling. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; + +const logger = createLogger('BranchSyncService'); + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Result of attempting to sync a base branch with its remote. + */ +export interface BaseBranchSyncResult { + /** Whether the sync was attempted */ + attempted: boolean; + /** Whether the sync succeeded */ + synced: boolean; + /** Whether the ref was resolved (but not synced, e.g. remote ref, tag, or commit hash) */ + resolved?: boolean; + /** The remote that was synced from (e.g. 'origin') */ + remote?: string; + /** The commit hash the base branch points to after sync */ + commitHash?: string; + /** Human-readable message about the sync result */ + message?: string; + /** Whether the branch had diverged (local commits ahead of remote) */ + diverged?: boolean; + /** Whether the user can proceed with a stale local copy */ + canProceedWithStale?: boolean; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Detect the remote tracking branch for a given local branch. + * + * @param projectPath - Path to the git repository + * @param branchName - Local branch name to check (e.g. 'main') + * @returns Object with remote name and remote branch, or null if no tracking branch + */ +export async function getTrackingBranch( + projectPath: string, + branchName: string +): Promise<{ remote: string; remoteBranch: string } | null> { + try { + // git rev-parse --abbrev-ref @{upstream} returns e.g. "origin/main" + const upstream = await execGitCommand( + ['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], + projectPath + ); + const trimmed = upstream.trim(); + if (!trimmed) return null; + + // First, attempt to determine the remote name explicitly via git config + // so that remotes whose names contain slashes are handled correctly. + let remote: string | null = null; + try { + const configRemote = await execGitCommand( + ['config', '--get', `branch.${branchName}.remote`], + projectPath + ); + const configRemoteTrimmed = configRemote.trim(); + if (configRemoteTrimmed) { + remote = configRemoteTrimmed; + } + } catch { + // git config lookup failed — will fall back to string splitting below + } + + if (remote) { + // Strip the known remote prefix (plus the separating '/') to get the remote branch. + // The upstream string is expected to be "/". + const prefix = `${remote}/`; + if (trimmed.startsWith(prefix)) { + return { + remote, + remoteBranch: trimmed.substring(prefix.length), + }; + } + // Upstream doesn't start with the expected prefix — fall through to split + } + + // Fall back: split on the FIRST slash, which favors the common case of + // single-name remotes with slash-containing branch names (e.g. + // "origin/feature/foo" → remote="origin", remoteBranch="feature/foo"). + // Remotes with slashes in their names are uncommon and are already handled + // by the git-config lookup above; this fallback only runs when that lookup + // fails, so optimizing for single-name remotes is the safer default. + const slashIndex = trimmed.indexOf('/'); + if (slashIndex > 0) { + return { + remote: trimmed.substring(0, slashIndex), + remoteBranch: trimmed.substring(slashIndex + 1), + }; + } + return null; + } catch { + // No upstream tracking branch configured + return null; + } +} + +/** + * Check whether a branch is checked out in ANY worktree (main or linked). + * Uses `git worktree list --porcelain` to enumerate all worktrees and + * checks if any of them has the given branch as their HEAD. + * + * Returns the absolute path of the worktree where the branch is checked out, + * or null if the branch is not checked out anywhere. Callers can use the + * returned path to run commands (e.g. `git merge`) inside the correct worktree. + * + * This prevents using `git update-ref` on a branch that is checked out in + * a linked worktree, which would desync that worktree's HEAD. + */ +export async function isBranchCheckedOut( + projectPath: string, + branchName: string +): Promise { + try { + const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath); + const lines = stdout.split('\n'); + let currentWorktreePath: string | null = null; + let currentBranch: string | null = null; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentWorktreePath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = line.slice(7).replace('refs/heads/', ''); + } else if (line === '') { + // End of a worktree entry — check for match, then reset for the next + if (currentBranch === branchName && currentWorktreePath) { + return currentWorktreePath; + } + currentWorktreePath = null; + currentBranch = null; + } + } + + // Check the last entry (if output doesn't end with a blank line) + if (currentBranch === branchName && currentWorktreePath) { + return currentWorktreePath; + } + + return null; + } catch { + return null; + } +} + +/** + * Build a BaseBranchSyncResult for cases where we proceed with a stale local copy. + * Extracts the repeated pattern of getting the short commit hash with a fallback. + */ +export async function buildStaleResult( + projectPath: string, + branchName: string, + remote: string | undefined, + message: string, + extra?: Partial +): Promise { + let commitHash: string | undefined; + try { + const hash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + commitHash = hash.trim(); + } catch { + /* ignore — commit hash is non-critical */ + } + return { + attempted: true, + synced: false, + remote, + commitHash, + message, + canProceedWithStale: true, + ...extra, + }; +} + +// ============================================================================ +// Main Sync Function +// ============================================================================ + +/** + * Sync a local base branch with its remote tracking branch using fast-forward only. + * + * This function: + * 1. Detects the remote tracking branch for the given local branch + * 2. Fetches latest from that remote (unless skipFetch is true) + * 3. Attempts a fast-forward-only update of the local branch + * 4. If the branch has diverged, reports the divergence and allows proceeding with stale copy + * 5. If no remote tracking branch exists, skips silently + * + * @param projectPath - Path to the git repository + * @param branchName - The local branch name to sync (e.g. 'main') + * @param skipFetch - When true, skip the internal git fetch (caller has already fetched) + * @returns Sync result with status information + */ +export async function syncBaseBranch( + projectPath: string, + branchName: string, + skipFetch = false +): Promise { + // Check if the branch exists as a local branch (under refs/heads/). + // This correctly handles branch names containing slashes (e.g. "feature/abc", + // "fix/issue-123") which are valid local branch names, not remote refs. + let existsLocally = false; + try { + await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], projectPath); + existsLocally = true; + } catch { + existsLocally = false; + } + + if (!existsLocally) { + // Not a local branch — check if it's a valid ref (remote ref, tag, or commit hash). + // No synchronization is performed here; we only resolve the ref to a commit hash. + try { + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + return { + attempted: false, + synced: false, + resolved: true, + commitHash: commitHash.trim(), + message: `Ref '${branchName}' resolved (not a local branch; no sync performed)`, + }; + } catch { + return { + attempted: false, + synced: false, + message: `Ref '${branchName}' not found`, + }; + } + } + + // Detect remote tracking branch + const tracking = await getTrackingBranch(projectPath, branchName); + if (!tracking) { + // No remote tracking branch — skip silently + logger.info(`Branch '${branchName}' has no remote tracking branch, skipping sync`); + try { + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + return { + attempted: false, + synced: false, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' has no remote tracking branch`, + }; + } catch { + return { + attempted: false, + synced: false, + message: `Branch '${branchName}' has no remote tracking branch`, + }; + } + } + + logger.info( + `Syncing base branch '${branchName}' from ${tracking.remote}/${tracking.remoteBranch}` + ); + + // Fetch the specific remote unless the caller has already performed a fetch + // (e.g. via `git fetch --all`) and passed skipFetch=true to avoid redundant work. + if (!skipFetch) { + try { + const fetchController = new AbortController(); + const fetchTimer = setTimeout(() => fetchController.abort(), FETCH_TIMEOUT_MS); + try { + await execGitCommand( + ['fetch', tracking.remote, tracking.remoteBranch, '--quiet'], + projectPath, + undefined, + fetchController + ); + } finally { + clearTimeout(fetchTimer); + } + } catch (fetchErr) { + // Fetch failed — network error, auth error, etc. + // Allow proceeding with stale local copy + const errMsg = getErrorMessage(fetchErr); + logger.warn(`Failed to fetch ${tracking.remote}/${tracking.remoteBranch}: ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Failed to fetch from remote: ${errMsg}. Proceeding with local copy.` + ); + } + } else { + logger.info(`Skipping fetch for '${branchName}' (caller already fetched from remotes)`); + } + + // Check if the local branch is behind, ahead, or diverged from the remote + const remoteRef = `${tracking.remote}/${tracking.remoteBranch}`; + try { + // Count commits ahead and behind + const revListOutput = await execGitCommand( + ['rev-list', '--left-right', '--count', `${branchName}...${remoteRef}`], + projectPath + ); + const parts = revListOutput.trim().split(/\s+/); + const ahead = parseInt(parts[0], 10) || 0; + const behind = parseInt(parts[1], 10) || 0; + + if (ahead === 0 && behind === 0) { + // Already up to date + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Branch '${branchName}' is already up to date with ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' is already up to date`, + }; + } + + if (ahead > 0 && behind > 0) { + // Branch has diverged — cannot fast-forward + logger.warn( + `Branch '${branchName}' has diverged from ${remoteRef} (${ahead} ahead, ${behind} behind)` + ); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Branch '${branchName}' has diverged from ${remoteRef} (${ahead} commit(s) ahead, ${behind} behind). Using local copy to avoid overwriting local commits.`, + { diverged: true } + ); + } + + if (ahead > 0 && behind === 0) { + // Local is ahead — nothing to pull, already has everything from remote plus more + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Branch '${branchName}' is ${ahead} commit(s) ahead of ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Branch '${branchName}' is ${ahead} commit(s) ahead of remote`, + }; + } + + // behind > 0 && ahead === 0 — can fast-forward + logger.info( + `Branch '${branchName}' is ${behind} commit(s) behind ${remoteRef}, fast-forwarding` + ); + + // Determine whether the branch is currently checked out (returns the + // worktree path where it is checked out, or null if not checked out) + const worktreePath = await isBranchCheckedOut(projectPath, branchName); + + if (worktreePath) { + // Branch is checked out in a worktree — use git merge --ff-only + // Run the merge inside the worktree that has the branch checked out + try { + await execGitCommand(['merge', '--ff-only', remoteRef], worktreePath); + } catch (mergeErr) { + const errMsg = getErrorMessage(mergeErr); + logger.warn(`Fast-forward merge failed for '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Fast-forward merge failed: ${errMsg}. Proceeding with local copy.` + ); + } + } else { + // Branch is NOT checked out — use git update-ref to fast-forward without checkout + // This is safe because we already verified the branch is strictly behind (ahead === 0) + try { + const remoteCommit = await execGitCommand(['rev-parse', remoteRef], projectPath); + await execGitCommand( + ['update-ref', `refs/heads/${branchName}`, remoteCommit.trim()], + projectPath + ); + } catch (updateErr) { + const errMsg = getErrorMessage(updateErr); + logger.warn(`update-ref failed for '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Failed to fast-forward branch: ${errMsg}. Proceeding with local copy.` + ); + } + } + + // Successfully fast-forwarded + const commitHash = await execGitCommand(['rev-parse', '--short', branchName], projectPath); + logger.info(`Successfully synced '${branchName}' to ${commitHash.trim()} from ${remoteRef}`); + return { + attempted: true, + synced: true, + remote: tracking.remote, + commitHash: commitHash.trim(), + message: `Fast-forwarded '${branchName}' by ${behind} commit(s) from ${remoteRef}`, + }; + } catch (err) { + // Unexpected error during rev-list or merge — proceed with stale + const errMsg = getErrorMessage(err); + logger.warn(`Unexpected error syncing '${branchName}': ${errMsg}`); + return buildStaleResult( + projectPath, + branchName, + tracking.remote, + `Sync failed: ${errMsg}. Proceeding with local copy.` + ); + } +} diff --git a/jules_branch/apps/server/src/services/branch-utils.ts b/jules_branch/apps/server/src/services/branch-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a618da065d98564eb468981777d80e0b52df2ef --- /dev/null +++ b/jules_branch/apps/server/src/services/branch-utils.ts @@ -0,0 +1,170 @@ +/** + * branch-utils - Shared git branch helper utilities + * + * Provides common git operations used by both checkout-branch-service and + * worktree-branch-service. Extracted to avoid duplication and ensure + * consistent behaviour across branch-related services. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; + +const logger = createLogger('BranchUtils'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface HasAnyChangesOptions { + /** + * When true, lines that refer to worktree-internal paths (containing + * ".worktrees/" or ending with ".worktrees") are excluded from the count. + * Use this in contexts where worktree directory entries should not be + * considered as real working-tree changes (e.g. worktree-branch-service). + */ + excludeWorktreePaths?: boolean; + /** + * When true (default), untracked files (lines starting with "??") are + * included in the change count. When false, untracked files are ignored so + * that hasAnyChanges() is consistent with stashChanges() called without + * --include-untracked. + */ + includeUntracked?: boolean; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Returns true when a `git status --porcelain` output line refers to a + * worktree-internal path that should be ignored when deciding whether there + * are "real" local changes. + */ +function isExcludedWorktreeLine(line: string): boolean { + return line.includes('.worktrees/') || line.endsWith('.worktrees'); +} + +// ============================================================================ +// Exported Utilities +// ============================================================================ + +/** + * Check if there are any changes that should be stashed. + * + * @param cwd - Working directory of the git repository / worktree + * @param options - Optional flags controlling which lines are counted + * @param options.excludeWorktreePaths - When true, lines matching worktree + * internal paths are excluded so they are not mistaken for real changes + * @param options.includeUntracked - When false, untracked files (lines + * starting with "??") are excluded so this is consistent with a + * stashChanges() call that does not pass --include-untracked. + * Defaults to true. + */ +export async function hasAnyChanges(cwd: string, options?: HasAnyChangesOptions): Promise { + try { + const includeUntracked = options?.includeUntracked ?? true; + const stdout = await execGitCommand(['status', '--porcelain'], cwd); + const lines = stdout + .trim() + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (options?.excludeWorktreePaths && isExcludedWorktreeLine(line)) return false; + if (!includeUntracked && line.startsWith('??')) return false; + return true; + }); + return lines.length > 0; + } catch (err) { + logger.error('hasAnyChanges: execGitCommand failed — returning false', { + cwd, + error: getErrorMessage(err), + }); + return false; + } +} + +/** + * Stash all local changes (including untracked files if requested). + * Returns true if a stash was created, false if there was nothing to stash. + * Throws on unexpected errors so callers abort rather than proceeding silently. + * + * @param cwd - Working directory of the git repository / worktree + * @param message - Stash message + * @param includeUntracked - When true, passes `--include-untracked` to git stash + */ +export async function stashChanges( + cwd: string, + message: string, + includeUntracked: boolean = true +): Promise { + try { + const args = ['stash', 'push']; + if (includeUntracked) { + args.push('--include-untracked'); + } + args.push('-m', message); + + const stdout = await execGitCommandWithLockRetry(args, cwd); + + // git exits 0 but prints a benign message when there is nothing to stash + const stdoutLower = stdout.toLowerCase(); + if ( + stdoutLower.includes('no local changes to save') || + stdoutLower.includes('nothing to stash') + ) { + logger.debug('stashChanges: nothing to stash', { cwd, message, stdout }); + return false; + } + + return true; + } catch (error) { + const errorMsg = getErrorMessage(error); + + // Unexpected error – log full details and re-throw so the caller aborts + // rather than proceeding with an un-stashed working tree + logger.error('stashChanges: unexpected error during stash', { + cwd, + message, + error: errorMsg, + }); + throw new Error(`Failed to stash changes in ${cwd}: ${errorMsg}`); + } +} + +/** + * Pop the most recent stash entry. + * Returns an object indicating success and whether there were conflicts. + * + * @param cwd - Working directory of the git repository / worktree + */ +export async function popStash( + cwd: string +): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> { + try { + await execGitCommandWithLockRetry(['stash', 'pop'], cwd); + // If execGitCommandWithLockRetry succeeds (zero exit code), there are no conflicts + return { success: true, hasConflicts: false }; + } catch (error) { + const errorMsg = getErrorMessage(error); + if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) { + return { success: false, hasConflicts: true, error: errorMsg }; + } + return { success: false, hasConflicts: false, error: errorMsg }; + } +} + +/** + * Check if a local branch already exists. + * + * @param cwd - Working directory of the git repository / worktree + * @param branchName - The branch name to look up (without refs/heads/ prefix) + */ +export async function localBranchExists(cwd: string, branchName: string): Promise { + try { + await execGitCommand(['rev-parse', '--verify', `refs/heads/${branchName}`], cwd); + return true; + } catch { + return false; + } +} diff --git a/jules_branch/apps/server/src/services/checkout-branch-service.ts b/jules_branch/apps/server/src/services/checkout-branch-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..922be329bd79dbd14479651e5fbe9eb70eab443a --- /dev/null +++ b/jules_branch/apps/server/src/services/checkout-branch-service.ts @@ -0,0 +1,389 @@ +/** + * CheckoutBranchService - Create and checkout a new branch with stash handling + * + * Handles new branch creation with automatic stash/reapply of local changes. + * If there are uncommitted changes and the caller requests stashing, they are + * stashed before creating the branch and reapplied after. If the stash pop + * results in merge conflicts, returns a special response so the UI can create + * a conflict resolution task. + * + * Follows the same pattern as worktree-branch-service.ts (performSwitchBranch). + * + * The workflow: + * 0. Fetch latest from all remotes (ensures remote refs are up-to-date) + * 1. Validate inputs (branch name, base branch) + * 2. Get current branch name + * 3. Check if target branch already exists + * 4. Optionally stash local changes + * 5. Create and checkout the new branch + * 6. Reapply stashed changes (detect conflicts) + * 7. Handle error recovery (restore stash if checkout fails) + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; +import type { EventEmitter } from '../lib/events.js'; +import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js'; + +const logger = createLogger('CheckoutBranchService'); + +// ============================================================================ +// Local Helpers +// ============================================================================ + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch creation flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. This is called before creating the new branch to + * ensure remote refs are up-to-date when a remote base branch is used. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (controller.signal.aborted) { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs regardless of failure type + } finally { + clearTimeout(timerId); + } +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface CheckoutBranchOptions { + /** When true, stash local changes before checkout and reapply after */ + stashChanges?: boolean; + /** When true, include untracked files in the stash */ + includeUntracked?: boolean; +} + +export interface CheckoutBranchResult { + success: boolean; + error?: string; + result?: { + previousBranch: string; + newBranch: string; + message: string; + hasConflicts?: boolean; + stashedChanges?: boolean; + }; + /** Set when checkout fails and stash pop produced conflicts during recovery */ + stashPopConflicts?: boolean; + /** Human-readable message when stash pop conflicts occur during error recovery */ + stashPopConflictMessage?: string; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Create and checkout a new branch, optionally stashing and restoring local changes. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Name of the new branch to create + * @param baseBranch - Optional base branch to create from (defaults to current HEAD) + * @param options - Stash handling options + * @param events - Optional event emitter for lifecycle events + * @returns CheckoutBranchResult with detailed status information + */ +export async function performCheckoutBranch( + worktreePath: string, + branchName: string, + baseBranch?: string, + options?: CheckoutBranchOptions, + events?: EventEmitter +): Promise { + const shouldStash = options?.stashChanges ?? false; + const includeUntracked = options?.includeUntracked ?? true; + + // Emit start event + events?.emit('switch:start', { worktreePath, branchName, operation: 'checkout' }); + + // 0. Fetch latest from all remotes before creating the branch + // This ensures remote refs are up-to-date so that base branch validation + // works correctly for remote branch references (e.g. "origin/main"). + await fetchRemotes(worktreePath); + + // 1. Get current branch + let previousBranch: string; + try { + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + previousBranch = currentBranchOutput.trim(); + } catch (branchError) { + const branchErrorMsg = getErrorMessage(branchError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: branchErrorMsg, + }); + return { + success: false, + error: `Failed to determine current branch: ${branchErrorMsg}`, + }; + } + + // 2. Check if branch already exists + if (await localBranchExists(worktreePath, branchName)) { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Branch '${branchName}' already exists`, + }); + return { + success: false, + error: `Branch '${branchName}' already exists`, + }; + } + + // 3. Validate base branch if provided + if (baseBranch) { + try { + await execGitCommand(['rev-parse', '--verify', baseBranch], worktreePath); + } catch { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Base branch '${baseBranch}' does not exist`, + }); + return { + success: false, + error: `Base branch '${baseBranch}' does not exist`, + }; + } + } + + // 4. Stash local changes if requested and there are changes + let didStash = false; + + if (shouldStash) { + const hadChanges = await hasAnyChanges(worktreePath, { includeUntracked }); + if (hadChanges) { + events?.emit('switch:stash', { + worktreePath, + previousBranch, + targetBranch: branchName, + action: 'push', + }); + + const stashMessage = `Auto-stash before switching to ${branchName}`; + try { + didStash = await stashChanges(worktreePath, stashMessage, includeUntracked); + } catch (stashError) { + const stashErrorMsg = getErrorMessage(stashError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to stash local changes: ${stashErrorMsg}`, + }); + return { + success: false, + error: `Failed to stash local changes before creating branch: ${stashErrorMsg}`, + }; + } + } + } + + try { + // 5. Create and checkout the new branch + events?.emit('switch:checkout', { + worktreePath, + targetBranch: branchName, + isRemote: false, + previousBranch, + }); + + const checkoutArgs = ['checkout', '-b', branchName]; + if (baseBranch) { + checkoutArgs.push(baseBranch); + } + await execGitCommand(checkoutArgs, worktreePath); + + // 6. Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + let stashReapplied = false; + + if (didStash) { + events?.emit('switch:pop', { + worktreePath, + targetBranch: branchName, + action: 'pop', + }); + + // Isolate the pop in its own try/catch so a thrown exception does not + // propagate to the outer catch block, which would attempt a second pop. + try { + const popResult = await popStash(worktreePath); + // Mark didStash false so the outer error-recovery path cannot pop again. + didStash = false; + hasConflicts = popResult.hasConflicts; + if (popResult.hasConflicts) { + conflictMessage = `Created branch '${branchName}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + conflictMessage = `Created branch '${branchName}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } else { + stashReapplied = true; + } + } catch (popError) { + // Pop threw an unexpected exception. Record the error and clear didStash + // so the outer catch does not attempt a second pop. + didStash = false; + conflictMessage = `Created branch '${branchName}' but an error occurred while reapplying stashed changes: ${getErrorMessage(popError)}. Your changes may still be in the stash.`; + events?.emit('switch:pop', { + worktreePath, + targetBranch: branchName, + action: 'pop', + error: getErrorMessage(popError), + }); + } + } + + if (hasConflicts) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + hasConflicts: true, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }; + } else if (didStash && !stashReapplied) { + // Stash pop failed for a non-conflict reason — stash is still present + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + stashPopFailed: true, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: conflictMessage, + hasConflicts: false, + stashedChanges: true, + }, + }; + } else { + const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : ''; + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: branchName, + stashReapplied, + }); + return { + success: true, + result: { + previousBranch, + newBranch: branchName, + message: `Created and checked out branch '${branchName}'${stashNote}`, + hasConflicts: false, + stashedChanges: stashReapplied, + }, + }; + } + } catch (checkoutError) { + // 7. If checkout failed and we stashed, try to restore the stash + if (didStash) { + try { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + stashPopConflicts: true, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: true, + stashPopConflictMessage: + 'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' + + 'but produced merge conflicts. Please resolve the conflicts before retrying.', + }; + } else if (!popResult.success) { + const checkoutErrorMsg = getErrorMessage(checkoutError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` + + `${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + }; + } + // popResult.success === true: stash was cleanly restored + } catch (popError) { + // popStash itself threw — build a failure result rather than letting + // the exception propagate and produce an unhandled rejection. + const checkoutErrorMsg = getErrorMessage(checkoutError); + const popErrorMsg = getErrorMessage(popError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, an error occurred while attempting to restore ` + + `your stashed changes: ${popErrorMsg} — your changes may still be saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + stashPopConflictMessage: combinedMessage, + }; + } + } + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: false, + }; + } +} diff --git a/jules_branch/apps/server/src/services/cherry-pick-service.ts b/jules_branch/apps/server/src/services/cherry-pick-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..be5dbac2d35c273ee9c2a981b29c440402b5a8a9 --- /dev/null +++ b/jules_branch/apps/server/src/services/cherry-pick-service.ts @@ -0,0 +1,179 @@ +/** + * CherryPickService - Cherry-pick git operations without HTTP + * + * Extracted from worktree cherry-pick route to encapsulate all git + * cherry-pick business logic in a single service. Follows the same + * pattern as merge-service.ts. + */ + +import { createLogger } from '@automaker/utils'; +import { execGitCommand, getCurrentBranch } from '../lib/git.js'; +import { type EventEmitter } from '../lib/events.js'; + +const logger = createLogger('CherryPickService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface CherryPickOptions { + noCommit?: boolean; +} + +export interface CherryPickResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + aborted?: boolean; + cherryPicked?: boolean; + commitHashes?: string[]; + branch?: string; + message?: string; +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * Verify that each commit hash exists in the repository. + * + * @param worktreePath - Path to the git worktree + * @param commitHashes - Array of commit hashes to verify + * @param emitter - Optional event emitter for lifecycle events + * @returns The first invalid commit hash, or null if all are valid + */ +export async function verifyCommits( + worktreePath: string, + commitHashes: string[], + emitter?: EventEmitter +): Promise { + for (const hash of commitHashes) { + try { + await execGitCommand(['rev-parse', '--verify', hash], worktreePath); + } catch { + emitter?.emit('cherry-pick:verify-failed', { worktreePath, hash }); + return hash; + } + } + return null; +} + +/** + * Run the cherry-pick operation on the given worktree. + * + * @param worktreePath - Path to the git worktree + * @param commitHashes - Array of commit hashes to cherry-pick (in order) + * @param options - Cherry-pick options (e.g., noCommit) + * @param emitter - Optional event emitter for lifecycle events + * @returns CherryPickResult with success/failure information + */ +export async function runCherryPick( + worktreePath: string, + commitHashes: string[], + options?: CherryPickOptions, + emitter?: EventEmitter +): Promise { + const args = ['cherry-pick']; + if (options?.noCommit) { + args.push('--no-commit'); + } + args.push(...commitHashes); + + emitter?.emit('cherry-pick:started', { worktreePath, commitHashes }); + + try { + await execGitCommand(args, worktreePath); + + const branch = await getCurrentBranch(worktreePath); + + if (options?.noCommit) { + const result: CherryPickResult = { + success: true, + cherryPicked: false, + commitHashes, + branch, + message: `Staged changes from ${commitHashes.length} commit(s); no commit created due to --no-commit`, + }; + emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch }); + return result; + } + + const result: CherryPickResult = { + success: true, + cherryPicked: true, + commitHashes, + branch, + message: `Successfully cherry-picked ${commitHashes.length} commit(s)`, + }; + emitter?.emit('cherry-pick:success', { worktreePath, commitHashes, branch }); + return result; + } catch (cherryPickError: unknown) { + // Check if this is a cherry-pick conflict + const err = cherryPickError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + const hasConflicts = + output.includes('CONFLICT') || + output.includes('cherry-pick failed') || + output.includes('could not apply'); + + if (hasConflicts) { + // Abort the cherry-pick to leave the repo in a clean state + const aborted = await abortCherryPick(worktreePath, emitter); + + if (!aborted) { + logger.error( + 'Failed to abort cherry-pick after conflict; repository may be in a dirty state', + { worktreePath } + ); + } + + emitter?.emit('cherry-pick:conflict', { + worktreePath, + commitHashes, + aborted, + stdout: err.stdout, + stderr: err.stderr, + }); + + return { + success: false, + error: aborted + ? 'Cherry-pick aborted due to conflicts; no changes were applied.' + : 'Cherry-pick failed due to conflicts and the abort also failed; repository may be in a dirty state.', + hasConflicts: true, + aborted, + }; + } + + // Non-conflict error - propagate + throw cherryPickError; + } +} + +/** + * Abort an in-progress cherry-pick operation. + * + * @param worktreePath - Path to the git worktree + * @param emitter - Optional event emitter for lifecycle events + * @returns true if abort succeeded, false if it failed (logged as warning) + */ +export async function abortCherryPick( + worktreePath: string, + emitter?: EventEmitter +): Promise { + try { + await execGitCommand(['cherry-pick', '--abort'], worktreePath); + emitter?.emit('cherry-pick:abort', { worktreePath, aborted: true }); + return true; + } catch (err: unknown) { + const error = err as { message?: string }; + logger.warn('Failed to abort cherry-pick after conflict'); + emitter?.emit('cherry-pick:abort', { + worktreePath, + aborted: false, + error: error.message ?? 'Unknown error during cherry-pick abort', + }); + return false; + } +} diff --git a/jules_branch/apps/server/src/services/claude-usage-service.ts b/jules_branch/apps/server/src/services/claude-usage-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffb076319f144a74b68ea2a5f1484642fc8ca6c4 --- /dev/null +++ b/jules_branch/apps/server/src/services/claude-usage-service.ts @@ -0,0 +1,791 @@ +import { spawn } from 'child_process'; +import * as os from 'os'; +import * as pty from 'node-pty'; +import { ClaudeUsage } from '../routes/claude/types.js'; +import { createLogger } from '@automaker/utils'; + +/** + * Claude Usage Service + * + * Fetches usage data by executing the Claude CLI's /usage command. + * This approach doesn't require any API keys - it relies on the user + * having already authenticated via `claude login`. + * + * Platform-specific implementations: + * - macOS: Uses 'expect' command for PTY + * - Windows/Linux: Uses node-pty for PTY + */ +const logger = createLogger('ClaudeUsage'); + +export class ClaudeUsageService { + private claudeBinary = 'claude'; + private timeout = 30000; // 30 second timeout + private isWindows = os.platform() === 'win32'; + private isLinux = os.platform() === 'linux'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + // When in Electron, always use winpty to avoid ConPTY's AttachConsole errors + private isElectron = + !!(process.versions && (process.versions as Record).electron) || + !!process.env.ELECTRON_RUN_AS_NODE; + private useConptyFallback = false; // Track if we need to use winpty fallback on Windows + + /** + * Kill a PTY process with platform-specific handling. + * Windows doesn't support Unix signals like SIGTERM, so we call kill() without arguments. + * On Unix-like systems (macOS, Linux), we can specify the signal. + * + * @param ptyProcess - The PTY process to kill + * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') + */ + private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill(signal); + } + } + + /** + * Check if Claude CLI is available on the system + */ + async isAvailable(): Promise { + return new Promise((resolve) => { + const checkCmd = this.isWindows ? 'where' : 'which'; + const proc = spawn(checkCmd, [this.claudeBinary]); + proc.on('close', (code) => { + resolve(code === 0); + }); + proc.on('error', () => { + resolve(false); + }); + }); + } + + /** + * Fetch usage data by executing the Claude CLI + */ + async fetchUsageData(): Promise { + const output = await this.executeClaudeUsageCommand(); + return this.parseUsageOutput(output); + } + + /** + * Execute the claude /usage command and return the output + * Uses node-pty on all platforms for consistency + */ + private executeClaudeUsageCommand(): Promise { + // Use node-pty on all platforms - it's more reliable than expect on macOS + return this.executeClaudeUsageCommandPty(); + } + + /** + * macOS implementation using 'expect' command + */ + private executeClaudeUsageCommandMac(): Promise { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let settled = false; + + // Use current working directory - likely already trusted by Claude CLI + const workingDirectory = process.cwd(); + + // Use 'expect' with an inline script to run claude /usage with a PTY + // Running from cwd which should already be trusted + const expectScript = ` + set timeout 30 + spawn claude /usage + + # Wait for usage data or handle trust prompt if needed + expect { + -re "Ready to code|permission to work|Do you want to work" { + # Trust prompt appeared - send Enter to approve + sleep 1 + send "\\r" + exp_continue + } + "Current session" { + # Usage data appeared - wait for full output, then exit + sleep 3 + send "\\x1b" + } + "% left" { + # Usage percentage appeared + sleep 3 + send "\\x1b" + } + timeout { + send "\\x1b" + } + eof {} + } + expect eof + `; + + const proc = spawn('expect', ['-c', expectScript], { + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + }, + }); + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + proc.kill(); + reject(new Error('Command timed out')); + } + }, this.timeout); + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for authentication errors in output + if ( + stdout.includes('token_expired') || + stdout.includes('authentication_error') || + stderr.includes('token_expired') || + stderr.includes('authentication_error') + ) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + // Even if exit code is non-zero, we might have useful output + if (stdout.trim()) { + resolve(stdout); + } else if (code !== 0) { + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + reject(new Error('No output from claude command')); + } + }); + + proc.on('error', (err) => { + clearTimeout(timeoutId); + if (!settled) { + settled = true; + reject(new Error(`Failed to execute claude: ${err.message}`)); + } + }); + }); + } + + /** + * Windows/Linux implementation using node-pty + */ + private executeClaudeUsageCommandPty(): Promise { + return new Promise((resolve, reject) => { + let output = ''; + let settled = false; + let hasSeenUsageData = false; + let hasSeenTrustPrompt = false; + + // Use current working directory (project dir) - most likely already trusted by Claude CLI + const workingDirectory = process.cwd(); + + // Use platform-appropriate shell and command + const shell = this.isWindows ? 'cmd.exe' : '/bin/sh'; + // Use --add-dir to whitelist the current directory and bypass the trust prompt + // We don't pass /usage here, we'll type it into the REPL + const args = this.isWindows + ? ['/c', 'claude', '--add-dir', workingDirectory] + : ['-c', `claude --add-dir "${workingDirectory}"`]; + + // Using 'any' for ptyProcess because node-pty types don't include 'killed' property + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ptyProcess: any = null; + + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: workingDirectory, + env: { + ...process.env, + TERM: 'xterm-256color', + } as Record, + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + '[executeClaudeUsageCommandPty] Using winpty on Windows (ConPTY disabled for compatibility)' + ); + } + + try { + ptyProcess = pty.spawn(shell, args, ptyOptions); + } catch (spawnError) { + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn( + '[executeClaudeUsageCommandPty] ConPTY AttachConsole failed, retrying with winpty fallback' + ); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, args, ptyOptions); + logger.info( + '[executeClaudeUsageCommandPty] Successfully spawned with winpty fallback' + ); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error( + '[executeClaudeUsageCommandPty] Winpty fallback also failed:', + fallbackMessage + ); + reject( + new Error( + `Windows PTY unavailable: Both ConPTY and winpty failed. This typically happens when running in Electron without a console. ConPTY error: ${errorMessage}. Winpty error: ${fallbackMessage}` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Winpty fallback failed:', errorMessage); + reject( + new Error( + `Windows PTY unavailable: ${errorMessage}. The application is running without console access (common in Electron). Try running from a terminal window.` + ) + ); + return; + } + } else { + logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage); + reject( + new Error( + `Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.` + ) + ); + return; + } + } + + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + this.killPtyProcess(ptyProcess); + } + // Don't fail if we have data - return it instead + // Check cleaned output since raw output has ANSI codes between words + const cleanedForCheck = output + .replace(/\x1B\[(\d+)C/g, (_m: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); + if ( + cleanedForCheck.includes('Current session') || + cleanedForCheck.includes('% used') || + cleanedForCheck.includes('% left') + ) { + resolve(output); + } else if (hasSeenTrustPrompt) { + // Trust prompt was shown but we couldn't auto-approve it + reject( + new Error( + 'TRUST_PROMPT_PENDING: Claude CLI is waiting for folder permission. Please run "claude" in your terminal and approve access to continue.' + ) + ); + } else { + reject( + new Error( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ) + ); + } + } + }, 45000); // 45 second timeout + + let hasSentCommand = false; + let hasApprovedTrust = false; + + ptyProcess.onData((data: string) => { + output += data; + + // Strip ANSI codes for easier matching + // Convert cursor forward (ESC[nC) to spaces first to preserve word boundaries, + // then strip remaining ANSI sequences. Without this, the Claude CLI TUI output + // like "Current week (all models)" becomes "Currentweek(allmodels)". + const cleanOutput = output + .replace(/\x1B\[(\d+)C/g, (_match: string, n: string) => ' '.repeat(parseInt(n, 10))) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, ''); + + // Check for specific authentication/permission errors + // Must be very specific to avoid false positives from garbled terminal encoding + // Removed permission_error check as it was causing false positives with winpty encoding + const authChecks = { + oauth: cleanOutput.includes('OAuth token does not meet scope requirement'), + tokenExpired: cleanOutput.includes('token_expired'), + // Only match if it looks like a JSON API error response + authError: + cleanOutput.includes('"type":"authentication_error"') || + cleanOutput.includes('"type": "authentication_error"'), + }; + const hasAuthError = authChecks.oauth || authChecks.tokenExpired || authChecks.authError; + + if (hasAuthError) { + if (!settled) { + settled = true; + if (ptyProcess && !ptyProcess.killed) { + this.killPtyProcess(ptyProcess); + } + reject( + new Error( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ) + ); + } + return; + } + + // Check if we've seen the usage data (look for "Current session" or the TUI Usage header) + // Also check for percentage patterns that appear in usage output + const hasUsageIndicators = + cleanOutput.includes('Current session') || + (cleanOutput.includes('Usage') && cleanOutput.includes('% left')) || + // Look for percentage patterns - allow optional whitespace between % and left/used + // since cursor movement codes may or may not create spaces after stripping + /\d+%\s*(left|used|remaining)/i.test(cleanOutput) || + cleanOutput.includes('Resets in') || + cleanOutput.includes('Current week'); + + if (!hasSeenUsageData && hasUsageIndicators) { + hasSeenUsageData = true; + // Wait for full output, then send escape to exit + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\x1b'); // Send escape key + + // Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s + // Windows doesn't support signals, so killPtyProcess handles platform differences + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + this.killPtyProcess(ptyProcess); + } + }, 2000); + } + }, 3000); + } + + // Handle Trust Dialog - multiple variants: + // - "Do you want to work in this folder?" + // - "Ready to code here?" / "I'll need permission to work with your files" + // - "Quick safety check" / "Yes, I trust this folder" + // Since we are running in cwd (project dir), it is safe to approve. + if ( + !hasApprovedTrust && + (cleanOutput.includes('Do you want to work in this folder?') || + cleanOutput.includes('Ready to code here') || + cleanOutput.includes('permission to work with your files') || + cleanOutput.includes('trust this folder') || + cleanOutput.includes('safety check')) + ) { + hasApprovedTrust = true; + hasSeenTrustPrompt = true; + // Wait a tiny bit to ensure prompt is ready, then send Enter + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1000); + } + + // Detect REPL prompt and send /usage command + // On Windows with winpty, Unicode prompt char ❯ gets garbled, so also check for ASCII indicators + const isReplReady = + cleanOutput.includes('❯') || + cleanOutput.includes('? for shortcuts') || + // Fallback for winpty garbled encoding - detect CLI welcome screen elements + (cleanOutput.includes('Welcome back') && cleanOutput.includes('Claude')) || + (cleanOutput.includes('Tips for getting started') && cleanOutput.includes('Claude')) || + // Detect model indicator which appears when REPL is ready + (cleanOutput.includes('Opus') && cleanOutput.includes('Claude API')) || + (cleanOutput.includes('Sonnet') && cleanOutput.includes('Claude API')); + + if (!hasSentCommand && isReplReady) { + hasSentCommand = true; + // Wait for REPL to fully settle + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + // Send command with carriage return + ptyProcess.write('/usage\r'); + + // Send another enter after 1 second to confirm selection if autocomplete menu appeared + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\r'); + } + }, 1200); + } + }, 1500); + } + + // Fallback: if we see "Esc to cancel" but haven't seen usage data yet + if ( + !hasSeenUsageData && + cleanOutput.includes('Esc to cancel') && + !cleanOutput.includes('Do you want to work in this folder?') + ) { + setTimeout(() => { + if (!settled && ptyProcess && !ptyProcess.killed) { + ptyProcess.write('\x1b'); // Send escape key + } + }, 5000); + } + }); + + ptyProcess.onExit(({ exitCode }: { exitCode: number }) => { + clearTimeout(timeoutId); + if (settled) return; + settled = true; + + // Check for auth errors - must be specific to avoid false positives + // Removed permission_error check as it was causing false positives with winpty encoding + if (output.includes('token_expired') || output.includes('"type":"authentication_error"')) { + reject(new Error("Authentication required - please run 'claude login'")); + return; + } + + if (output.trim()) { + resolve(output); + } else if (exitCode !== 0) { + reject(new Error(`Command exited with code ${exitCode}`)); + } else { + reject(new Error('No output from claude command')); + } + }); + }); + } + + /** + * Strip ANSI escape codes from text + * Handles CSI, OSC, and other common ANSI sequences + */ + private stripAnsiCodes(text: string): string { + // First, convert cursor movement sequences to whitespace to preserve word boundaries. + // The Claude CLI TUI uses ESC[nC (cursor forward) instead of actual spaces between words. + // Without this, "Current week (all models)" becomes "Currentweek(allmodels)" after stripping. + let clean = text + // Cursor forward (CSI n C): replace with n spaces to preserve word separation + .replace(/\x1B\[(\d+)C/g, (_match, n) => ' '.repeat(parseInt(n, 10))) + // Cursor movement (up/down/back/position): replace with newline or nothing + .replace(/\x1B\[\d*[ABD]/g, '') // cursor up (A), down (B), back (D) + .replace(/\x1B\[\d+;\d+[Hf]/g, '\n') // cursor position (H/f) + // Now strip remaining CSI sequences (colors, modes, etc.) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') + // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') + // Other ESC sequences: ESC (letter) + .replace(/\x1B[A-Za-z]/g, '') + // Carriage returns: replace with newline to avoid concatenation + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + // Handle backspaces (\x08) by applying them + // If we encounter a backspace, remove the character before it + while (clean.includes('\x08')) { + clean = clean.replace(/[^\x08]\x08/, ''); + clean = clean.replace(/^\x08+/, ''); + } + + // Explicitly strip known "Synchronized Output" and "Window Title" garbage + // even if ESC is missing (seen in some environments) + clean = clean + .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l + .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL + .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence) + + // Strip remaining non-printable control characters (except newline \n) + // ASCII 0-8, 11-31, 127 + clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ''); + + return clean; + } + + /** + * Parse the Claude CLI output to extract usage information + * + * Expected output format: + * ``` + * Claude Code v1.0.27 + * + * Current session + * ████████████████░░░░ 65% left + * Resets in 2h 15m + * + * Current week (all models) + * ██████████░░░░░░░░░░ 35% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * + * Current week (Opus) + * ████████████████████ 80% left + * Resets Jan 15, 3:30pm (America/Los_Angeles) + * ``` + */ + private parseUsageOutput(rawOutput: string): ClaudeUsage { + const output = this.stripAnsiCodes(rawOutput); + const lines = output + .split('\n') + .map((l) => l.trim()) + .filter((l) => l); + + // Parse session usage + const sessionData = this.parseSection(lines, 'Current session', 'session'); + + // Parse weekly usage (all models) + const weeklyData = this.parseSection(lines, 'Current week (all models)', 'weekly'); + + // Parse Sonnet/Opus usage - try different labels + let sonnetData = this.parseSection(lines, 'Current week (Sonnet only)', 'sonnet'); + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, 'Current week (Sonnet)', 'sonnet'); + } + if (sonnetData.percentage === 0) { + sonnetData = this.parseSection(lines, 'Current week (Opus)', 'sonnet'); + } + + return { + sessionTokensUsed: 0, // Not available from CLI + sessionLimit: 0, // Not available from CLI + sessionPercentage: sessionData.percentage, + sessionResetTime: sessionData.resetTime, + sessionResetText: sessionData.resetText, + + weeklyTokensUsed: 0, // Not available from CLI + weeklyLimit: 0, // Not available from CLI + weeklyPercentage: weeklyData.percentage, + weeklyResetTime: weeklyData.resetTime, + weeklyResetText: weeklyData.resetText, + + sonnetWeeklyTokensUsed: 0, // Not available from CLI + sonnetWeeklyPercentage: sonnetData.percentage, + sonnetResetText: sonnetData.resetText, + + costUsed: null, // Not available from CLI + costLimit: null, + costCurrency: null, + + lastUpdated: new Date().toISOString(), + userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + } + + /** + * Parse a section of the usage output to extract percentage and reset time + */ + private parseSection( + lines: string[], + sectionLabel: string, + type: string + ): { percentage: number; resetTime: string; resetText: string } { + let percentage: number | null = null; + let resetTime = this.getDefaultResetTime(type); + let resetText = ''; + + // Find the LAST occurrence of the section (terminal output has multiple screen refreshes) + let sectionIndex = -1; + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].toLowerCase().includes(sectionLabel.toLowerCase())) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + return { percentage: 0, resetTime, resetText }; + } + + // Look at the lines following the section header (within a window of 5 lines) + const searchWindow = lines.slice(sectionIndex, sectionIndex + 5); + + for (const line of searchWindow) { + // Extract percentage - only take the first match (avoid picking up next section's data) + // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used) + if (percentage === null) { + const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); + if (percentMatch) { + const value = parseInt(percentMatch[1], 10); + const isUsed = percentMatch[2].toLowerCase() === 'used'; + // Convert "left" to "used" percentage (our UI shows % used) + percentage = isUsed ? value : 100 - value; + } + } + + // Extract reset time - only take the first match + if (!resetText && line.toLowerCase().includes('reset')) { + // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes + const match = line.match(/(Resets?.*)$/i); + // If regex fails despite 'includes', likely a complex string issues - verify match before using line + // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage + if (match) { + resetText = match[1]; + } + } + } + + // Parse the reset time if we found one + if (resetText) { + // Clean up resetText: remove percentage info if it was matched on the same line + // e.g. "46%used Resets5:59pm" -> " Resets5:59pm" + resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim(); + + // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm") + resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2'); + + resetTime = this.parseResetTime(resetText, type); + // Strip timezone like "(Asia/Dubai)" from the display text + resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim(); + } + + return { percentage: percentage ?? 0, resetTime, resetText }; + } + + /** + * Parse reset time from text like "Resets in 2h 15m", "Resets 11am", or "Resets Dec 22 at 8pm" + */ + private parseResetTime(text: string, type: string): string { + const now = new Date(); + + // Try to parse duration format: "Resets in 2h 15m" or "Resets in 30m" + const durationMatch = text.match( + /(\d+)\s*h(?:ours?)?(?:\s+(\d+)\s*m(?:in)?)?|(\d+)\s*m(?:in)?/i + ); + if (durationMatch) { + let hours = 0; + let minutes = 0; + + if (durationMatch[1]) { + hours = parseInt(durationMatch[1], 10); + minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; + } else if (durationMatch[3]) { + minutes = parseInt(durationMatch[3], 10); + } + + const resetDate = new Date(now.getTime() + (hours * 60 + minutes) * 60 * 1000); + return resetDate.toISOString(); + } + + // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" + const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + if (simpleTimeMatch) { + let hours = parseInt(simpleTimeMatch[1], 10); + const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; + const ampm = simpleTimeMatch[3].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === 'pm' && hours !== 12) { + hours += 12; + } else if (ampm === 'am' && hours === 12) { + hours = 0; + } + + // Create date for today at specified time + const resetDate = new Date(now); + resetDate.setHours(hours, minutes, 0, 0); + + // If time has passed, use tomorrow + if (resetDate <= now) { + resetDate.setDate(resetDate.getDate() + 1); + } + return resetDate.toISOString(); + } + + // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + // The regex explicitly matches only valid 3-letter month abbreviations to avoid + // matching words like "Resets" when there's no space separator. + // Optional "resets\s*" prefix handles cases with or without space after "Resets" + const dateMatch = text.match( + /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + ); + if (dateMatch) { + const monthName = dateMatch[1]; + const day = parseInt(dateMatch[2], 10); + let hours = parseInt(dateMatch[3], 10); + const minutes = dateMatch[4] ? parseInt(dateMatch[4], 10) : 0; + const ampm = dateMatch[5].toLowerCase(); + + // Convert 12-hour to 24-hour + if (ampm === 'pm' && hours !== 12) { + hours += 12; + } else if (ampm === 'am' && hours === 12) { + hours = 0; + } + + // Parse month name + const months: Record = { + jan: 0, + feb: 1, + mar: 2, + apr: 3, + may: 4, + jun: 5, + jul: 6, + aug: 7, + sep: 8, + oct: 9, + nov: 10, + dec: 11, + }; + const month = months[monthName.toLowerCase().substring(0, 3)]; + + if (month !== undefined) { + let year = now.getFullYear(); + // If the date appears to be in the past, assume next year + const resetDate = new Date(year, month, day, hours, minutes); + if (resetDate < now) { + resetDate.setFullYear(year + 1); + } + return resetDate.toISOString(); + } + } + + // Fallback to default + return this.getDefaultResetTime(type); + } + + /** + * Get default reset time based on usage type + */ + private getDefaultResetTime(type: string): string { + const now = new Date(); + + if (type === 'session') { + // Session resets in ~5 hours + return new Date(now.getTime() + 5 * 60 * 60 * 1000).toISOString(); + } else { + // Weekly resets on next Monday around noon + const result = new Date(now); + const currentDay = now.getDay(); + let daysUntilMonday = (1 + 7 - currentDay) % 7; + if (daysUntilMonday === 0) daysUntilMonday = 7; + result.setDate(result.getDate() + daysUntilMonday); + result.setHours(12, 59, 0, 0); + return result.toISOString(); + } + } +} diff --git a/jules_branch/apps/server/src/services/codex-app-server-service.ts b/jules_branch/apps/server/src/services/codex-app-server-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecfb99da19055e91b5f9df9a365ea931ebb31e81 --- /dev/null +++ b/jules_branch/apps/server/src/services/codex-app-server-service.ts @@ -0,0 +1,212 @@ +import { spawn, type ChildProcess } from 'child_process'; +import readline from 'readline'; +import { findCodexCliPath } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import type { + AppServerModelResponse, + AppServerAccountResponse, + AppServerRateLimitsResponse, + JsonRpcRequest, +} from '@automaker/types'; + +const logger = createLogger('CodexAppServer'); + +/** + * CodexAppServerService + * + * Centralized service for communicating with Codex CLI's app-server via JSON-RPC protocol. + * Handles process spawning, JSON-RPC messaging, and cleanup. + * + * Connection strategy: Spawn on-demand (new process for each method call) + */ +export class CodexAppServerService { + private cachedCliPath: string | null = null; + + /** + * Check if Codex CLI is available on the system + */ + async isAvailable(): Promise { + this.cachedCliPath = await findCodexCliPath(); + return Boolean(this.cachedCliPath); + } + + /** + * Fetch available models from app-server + */ + async getModels(): Promise { + const result = await this.executeJsonRpc((sendRequest) => { + return sendRequest('model/list', {}); + }); + + if (result) { + logger.info(`[getModels] ✓ Fetched ${result.data.length} models`); + } + + return result; + } + + /** + * Fetch account information from app-server + */ + async getAccount(): Promise { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/read', { refreshToken: false }); + }); + } + + /** + * Fetch rate limits from app-server + */ + async getRateLimits(): Promise { + return this.executeJsonRpc((sendRequest) => { + return sendRequest('account/rateLimits/read', {}); + }); + } + + /** + * Execute JSON-RPC requests via Codex app-server + * + * This method: + * 1. Spawns a new `codex app-server` process + * 2. Handles JSON-RPC initialization handshake + * 3. Executes user-provided requests + * 4. Cleans up the process + * + * @param requestFn - Function that receives sendRequest helper and returns a promise + * @returns Result of the JSON-RPC request or null on failure + */ + private async executeJsonRpc( + requestFn: (sendRequest: (method: string, params?: unknown) => Promise) => Promise + ): Promise { + let childProcess: ChildProcess | null = null; + + try { + const cliPath = this.cachedCliPath || (await findCodexCliPath()); + + if (!cliPath) { + return null; + } + + // On Windows, .cmd files must be run through shell + const needsShell = process.platform === 'win32' && cliPath.toLowerCase().endsWith('.cmd'); + + childProcess = spawn(cliPath, ['app-server'], { + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'dumb', + }, + stdio: ['pipe', 'pipe', 'pipe'], + shell: needsShell, + }); + + if (!childProcess.stdin || !childProcess.stdout) { + throw new Error('Failed to create stdio pipes'); + } + + // Setup readline for reading JSONL responses + const rl = readline.createInterface({ + input: childProcess.stdout, + crlfDelay: Infinity, + }); + + // Message ID counter for JSON-RPC + let messageId = 0; + const pendingRequests = new Map< + number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timeout: NodeJS.Timeout; + } + >(); + + // Process incoming messages + rl.on('line', (line) => { + if (!line.trim()) return; + + try { + const message = JSON.parse(line); + + // Handle response to our request + if ('id' in message && message.id !== undefined) { + const pending = pendingRequests.get(message.id); + if (pending) { + clearTimeout(pending.timeout); + pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(message.error.message || 'Unknown error')); + } else { + pending.resolve(message.result); + } + } + } + // Ignore notifications (no id field) + } catch { + // Ignore parse errors for non-JSON lines + } + }); + + // Helper to send JSON-RPC request and wait for response + const sendRequest = (method: string, params?: unknown): Promise => { + return new Promise((resolve, reject) => { + const id = ++messageId; + const request: JsonRpcRequest = { + method, + id, + params: params ?? {}, + }; + + // Set timeout for request (10 seconds) + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`Request timeout: ${method}`)); + }, 10000); + + pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeout, + }); + + childProcess!.stdin!.write(JSON.stringify(request) + '\n'); + }); + }; + + // Helper to send notification (no response expected) + const sendNotification = (method: string, params?: unknown): void => { + const notification = params ? { method, params } : { method }; + childProcess!.stdin!.write(JSON.stringify(notification) + '\n'); + }; + + // 1. Initialize the app-server + await sendRequest('initialize', { + clientInfo: { + name: 'automaker', + title: 'AutoMaker', + version: '1.0.0', + }, + }); + + // 2. Send initialized notification + sendNotification('initialized'); + + // 3. Execute user-provided requests + const result = await requestFn(sendRequest); + + // Clean up + rl.close(); + childProcess.kill('SIGTERM'); + + return result; + } catch (error) { + logger.error('[executeJsonRpc] Failed:', error); + return null; + } finally { + // Ensure process is killed + if (childProcess && !childProcess.killed) { + childProcess.kill('SIGTERM'); + } + } + } +} diff --git a/jules_branch/apps/server/src/services/codex-model-cache-service.ts b/jules_branch/apps/server/src/services/codex-model-cache-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c9054036783cce981bf66a430786d243edea4b6 --- /dev/null +++ b/jules_branch/apps/server/src/services/codex-model-cache-service.ts @@ -0,0 +1,262 @@ +import path from 'path'; +import { secureFs } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import type { AppServerModel } from '@automaker/types'; +import type { CodexAppServerService } from './codex-app-server-service.js'; + +const logger = createLogger('CodexModelCache'); + +/** + * Codex model with UI-compatible format + */ +export interface CodexModel { + id: string; + label: string; + description: string; + hasThinking: boolean; + supportsVision: boolean; + tier: 'premium' | 'standard' | 'basic'; + isDefault: boolean; +} + +/** + * Cache structure stored on disk + */ +interface CodexModelCache { + models: CodexModel[]; + cachedAt: number; + ttl: number; +} + +/** + * CodexModelCacheService + * + * Caches Codex models fetched from app-server with TTL-based invalidation and disk persistence. + * + * Features: + * - 1-hour TTL (configurable) + * - Atomic file writes (temp file + rename) + * - Thread-safe (deduplicates concurrent refresh requests) + * - Auto-bootstrap on service creation + * - Graceful fallback (returns empty array on errors) + */ +export class CodexModelCacheService { + private cacheFilePath: string; + private ttl: number; + private appServerService: CodexAppServerService; + private inFlightRefresh: Promise | null = null; + + constructor( + dataDir: string, + appServerService: CodexAppServerService, + ttl: number = 3600000 // 1 hour default + ) { + this.cacheFilePath = path.join(dataDir, 'codex-models-cache.json'); + this.ttl = ttl; + this.appServerService = appServerService; + } + + /** + * Get models from cache or fetch if stale + * + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Array of Codex models (empty array if unavailable) + */ + async getModels(forceRefresh = false): Promise { + // If force refresh, skip cache + if (forceRefresh) { + return this.refreshModels(); + } + + // Try to load from cache + const cached = await this.loadFromCache(); + if (cached) { + const age = Date.now() - cached.cachedAt; + const isStale = age > cached.ttl; + + if (!isStale) { + logger.info( + `[getModels] ✓ Using cached models (${cached.models.length} models, age: ${Math.round(age / 60000)}min)` + ); + return cached.models; + } + } + + // Cache is stale or missing, refresh + return this.refreshModels(); + } + + /** + * Get models with cache metadata + * + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Object containing models and cache timestamp + */ + async getModelsWithMetadata( + forceRefresh = false + ): Promise<{ models: CodexModel[]; cachedAt: number }> { + const models = await this.getModels(forceRefresh); + + // Try to get the actual cache timestamp + const cached = await this.loadFromCache(); + const cachedAt = cached?.cachedAt ?? Date.now(); + + return { models, cachedAt }; + } + + /** + * Refresh models from app-server and update cache + * + * Thread-safe: Deduplicates concurrent refresh requests + */ + async refreshModels(): Promise { + // Deduplicate concurrent refresh requests + if (this.inFlightRefresh) { + return this.inFlightRefresh; + } + + // Start new refresh + this.inFlightRefresh = this.doRefresh(); + + try { + const models = await this.inFlightRefresh; + return models; + } finally { + this.inFlightRefresh = null; + } + } + + /** + * Clear the cache file + */ + async clearCache(): Promise { + logger.info('[clearCache] Clearing cache...'); + + try { + await secureFs.unlink(this.cacheFilePath); + logger.info('[clearCache] Cache cleared'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('[clearCache] Failed to clear cache:', error); + } + } + } + + /** + * Internal method to perform the actual refresh + */ + private async doRefresh(): Promise { + try { + // Check if app-server is available + const isAvailable = await this.appServerService.isAvailable(); + if (!isAvailable) { + return []; + } + + // Fetch models from app-server + const response = await this.appServerService.getModels(); + if (!response || !response.data) { + return []; + } + + // Transform models to UI format + const models = response.data.map((model) => this.transformModel(model)); + + // Save to cache + await this.saveToCache(models); + + logger.info(`[refreshModels] ✓ Fetched fresh models (${models.length} models)`); + + return models; + } catch (error) { + logger.error('[doRefresh] Refresh failed:', error); + return []; + } + } + + /** + * Transform app-server model to UI-compatible format + */ + private transformModel(appServerModel: AppServerModel): CodexModel { + return { + id: `codex-${appServerModel.id}`, // Add 'codex-' prefix for compatibility + label: appServerModel.displayName, + description: appServerModel.description, + hasThinking: appServerModel.supportedReasoningEfforts.length > 0, + supportsVision: true, // All Codex models support vision + tier: this.inferTier(appServerModel.id), + isDefault: appServerModel.isDefault, + }; + } + + /** + * Infer tier from model ID + */ + private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { + if ( + modelId.includes('max') || + modelId.includes('gpt-5.2-codex') || + modelId.includes('gpt-5.3-codex') + ) { + return 'premium'; + } + if (modelId.includes('mini')) { + return 'basic'; + } + return 'standard'; + } + + /** + * Load cache from disk + */ + private async loadFromCache(): Promise { + try { + const content = await secureFs.readFile(this.cacheFilePath, 'utf-8'); + const cache = JSON.parse(content.toString()) as CodexModelCache; + + // Validate cache structure + if (!Array.isArray(cache.models) || typeof cache.cachedAt !== 'number') { + logger.warn('[loadFromCache] Invalid cache structure, ignoring'); + return null; + } + + return cache; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.warn('[loadFromCache] Failed to read cache:', error); + } + return null; + } + } + + /** + * Save cache to disk (atomic write) + */ + private async saveToCache(models: CodexModel[]): Promise { + const cache: CodexModelCache = { + models, + cachedAt: Date.now(), + ttl: this.ttl, + }; + + const tempPath = `${this.cacheFilePath}.tmp.${Date.now()}`; + + try { + // Write to temp file + const content = JSON.stringify(cache, null, 2); + await secureFs.writeFile(tempPath, content, 'utf-8'); + + // Atomic rename + await secureFs.rename(tempPath, this.cacheFilePath); + } catch (error) { + logger.error('[saveToCache] Failed to save cache:', error); + + // Clean up temp file + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + } + } +} diff --git a/jules_branch/apps/server/src/services/codex-usage-service.ts b/jules_branch/apps/server/src/services/codex-usage-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e18d508e3c2d3c365fdaacaa9c1237b219f7e667 --- /dev/null +++ b/jules_branch/apps/server/src/services/codex-usage-service.ts @@ -0,0 +1,348 @@ +import { + findCodexCliPath, + getCodexAuthPath, + systemPathExists, + systemPathReadFile, +} from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; +import type { CodexAppServerService } from './codex-app-server-service.js'; + +const logger = createLogger('CodexUsage'); + +export interface CodexRateLimitWindow { + limit: number; + used: number; + remaining: number; + usedPercent: number; + windowDurationMins: number; + resetsAt: number; +} + +export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown'; + +export interface CodexUsageData { + rateLimits: { + primary?: CodexRateLimitWindow; + secondary?: CodexRateLimitWindow; + planType?: CodexPlanType; + } | null; + lastUpdated: string; +} + +/** + * Codex Usage Service + * + * Fetches usage data from Codex CLI using the app-server JSON-RPC API. + * Falls back to auth file parsing if app-server is unavailable. + */ +export class CodexUsageService { + private cachedCliPath: string | null = null; + private appServerService: CodexAppServerService | null = null; + private accountPlanTypeArray: CodexPlanType[] = [ + 'free', + 'plus', + 'pro', + 'team', + 'enterprise', + 'edu', + ]; + + constructor(appServerService?: CodexAppServerService) { + this.appServerService = appServerService || null; + } + + /** + * Check if Codex CLI is available on the system + */ + async isAvailable(): Promise { + this.cachedCliPath = await findCodexCliPath(); + return Boolean(this.cachedCliPath); + } + + /** + * Attempt to fetch usage data + * + * Priority order: + * 1. Codex app-server JSON-RPC API (most reliable, provides real-time data) + * 2. Auth file JWT parsing (fallback for plan type) + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + const cliPath = this.cachedCliPath || (await findCodexCliPath()); + + if (!cliPath) { + logger.error('[fetchUsageData] Codex CLI not found'); + throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex'); + } + + logger.info(`[fetchUsageData] Using CLI path: ${cliPath}`); + + // Try to get usage from Codex app-server (most reliable method) + const appServerUsage = await this.fetchFromAppServer(); + if (appServerUsage) { + logger.info('[fetchUsageData] ✓ Fetched usage from app-server'); + return appServerUsage; + } + + logger.info('[fetchUsageData] App-server failed, trying auth file fallback...'); + + // Fallback: try to parse usage from auth file + const authUsage = await this.fetchFromAuthFile(); + if (authUsage) { + logger.info('[fetchUsageData] ✓ Fetched usage from auth file'); + return authUsage; + } + + logger.info('[fetchUsageData] All methods failed, returning unknown'); + + // If all else fails, return unknown + return { + rateLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + } + + /** + * Fetch usage data from Codex app-server using JSON-RPC API + * This is the most reliable method as it gets real-time data from OpenAI + */ + private async fetchFromAppServer(): Promise { + try { + // Use CodexAppServerService if available + if (!this.appServerService) { + return null; + } + + // Fetch account and rate limits in parallel + const [accountResult, rateLimitsResult] = await Promise.all([ + this.appServerService.getAccount(), + this.appServerService.getRateLimits(), + ]); + + if (!accountResult) { + return null; + } + + // Build response + // Prefer planType from rateLimits (more accurate/current) over account (can be stale) + let planType: CodexPlanType = 'unknown'; + + // First try rate limits planType (most accurate) + const rateLimitsPlanType = rateLimitsResult?.rateLimits?.planType; + if (rateLimitsPlanType) { + const normalizedType = rateLimitsPlanType.toLowerCase() as CodexPlanType; + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + // Fall back to account planType if rate limits didn't have it + if (planType === 'unknown' && accountResult.account?.planType) { + const normalizedType = accountResult.account.planType.toLowerCase() as CodexPlanType; + if (this.accountPlanTypeArray.includes(normalizedType)) { + planType = normalizedType; + } + } + + const result: CodexUsageData = { + rateLimits: { + planType, + }, + lastUpdated: new Date().toISOString(), + }; + + // Add rate limit info if available + if (rateLimitsResult?.rateLimits?.primary) { + const primary = rateLimitsResult.rateLimits.primary; + result.rateLimits!.primary = { + limit: -1, // Not provided by API + used: -1, // Not provided by API + remaining: -1, // Not provided by API + usedPercent: primary.usedPercent, + windowDurationMins: primary.windowDurationMins, + resetsAt: primary.resetsAt, + }; + } + + // Add secondary rate limit if available + if (rateLimitsResult?.rateLimits?.secondary) { + const secondary = rateLimitsResult.rateLimits.secondary; + result.rateLimits!.secondary = { + limit: -1, // Not provided by API + used: -1, // Not provided by API + remaining: -1, // Not provided by API + usedPercent: secondary.usedPercent, + windowDurationMins: secondary.windowDurationMins, + resetsAt: secondary.resetsAt, + }; + } + + logger.info( + `[fetchFromAppServer] ✓ Plan: ${planType}, Primary: ${result.rateLimits?.primary?.usedPercent || 'N/A'}%, Secondary: ${result.rateLimits?.secondary?.usedPercent || 'N/A'}%` + ); + return result; + } catch (error) { + logger.error('[fetchFromAppServer] Failed:', error); + return null; + } + } + + /** + * Extract plan type from auth file JWT token + * Returns the actual plan type or 'unknown' if not available + */ + private async getPlanTypeFromAuthFile(): Promise { + try { + const authFilePath = getCodexAuthPath(); + logger.info(`[getPlanTypeFromAuthFile] Auth file path: ${authFilePath}`); + const exists = systemPathExists(authFilePath); + + if (!exists) { + logger.warn('[getPlanTypeFromAuthFile] Auth file does not exist'); + return 'unknown'; + } + + const authContent = await systemPathReadFile(authFilePath); + const authData = JSON.parse(authContent); + + if (!authData.tokens?.id_token) { + logger.info('[getPlanTypeFromAuthFile] No id_token in auth file'); + return 'unknown'; + } + + const claims = this.parseJwt(authData.tokens.id_token); + if (!claims) { + logger.info('[getPlanTypeFromAuthFile] Failed to parse JWT'); + return 'unknown'; + } + + logger.info('[getPlanTypeFromAuthFile] JWT claims keys:', Object.keys(claims)); + + // Extract plan type from nested OpenAI auth object with type validation + const openaiAuthClaim = claims['https://api.openai.com/auth']; + logger.info( + '[getPlanTypeFromAuthFile] OpenAI auth claim:', + JSON.stringify(openaiAuthClaim, null, 2) + ); + + let accountType: string | undefined; + let isSubscriptionExpired = false; + + if ( + openaiAuthClaim && + typeof openaiAuthClaim === 'object' && + !Array.isArray(openaiAuthClaim) + ) { + const openaiAuth = openaiAuthClaim as Record; + + if (typeof openaiAuth.chatgpt_plan_type === 'string') { + accountType = openaiAuth.chatgpt_plan_type; + } + + // Check if subscription has expired + if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') { + const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until); + if (!isNaN(expiryDate.getTime())) { + isSubscriptionExpired = expiryDate < new Date(); + } + } + } else { + // Fallback: try top-level claim names + const possibleClaimNames = [ + 'https://chatgpt.com/account_type', + 'account_type', + 'plan', + 'plan_type', + ]; + + for (const claimName of possibleClaimNames) { + const claimValue = claims[claimName]; + if (claimValue && typeof claimValue === 'string') { + accountType = claimValue; + break; + } + } + } + + // If subscription is expired, treat as free plan + if (isSubscriptionExpired && accountType && accountType !== 'free') { + logger.info(`Subscription expired, using "free" instead of "${accountType}"`); + accountType = 'free'; + } + + if (accountType) { + const normalizedType = accountType.toLowerCase() as CodexPlanType; + logger.info( + `[getPlanTypeFromAuthFile] Account type: "${accountType}", normalized: "${normalizedType}"` + ); + if (this.accountPlanTypeArray.includes(normalizedType)) { + logger.info(`[getPlanTypeFromAuthFile] Returning plan type: ${normalizedType}`); + return normalizedType; + } + } else { + logger.info('[getPlanTypeFromAuthFile] No account type found in claims'); + } + } catch (error) { + logger.error('[getPlanTypeFromAuthFile] Failed to get plan type from auth file:', error); + } + + logger.info('[getPlanTypeFromAuthFile] Returning unknown'); + return 'unknown'; + } + + /** + * Try to extract usage info from the Codex auth file + * Reuses getPlanTypeFromAuthFile to avoid code duplication + */ + private async fetchFromAuthFile(): Promise { + logger.info('[fetchFromAuthFile] Starting...'); + try { + const planType = await this.getPlanTypeFromAuthFile(); + logger.info(`[fetchFromAuthFile] Got plan type: ${planType}`); + + if (planType === 'unknown') { + logger.info('[fetchFromAuthFile] Plan type unknown, returning null'); + return null; + } + + const result: CodexUsageData = { + rateLimits: { + planType, + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[fetchFromAuthFile] Returning result:', JSON.stringify(result, null, 2)); + return result; + } catch (error) { + logger.error('[fetchFromAuthFile] Failed to parse auth file:', error); + } + + return null; + } + + /** + * Parse JWT token to extract claims + */ + private parseJwt(token: string): Record | null { + try { + const parts = token.split('.'); + + if (parts.length !== 3) { + return null; + } + + const base64Url = parts[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + + // Use Buffer for Node.js environment + const jsonPayload = Buffer.from(base64, 'base64').toString('utf-8'); + + return JSON.parse(jsonPayload); + } catch { + return null; + } + } +} diff --git a/jules_branch/apps/server/src/services/commit-log-service.ts b/jules_branch/apps/server/src/services/commit-log-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..14cb21d0b65fcd8058ebd89c0d0d7510213a31d9 --- /dev/null +++ b/jules_branch/apps/server/src/services/commit-log-service.ts @@ -0,0 +1,161 @@ +/** + * Service for fetching commit log data from a worktree. + * + * Extracts the heavy Git command execution and parsing logic from the + * commit-log route handler so the handler only validates input, + * invokes this service, streams lifecycle events, and sends the response. + * + * Follows the same approach as branch-commit-log-service: a single + * `git log --name-only` call with custom separators to fetch both + * commit metadata and file lists, avoiding N+1 git invocations. + */ + +import { execGitCommand } from '../lib/git.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface CommitLogEntry { + hash: string; + shortHash: string; + author: string; + authorEmail: string; + date: string; + subject: string; + body: string; + files: string[]; +} + +export interface CommitLogResult { + branch: string; + commits: CommitLogEntry[]; + total: number; +} + +// ============================================================================ +// Service +// ============================================================================ + +/** + * Fetch the commit log for a worktree (HEAD). + * + * Runs a single `git log --name-only` invocation plus `git rev-parse` + * inside the given worktree path and returns a structured result. + * + * @param worktreePath - Absolute path to the worktree / repository + * @param limit - Maximum number of commits to return (clamped 1-100) + */ +export async function getCommitLog(worktreePath: string, limit: number): Promise { + // Clamp limit to a reasonable range + const parsedLimit = Number(limit); + const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100); + + // Use custom separators to parse both metadata and file lists from + // a single git log invocation (same approach as branch-commit-log-service). + // + // -m causes merge commits to be diffed against each parent so all + // files touched by the merge are listed (without -m, --name-only + // produces no file output for merge commits because they have 2+ parents). + // This means merge commits appear multiple times in the output (once per + // parent), so we deduplicate by hash below and merge their file lists. + // We over-fetch (2x the limit) to compensate for -m duplicating merge + // commit entries, then trim the result to the requested limit. + // Use ASCII control characters as record separators – these cannot appear in + // git commit messages, so these delimiters are safe regardless of commit + // body content. %x00 and %x01 in git's format string emit literal NUL / + // SOH bytes respectively. + // + // COMMIT_SEP (\x00) – marks the start of each commit record. + // META_END (\x01) – separates commit metadata from the --name-only file list. + // + // Full per-commit layout emitted by git: + // \x00\n\n\n...\n\n\x01 + const COMMIT_SEP = '\x00'; + const META_END = '\x01'; + const fetchLimit = commitLimit * 2; + + const logOutput = await execGitCommand( + [ + 'log', + `--max-count=${fetchLimit}`, + '-m', + '--name-only', + `--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`, + ], + worktreePath + ); + + // Split output into per-commit blocks and drop the empty first chunk + // (the output starts with a NUL commit separator). + const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim()); + + // Use a Map to deduplicate merge commit entries (which appear once per + // parent when -m is used) while preserving insertion order. + const commitMap = new Map(); + + for (const block of commitBlocks) { + const metaEndIdx = block.indexOf(META_END); + if (metaEndIdx === -1) continue; // malformed block, skip + + // --- Parse metadata (everything before the META_END delimiter) --- + const metaRaw = block.substring(0, metaEndIdx); + const metaLines = metaRaw.split('\n'); + + // The first line may be empty (newline right after COMMIT_SEP), skip it + const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== ''); + if (nonEmptyStart === -1) continue; + + const fields = metaLines.slice(nonEmptyStart); + if (fields.length < 6) continue; // need at least hash..subject + + const hash = fields[0].trim(); + if (!hash) continue; // defensive: skip if hash is empty + const shortHash = fields[1]?.trim() ?? ''; + const author = fields[2]?.trim() ?? ''; + const authorEmail = fields[3]?.trim() ?? ''; + const date = fields[4]?.trim() ?? ''; + const subject = fields[5]?.trim() ?? ''; + const body = fields.slice(6).join('\n').trim(); + + // --- Parse file list (everything after the META_END delimiter) --- + const filesRaw = block.substring(metaEndIdx + META_END.length); + const blockFiles = filesRaw + .trim() + .split('\n') + .filter((f) => f.trim()); + + // Merge file lists for duplicate entries (merge commits with -m) + const existing = commitMap.get(hash); + if (existing) { + // Add new files to the existing entry's file set + const fileSet = new Set(existing.files); + for (const f of blockFiles) fileSet.add(f); + existing.files = [...fileSet]; + } else { + commitMap.set(hash, { + hash, + shortHash, + author, + authorEmail, + date, + subject, + body, + files: [...new Set(blockFiles)], + }); + } + } + + // Trim to the requested limit (we over-fetched to account for -m duplicates) + const commits = [...commitMap.values()].slice(0, commitLimit); + + // Get current branch name + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const branch = branchOutput.trim(); + + return { + branch, + commits, + total: commits.length, + }; +} diff --git a/jules_branch/apps/server/src/services/concurrency-manager.ts b/jules_branch/apps/server/src/services/concurrency-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..b64456a172428110c99f247ebaef7e71d526313a --- /dev/null +++ b/jules_branch/apps/server/src/services/concurrency-manager.ts @@ -0,0 +1,272 @@ +/** + * ConcurrencyManager - Manages running feature slots with lease-based reference counting + * + * Extracted from AutoModeService to provide a standalone service for tracking + * running feature execution with proper lease counting to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * Key behaviors: + * - acquire() with existing entry + allowReuse: increment leaseCount, return existing + * - acquire() with existing entry + no allowReuse: throw Error('already running') + * - release() decrements leaseCount, only deletes at 0 + * - release() with force:true bypasses leaseCount check + */ + +import type { ModelProvider } from '@automaker/types'; + +/** + * Function type for getting the current branch of a project. + * Injected to allow for testing and decoupling from git operations. + */ +export type GetCurrentBranchFn = (projectPath: string) => Promise; + +/** + * Represents a running feature execution with all tracking metadata + */ +export interface RunningFeature { + featureId: string; + projectPath: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + isAutoMode: boolean; + startTime: number; + leaseCount: number; + model?: string; + provider?: ModelProvider; +} + +/** + * Parameters for acquiring a running feature slot + */ +export interface AcquireParams { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + abortController?: AbortController; +} + +/** + * ConcurrencyManager manages the running features Map with lease-based reference counting. + * + * This supports nested execution patterns where a feature may be acquired multiple times + * (e.g., during resume operations) and should only be released when all references are done. + */ +export class ConcurrencyManager { + private runningFeatures = new Map(); + private getCurrentBranch: GetCurrentBranchFn; + + /** + * @param getCurrentBranch - Function to get the current branch for a project. + * If not provided, defaults to returning 'main'. + */ + constructor(getCurrentBranch?: GetCurrentBranchFn) { + this.getCurrentBranch = getCurrentBranch ?? (() => Promise.resolve('main')); + } + + /** + * Acquire a slot in the runningFeatures map for a feature. + * Implements reference counting via leaseCount to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * @param params.featureId - ID of the feature to track + * @param params.projectPath - Path to the project + * @param params.isAutoMode - Whether this is an auto-mode execution + * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features + * @param params.abortController - Optional abort controller to use + * @returns The RunningFeature entry (existing or newly created) + * @throws Error if feature is already running and allowReuse is false + */ + acquire(params: AcquireParams): RunningFeature { + const existing = this.runningFeatures.get(params.featureId); + if (existing) { + if (!params.allowReuse) { + throw new Error('already running'); + } + existing.leaseCount += 1; + return existing; + } + + const abortController = params.abortController ?? new AbortController(); + const entry: RunningFeature = { + featureId: params.featureId, + projectPath: params.projectPath, + worktreePath: null, + branchName: null, + abortController, + isAutoMode: params.isAutoMode, + startTime: Date.now(), + leaseCount: 1, + }; + this.runningFeatures.set(params.featureId, entry); + return entry; + } + + /** + * Release a slot in the runningFeatures map for a feature. + * Decrements leaseCount and only removes the entry when it reaches zero, + * unless force option is used. + * + * @param featureId - ID of the feature to release + * @param options.force - If true, immediately removes the entry regardless of leaseCount + */ + release(featureId: string, options?: { force?: boolean }): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + if (options?.force) { + this.runningFeatures.delete(featureId); + return; + } + + entry.leaseCount -= 1; + if (entry.leaseCount <= 0) { + this.runningFeatures.delete(featureId); + } + } + + /** + * Check if a feature is currently running + * + * @param featureId - ID of the feature to check + * @returns true if the feature is in the runningFeatures map + */ + isRunning(featureId: string): boolean { + return this.runningFeatures.has(featureId); + } + + /** + * Get the RunningFeature entry for a feature + * + * @param featureId - ID of the feature + * @returns The RunningFeature entry or undefined if not running + */ + getRunningFeature(featureId: string): RunningFeature | undefined { + return this.runningFeatures.get(featureId); + } + + /** + * Get count of running features for a specific project + * + * @param projectPath - The project path to count features for + * @returns Number of running features for the project + */ + getRunningCount(projectPath: string): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath === projectPath) { + count++; + } + } + return count; + } + + /** + * Get count of running features for a specific worktree + * + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * (features without branchName or matching primary branch) + * @param options.autoModeOnly - If true, only count features started by auto mode. + * Note: The auto-loop coordinator now counts ALL + * running features (not just auto-mode) to ensure + * total system load is respected. This option is + * retained for other callers that may need filtered counts. + * @returns Number of running features for the worktree + */ + async getRunningCountForWorktree( + projectPath: string, + branchName: string | null, + options?: { autoModeOnly?: boolean } + ): Promise { + // Get the actual primary branch name for the project + const primaryBranch = await this.getCurrentBranch(projectPath); + + let count = 0; + for (const [, feature] of this.runningFeatures) { + // If autoModeOnly is set, skip manually started features + if (options?.autoModeOnly && !feature.isAutoMode) { + continue; + } + + // Filter by project path AND branchName to get accurate worktree-specific count + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: match features with branchName === null OR branchName matching primary branch + const isPrimaryBranch = + featureBranch === null || (primaryBranch && featureBranch === primaryBranch); + if (feature.projectPath === projectPath && isPrimaryBranch) { + count++; + } + } else { + // Feature worktree: exact match + if (feature.projectPath === projectPath && featureBranch === branchName) { + count++; + } + } + } + return count; + } + + /** + * Get all currently running features + * + * @returns Array of all RunningFeature entries + */ + getAllRunning(): RunningFeature[] { + return Array.from(this.runningFeatures.values()); + } + + /** + * Get running feature IDs for a specific worktree, with proper primary branch normalization. + * + * When branchName is null (main worktree), matches features with branchName === null + * OR branchName matching the primary branch (e.g., "main", "master"). + * + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * @returns Array of feature IDs running in the specified worktree + */ + async getRunningFeaturesForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + const primaryBranch = await this.getCurrentBranch(projectPath); + const featureIds: string[] = []; + + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath !== projectPath) continue; + const featureBranch = feature.branchName ?? null; + + if (branchName === null) { + // Main worktree: match features with null branchName OR primary branch name + const isPrimaryBranch = + featureBranch === null || (primaryBranch && featureBranch === primaryBranch); + if (isPrimaryBranch) featureIds.push(feature.featureId); + } else { + // Feature worktree: exact match + if (featureBranch === branchName) featureIds.push(feature.featureId); + } + } + + return featureIds; + } + + /** + * Update properties of a running feature + * + * @param featureId - ID of the feature to update + * @param updates - Partial RunningFeature properties to update + */ + updateRunningFeature(featureId: string, updates: Partial): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + Object.assign(entry, updates); + } +} diff --git a/jules_branch/apps/server/src/services/copilot-connection-service.ts b/jules_branch/apps/server/src/services/copilot-connection-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb1e4324f529ae07e3a75f5bae9eb7ba9ff0862 --- /dev/null +++ b/jules_branch/apps/server/src/services/copilot-connection-service.ts @@ -0,0 +1,80 @@ +/** + * Copilot Connection Service + * + * Handles the connection and disconnection of Copilot CLI to the app. + * Uses a marker file to track the disconnected state. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { createLogger } from '@automaker/utils'; +import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js'; + +const logger = createLogger('CopilotConnectionService'); + +/** + * Get the path to the disconnected marker file + */ +function getMarkerPath(projectRoot?: string): string { + const root = projectRoot || process.cwd(); + const automakerDir = path.join(root, '.automaker'); + return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE); +} + +/** + * Connect Copilot CLI to the app by removing the disconnected marker + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves when the connection is established + */ +export async function connectCopilot(projectRoot?: string): Promise { + const markerPath = getMarkerPath(projectRoot); + + try { + await fs.unlink(markerPath); + logger.info('Copilot CLI connected to app (marker removed)'); + } catch (error) { + // File doesn't exist - that's fine, Copilot is already connected + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error('Failed to remove disconnected marker:', error); + throw error; + } + logger.debug('Copilot already connected (no marker file found)'); + } +} + +/** + * Disconnect Copilot CLI from the app by creating the disconnected marker + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves when the disconnection is complete + */ +export async function disconnectCopilot(projectRoot?: string): Promise { + const root = projectRoot || process.cwd(); + const automakerDir = path.join(root, '.automaker'); + const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE); + + // Ensure .automaker directory exists + await fs.mkdir(automakerDir, { recursive: true }); + + // Create the disconnection marker + await fs.writeFile(markerPath, 'Copilot CLI disconnected from app'); + logger.info('Copilot CLI disconnected from app (marker created)'); +} + +/** + * Check if Copilot CLI is connected (not disconnected) + * + * @param projectRoot - Optional project root directory (defaults to cwd) + * @returns Promise that resolves to true if connected, false if disconnected + */ +export async function isCopilotConnected(projectRoot?: string): Promise { + const markerPath = getMarkerPath(projectRoot); + + try { + await fs.access(markerPath); + return false; // Marker exists = disconnected + } catch { + return true; // Marker doesn't exist = connected + } +} diff --git a/jules_branch/apps/server/src/services/cursor-config-service.ts b/jules_branch/apps/server/src/services/cursor-config-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d84252b9b7bee7268359d8aa6092663c4467a7a5 --- /dev/null +++ b/jules_branch/apps/server/src/services/cursor-config-service.ts @@ -0,0 +1,280 @@ +/** + * Cursor Config Service + * + * Manages Cursor CLI permissions configuration files: + * - Global: ~/.cursor/cli-config.json + * - Project: /.cursor/cli.json + * + * Based on: https://cursor.com/docs/cli/reference/configuration + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { createLogger } from '@automaker/utils'; +import type { + CursorCliConfigFile, + CursorCliPermissions, + CursorPermissionProfile, +} from '@automaker/types'; +import { + CURSOR_STRICT_PROFILE, + CURSOR_DEVELOPMENT_PROFILE, + CURSOR_PERMISSION_PROFILES, +} from '@automaker/types'; + +const logger = createLogger('CursorConfigService'); + +/** + * Get the path to the global Cursor CLI config + */ +export function getGlobalConfigPath(): string { + // Windows: $env:USERPROFILE\.cursor\cli-config.json + // macOS/Linux: ~/.cursor/cli-config.json + // XDG_CONFIG_HOME override on Linux: $XDG_CONFIG_HOME/cursor/cli-config.json + const xdgConfig = process.env.XDG_CONFIG_HOME; + const cursorConfigDir = process.env.CURSOR_CONFIG_DIR; + + if (cursorConfigDir) { + return path.join(cursorConfigDir, 'cli-config.json'); + } + + if (process.platform === 'linux' && xdgConfig) { + return path.join(xdgConfig, 'cursor', 'cli-config.json'); + } + + return path.join(os.homedir(), '.cursor', 'cli-config.json'); +} + +/** + * Get the path to a project's Cursor CLI config + */ +export function getProjectConfigPath(projectPath: string): string { + return path.join(projectPath, '.cursor', 'cli.json'); +} + +/** + * Read the global Cursor CLI config + */ +export async function readGlobalConfig(): Promise { + const configPath = getGlobalConfigPath(); + + try { + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content) as CursorCliConfigFile; + logger.debug('Read global Cursor config from:', configPath); + return config; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug('Global Cursor config not found at:', configPath); + return null; + } + logger.error('Failed to read global Cursor config:', error); + throw error; + } +} + +/** + * Write the global Cursor CLI config + */ +export async function writeGlobalConfig(config: CursorCliConfigFile): Promise { + const configPath = getGlobalConfigPath(); + const configDir = path.dirname(configPath); + + // Ensure directory exists + await fs.mkdir(configDir, { recursive: true }); + + // Write config + await fs.writeFile(configPath, JSON.stringify(config, null, 2)); + logger.info('Wrote global Cursor config to:', configPath); +} + +/** + * Read a project's Cursor CLI config + */ +export async function readProjectConfig(projectPath: string): Promise { + const configPath = getProjectConfigPath(projectPath); + + try { + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content) as CursorCliConfigFile; + logger.debug('Read project Cursor config from:', configPath); + return config; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.debug('Project Cursor config not found at:', configPath); + return null; + } + logger.error('Failed to read project Cursor config:', error); + throw error; + } +} + +/** + * Write a project's Cursor CLI config + * + * Note: Project-level config ONLY supports permissions. + * The version field and other settings are global-only. + * See: https://cursor.com/docs/cli/reference/configuration + */ +export async function writeProjectConfig( + projectPath: string, + config: CursorCliConfigFile +): Promise { + const configPath = getProjectConfigPath(projectPath); + const configDir = path.dirname(configPath); + + // Ensure .cursor directory exists + await fs.mkdir(configDir, { recursive: true }); + + // Write config (project config ONLY supports permissions - no version field!) + const projectConfig = { + permissions: config.permissions, + }; + + await fs.writeFile(configPath, JSON.stringify(projectConfig, null, 2)); + logger.info('Wrote project Cursor config to:', configPath); +} + +/** + * Delete a project's Cursor CLI config + */ +export async function deleteProjectConfig(projectPath: string): Promise { + const configPath = getProjectConfigPath(projectPath); + + try { + await fs.unlink(configPath); + logger.info('Deleted project Cursor config:', configPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } +} + +/** + * Get the effective permissions for a project + * Project config takes precedence over global config + */ +export async function getEffectivePermissions( + projectPath?: string +): Promise { + // Try project config first + if (projectPath) { + const projectConfig = await readProjectConfig(projectPath); + if (projectConfig?.permissions) { + return projectConfig.permissions; + } + } + + // Fall back to global config + const globalConfig = await readGlobalConfig(); + return globalConfig?.permissions || null; +} + +/** + * Apply a predefined permission profile to a project + */ +export async function applyProfileToProject( + projectPath: string, + profileId: CursorPermissionProfile +): Promise { + const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId); + + if (!profile) { + throw new Error(`Unknown permission profile: ${profileId}`); + } + + await writeProjectConfig(projectPath, { + version: 1, + permissions: profile.permissions, + }); + + logger.info(`Applied "${profile.name}" profile to project:`, projectPath); +} + +/** + * Apply a predefined permission profile globally + */ +export async function applyProfileGlobally(profileId: CursorPermissionProfile): Promise { + const profile = CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId); + + if (!profile) { + throw new Error(`Unknown permission profile: ${profileId}`); + } + + // Read existing global config to preserve other settings + const existingConfig = await readGlobalConfig(); + + await writeGlobalConfig({ + version: 1, + ...existingConfig, + permissions: profile.permissions, + }); + + logger.info(`Applied "${profile.name}" profile globally`); +} + +/** + * Detect which profile matches the current permissions + */ +export function detectProfile( + permissions: CursorCliPermissions | null +): CursorPermissionProfile | null { + if (!permissions) { + return null; + } + + // Check if permissions match a predefined profile + for (const profile of CURSOR_PERMISSION_PROFILES) { + const allowMatch = + JSON.stringify(profile.permissions.allow.sort()) === JSON.stringify(permissions.allow.sort()); + const denyMatch = + JSON.stringify(profile.permissions.deny.sort()) === JSON.stringify(permissions.deny.sort()); + + if (allowMatch && denyMatch) { + return profile.id; + } + } + + return 'custom'; +} + +/** + * Generate example config file content + */ +export function generateExampleConfig(profileId: CursorPermissionProfile = 'development'): string { + const profile = + CURSOR_PERMISSION_PROFILES.find((p) => p.id === profileId) || CURSOR_DEVELOPMENT_PROFILE; + + const config: CursorCliConfigFile = { + version: 1, + permissions: profile.permissions, + }; + + return JSON.stringify(config, null, 2); +} + +/** + * Check if a project has Cursor CLI config + */ +export async function hasProjectConfig(projectPath: string): Promise { + const configPath = getProjectConfigPath(projectPath); + + try { + await fs.access(configPath); + return true; + } catch { + return false; + } +} + +/** + * Get all available permission profiles + */ +export function getAvailableProfiles() { + return CURSOR_PERMISSION_PROFILES; +} + +// Export profile constants for convenience +export { CURSOR_STRICT_PROFILE, CURSOR_DEVELOPMENT_PROFILE }; diff --git a/jules_branch/apps/server/src/services/dev-server-service.ts b/jules_branch/apps/server/src/services/dev-server-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..319e9895f4b2854793131010bbc26523e908d704 --- /dev/null +++ b/jules_branch/apps/server/src/services/dev-server-service.ts @@ -0,0 +1,1317 @@ +/** + * Dev Server Service + * + * Manages multiple development server processes for git worktrees. + * Each worktree can have its own dev server running on a unique port. + * + * Developers should configure their projects to use the PORT environment variable. + */ + +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as secureFs from '../lib/secure-fs.js'; +import path from 'path'; +import net from 'net'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import fs from 'fs/promises'; +import { constants } from 'fs'; + +const logger = createLogger('DevServerService'); + +// Maximum scrollback buffer size (characters) - matches TerminalService pattern +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per dev server + +// Timeout (ms) before falling back to the allocated port if URL detection hasn't succeeded. +// This handles cases where the dev server output format is not recognized by any pattern. +const URL_DETECTION_TIMEOUT_MS = 30_000; + +// URL patterns for detecting full URLs from dev server output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +// Ordered from most specific (framework-specific) to least specific. +const URL_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // Vite / Nuxt / SvelteKit / Astro / Angular CLI format: "Local: http://..." + { + pattern: /(?:Local|Network|External):\s+(https?:\/\/[^\s]+)/i, + description: 'Vite/Nuxt/SvelteKit/Astro/Angular format', + }, + // Next.js format: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + // Next.js 14+: "▲ Next.js 14.0.0\n- Local: http://localhost:3000" + { + pattern: /(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, + description: 'Next.js format', + }, + // Remix format: "started at http://localhost:3000" + // Django format: "Starting development server at http://127.0.0.1:8000/" + // Rails / Puma: "Listening on http://127.0.0.1:3000" + // Generic: "listening at http://...", "available at http://...", "running at http://..." + { + pattern: + /(?:starting|started|listening|running|available|serving|accessible)\s+(?:at|on)\s+(https?:\/\/[^\s,)]+)/i, + description: 'Generic "starting/started/listening at" format', + }, + // PHP built-in server: "Development Server (http://localhost:8000) started" + { + pattern: /(?:server|development server)\s*\(\s*(https?:\/\/[^\s)]+)\s*\)/i, + description: 'PHP server format', + }, + // Webpack Dev Server: "Project is running at http://localhost:8080/" + { + pattern: /(?:project|app|application)\s+(?:is\s+)?running\s+(?:at|on)\s+(https?:\/\/[^\s,]+)/i, + description: 'Webpack/generic "running at" format', + }, + // Go / Rust / generic: "Serving on http://...", "Server on http://..." + { + pattern: /(?:serving|server)\s+(?:on|at)\s+(https?:\/\/[^\s,]+)/i, + description: 'Generic "serving on" format', + }, + // Localhost URL with port (conservative - must be localhost/127.0.0.1/[::]/0.0.0.0) + // This catches anything that looks like a dev server URL + { + pattern: /(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]|0\.0\.0\.0):\d+\S*)/i, + description: 'Generic localhost URL with port', + }, +]; + +// Port-only patterns for detecting port numbers from dev server output +// when a full URL is not present in the output. +// Defined once at module level to avoid reallocation on every call to detectUrlFromOutput. +const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + // "listening on port 3000", "server on port 3000", "started on port 3000" + { + pattern: /(?:listening|running|started|serving|available)\s+on\s+port\s+(\d+)/i, + description: '"listening on port" format', + }, + // "Port: 3000", "port 3000" (at start of line or after whitespace) + { + pattern: /(?:^|\s)port[:\s]+(\d{4,5})(?:\s|$|[.,;])/im, + description: '"port:" format', + }, +]; + +// Throttle output to prevent overwhelming WebSocket under heavy load. +// 100ms (~10fps) is sufficient for readable log streaming while keeping +// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated +// up to 250 events/sec which caused progressive browser slowdown from +// accumulated console logs, JSON serialization overhead, and React re-renders. +const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate +const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency + +export interface DevServerInfo { + worktreePath: string; + /** The port originally reserved by findAvailablePort() – never mutated after startDevServer sets it */ + allocatedPort: number; + port: number; + url: string; + process: ChildProcess | null; + startedAt: Date; + // Scrollback buffer for log history (replay on reconnect) + scrollbackBuffer: string; + // Pending output to be flushed to subscribers + outputBuffer: string; + // Throttle timer for batching output + flushTimeout: NodeJS.Timeout | null; + // Flag to indicate server is stopping (prevents output after stop) + stopping: boolean; + // Flag to indicate if URL has been detected from output + urlDetected: boolean; + // Timer for URL detection timeout fallback + urlDetectionTimeout: NodeJS.Timeout | null; + // Custom command used to start the server + customCommand?: string; +} + +/** + * Persistable subset of DevServerInfo for survival across server restarts + */ +interface PersistedDevServerInfo { + worktreePath: string; + allocatedPort: number; + port: number; + url: string; + startedAt: string; + urlDetected: boolean; + customCommand?: string; +} + +// Port allocation starts at 3001 to avoid conflicts with common dev ports +const BASE_PORT = 3001; +const MAX_PORT = 3099; // Safety limit + +// Common livereload ports that may need cleanup when stopping dev servers +const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; + +class DevServerService { + private runningServers: Map = new Map(); + private startingServers: Set = new Set(); + private allocatedPorts: Set = new Set(); + private emitter: EventEmitter | null = null; + private dataDir: string | null = null; + private saveQueue: Promise = Promise.resolve(); + + /** + * Initialize the service with data directory for persistence + */ + async initialize(dataDir: string, emitter: EventEmitter): Promise { + this.dataDir = dataDir; + this.emitter = emitter; + await this.loadState(); + } + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Save the current state of running servers to disk + */ + private async saveState(): Promise { + if (!this.dataDir) return; + + // Queue the save operation to prevent concurrent writes + this.saveQueue = this.saveQueue + .then(async () => { + if (!this.dataDir) return; + try { + const statePath = path.join(this.dataDir, 'dev-servers.json'); + const persistedInfo: PersistedDevServerInfo[] = Array.from( + this.runningServers.values() + ).map((s) => ({ + worktreePath: s.worktreePath, + allocatedPort: s.allocatedPort, + port: s.port, + url: s.url, + startedAt: s.startedAt.toISOString(), + urlDetected: s.urlDetected, + customCommand: s.customCommand, + })); + + await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2)); + logger.debug(`Saved dev server state to ${statePath}`); + } catch (error) { + logger.error('Failed to save dev server state:', error); + } + }) + .catch((error) => { + logger.error('Error in save queue:', error); + }); + + return this.saveQueue; + } + + /** + * Load the state of running servers from disk + */ + private async loadState(): Promise { + if (!this.dataDir) return; + + try { + const statePath = path.join(this.dataDir, 'dev-servers.json'); + try { + await fs.access(statePath, constants.F_OK); + } catch { + // File doesn't exist, which is fine + return; + } + + const content = await fs.readFile(statePath, 'utf-8'); + const rawParsed: unknown = JSON.parse(content); + + if (!Array.isArray(rawParsed)) { + logger.warn('Dev server state file is not an array, skipping load'); + return; + } + + const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => { + if (entry === null || typeof entry !== 'object') { + logger.warn('Dropping invalid dev server entry (not an object):', entry); + return false; + } + const e = entry as Record; + const valid = + typeof e.worktreePath === 'string' && + e.worktreePath.length > 0 && + typeof e.allocatedPort === 'number' && + Number.isInteger(e.allocatedPort) && + e.allocatedPort >= 1 && + e.allocatedPort <= 65535 && + typeof e.port === 'number' && + Number.isInteger(e.port) && + e.port >= 1 && + e.port <= 65535 && + typeof e.url === 'string' && + typeof e.startedAt === 'string' && + typeof e.urlDetected === 'boolean' && + (e.customCommand === undefined || typeof e.customCommand === 'string'); + if (!valid) { + logger.warn('Dropping malformed dev server entry:', e); + } + return valid; + }) as PersistedDevServerInfo[]; + + logger.info(`Loading ${persistedInfo.length} dev servers from state`); + + for (const info of persistedInfo) { + // Check if the process is still running on the port + // Since we can't reliably re-attach to the process for output, + // we'll just check if the port is in use. + const portInUse = !(await this.isPortAvailable(info.port)); + + if (portInUse) { + logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`); + const serverInfo: DevServerInfo = { + ...info, + startedAt: new Date(info.startedAt), + process: null, // Process object is lost, but we know it's running + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + urlDetectionTimeout: null, + }; + this.runningServers.set(info.worktreePath, serverInfo); + this.allocatedPorts.add(info.allocatedPort); + } else { + logger.info( + `Dev server on port ${info.port} for ${info.worktreePath} is no longer running` + ); + } + } + + // Cleanup stale entries from the file if any + if (this.runningServers.size !== persistedInfo.length) { + await this.saveState(); + } + } catch (error) { + logger.error('Failed to load dev server state:', error); + } + } + + /** + * Prune a stale server entry whose process has exited without cleanup. + * Clears any pending timers, removes the port from allocatedPorts, deletes + * the entry from runningServers, and emits the "dev-server:stopped" event + * so all callers consistently notify the frontend when pruning entries. + * + * @param worktreePath - The key used in runningServers + * @param server - The DevServerInfo entry to prune + */ + private pruneStaleServer(worktreePath: string, server: DevServerInfo): void { + if (server.flushTimeout) clearTimeout(server.flushTimeout); + if (server.urlDetectionTimeout) clearTimeout(server.urlDetectionTimeout); + // Use allocatedPort (immutable) to free the reserved slot; server.port may have + // been mutated by detectUrlFromOutput to reflect the actual detected port. + this.allocatedPorts.delete(server.allocatedPort); + this.runningServers.delete(worktreePath); + + // Persist state change + this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err)); + + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, // Report the externally-visible (detected) port + exitCode: server.process?.exitCode ?? null, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(server: DevServerInfo, data: string): void { + server.scrollbackBuffer += data; + if (server.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + server.scrollbackBuffer = server.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(server: DevServerInfo): void { + // Skip flush if server is stopping or buffer is empty + if (server.stopping || server.outputBuffer.length === 0) { + server.flushTimeout = null; + return; + } + + let dataToSend = server.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = server.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + server.outputBuffer = server.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } else { + server.outputBuffer = ''; + server.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('dev-server:output', { + worktreePath: server.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Strip ANSI escape codes from a string + * Dev server output often contains color codes that can interfere with URL detection + */ + private stripAnsi(str: string): string { + // Matches ANSI escape sequences: CSI sequences, OSC sequences, and simple escapes + // eslint-disable-next-line no-control-regex + return str.replace(/\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\[[?]?[0-9;]*[hl])/g, ''); + } + + /** + * Extract port number from a URL string. + * Returns the explicit port if present, or null if no port is specified. + * Default protocol ports (80/443) are intentionally NOT returned to avoid + * overwriting allocated dev server ports with protocol defaults. + */ + private extractPortFromUrl(url: string): number | null { + try { + const parsed = new URL(url); + if (parsed.port) { + return parseInt(parsed.port, 10); + } + return null; + } catch { + return null; + } + } + + /** + * Detect actual server URL from output + * Parses stdout/stderr for common URL patterns from dev servers. + * + * Supports detection of URLs from: + * - Vite: "Local: http://localhost:5173/" + * - Next.js: "ready - started server on 0.0.0.0:3000, url: http://localhost:3000" + * - Nuxt: "Local: http://localhost:3000/" + * - Remix: "started at http://localhost:3000" + * - Astro: "Local http://localhost:4321/" + * - SvelteKit: "Local: http://localhost:5173/" + * - CRA/Webpack: "On Your Network: http://192.168.1.1:3000" + * - Angular: "Local: http://localhost:4200/" + * - Express/Fastify/Koa: "Server listening on port 3000" + * - Django: "Starting development server at http://127.0.0.1:8000/" + * - Rails: "Listening on http://127.0.0.1:3000" + * - PHP: "Development Server (http://localhost:8000) started" + * - Generic: Any localhost URL with a port + */ + private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise { + // Skip if URL already detected + if (server.urlDetected) { + return; + } + + // Strip ANSI escape codes to prevent color codes from breaking regex matching + const cleanContent = this.stripAnsi(content); + + // Phase 1: Try to detect a full URL from output + // Patterns are defined at module level (URL_PATTERNS) and reused across calls + for (const { pattern, description } of URL_PATTERNS) { + const match = cleanContent.match(pattern); + if (match && match[1]) { + let detectedUrl = match[1].trim(); + // Remove trailing punctuation that might have been captured + detectedUrl = detectedUrl.replace(/[.,;:!?)\]}>]+$/, ''); + + if (detectedUrl.startsWith('http://') || detectedUrl.startsWith('https://')) { + // Normalize 0.0.0.0 to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/0\.0\.0\.0(:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::] to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + // Normalize [::1] (IPv6 loopback) to localhost for browser accessibility + detectedUrl = detectedUrl.replace( + /\/\/\[::1\](:\d+)?/, + (_, port) => `//localhost${port || ''}` + ); + + server.url = detectedUrl; + server.urlDetected = true; + + // Clear the URL detection timeout since we found the URL + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + + // Update the port to match the detected URL's actual port + const detectedPort = this.extractPortFromUrl(detectedUrl); + if (detectedPort && detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server URL via ${description}: ${detectedUrl}`); + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in detectUrlFromOutput:', err) + ); + + // Emit URL update event + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: server.worktreePath, + url: detectedUrl, + port: server.port, + timestamp: new Date().toISOString(), + }); + } + return; + } + } + } + + // Phase 2: Try to detect just a port number from output (no full URL) + // Some servers only print "listening on port 3000" without a full URL + // Patterns are defined at module level (PORT_PATTERNS) and reused across calls + for (const { pattern, description } of PORT_PATTERNS) { + const match = cleanContent.match(pattern); + if (match && match[1]) { + const detectedPort = parseInt(match[1], 10); + // Sanity check: port should be in a reasonable range + if (detectedPort > 0 && detectedPort <= 65535) { + const detectedUrl = `http://localhost:${detectedPort}`; + server.url = detectedUrl; + server.urlDetected = true; + + // Clear the URL detection timeout since we found the port + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + + if (detectedPort !== server.port) { + logger.info( + `Port mismatch: allocated ${server.port}, detected ${detectedPort} from ${description}` + ); + server.port = detectedPort; + } + + logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`); + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err) + ); + + // Emit URL update event + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: server.worktreePath, + url: detectedUrl, + port: server.port, + timestamp: new Date().toISOString(), + }); + } + return; + } + } + } + } + + /** + * Handle incoming stdout/stderr data from dev server process + * Buffers data for scrollback replay and schedules throttled emission + */ + private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise { + // Skip output if server is stopping + if (server.stopping) { + return; + } + + const content = data.toString(); + + // Try to detect actual server URL from output + await this.detectUrlFromOutput(server, content); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(server, content); + + // Buffer output for throttled live delivery + server.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!server.flushTimeout) { + server.flushTimeout = setTimeout(() => this.flushOutput(server), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[Port${server.port}] ${content.trim()}`); + } + + /** + * Check if a port is available (not in use by system or by us) + */ + private async isPortAvailable(port: number): Promise { + // First check if we've already allocated it + if (this.allocatedPorts.has(port)) { + return false; + } + + // Then check if the system has it in use + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(); + resolve(true); + }); + server.listen(port, '127.0.0.1'); + }); + } + + /** + * Kill any process running on the given port + */ + private killProcessOnPort(port: number): void { + try { + if (process.platform === 'win32') { + // Windows: find and kill process on port + const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' }); + const lines = result.trim().split('\n'); + const pids = new Set(); + for (const line of lines) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && pid !== '0') { + pids.add(pid); + } + } + for (const pid of pids) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); + logger.debug(`Killed process ${pid} on port ${port}`); + } catch { + // Process may have already exited + } + } + } else { + // macOS/Linux: use lsof to find and kill process + try { + const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' }); + const pids = result.trim().split('\n').filter(Boolean); + for (const pid of pids) { + try { + execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); + logger.debug(`Killed process ${pid} on port ${port}`); + } catch { + // Process may have already exited + } + } + } catch { + // No process found on port, which is fine + } + } + } catch { + // Ignore errors - port might not have any process + logger.debug(`No process to kill on port ${port}`); + } + } + + /** + * Find the next available port, killing any process on it first + */ + private async findAvailablePort(): Promise { + let port = BASE_PORT; + + while (port <= MAX_PORT) { + // Skip ports we've already allocated internally + if (this.allocatedPorts.has(port)) { + port++; + continue; + } + + // Force kill any process on this port before checking availability + // This ensures we can claim the port even if something stale is holding it + this.killProcessOnPort(port); + + // Small delay to let the port be released + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now check if it's available + if (await this.isPortAvailable(port)) { + return port; + } + port++; + } + + throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`); + } + + /** + * Helper to check if a file exists using secureFs + */ + private async fileExists(filePath: string): Promise { + try { + await secureFs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * Detect the package manager used in a directory + */ + private async detectPackageManager(dir: string): Promise<'npm' | 'yarn' | 'pnpm' | 'bun' | null> { + if (await this.fileExists(path.join(dir, 'bun.lockb'))) return 'bun'; + if (await this.fileExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm'; + if (await this.fileExists(path.join(dir, 'yarn.lock'))) return 'yarn'; + if (await this.fileExists(path.join(dir, 'package-lock.json'))) return 'npm'; + if (await this.fileExists(path.join(dir, 'package.json'))) return 'npm'; // Default + return null; + } + + /** + * Get the dev script command for a directory + */ + private async getDevCommand(dir: string): Promise<{ cmd: string; args: string[] } | null> { + const pm = await this.detectPackageManager(dir); + if (!pm) return null; + + switch (pm) { + case 'bun': + return { cmd: 'bun', args: ['run', 'dev'] }; + case 'pnpm': + return { cmd: 'pnpm', args: ['run', 'dev'] }; + case 'yarn': + return { cmd: 'yarn', args: ['dev'] }; + case 'npm': + default: + return { cmd: 'npm', args: ['run', 'dev'] }; + } + } + + /** + * Parse a custom command string into cmd and args + * Handles quoted strings with spaces (e.g., "my command" arg1 arg2) + */ + private parseCustomCommand(command: string): { cmd: string; args: string[] } { + const tokens: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + + if (inQuote) { + if (char === quoteChar) { + inQuote = false; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = true; + quoteChar = char; + } else if (char === ' ') { + if (current) { + tokens.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current) { + tokens.push(current); + } + + const [cmd, ...args] = tokens; + return { cmd: cmd || '', args }; + } + + /** + * Start a dev server for a worktree + * @param projectPath - The project root path + * @param worktreePath - The worktree directory path + * @param customCommand - Optional custom command to run instead of auto-detected dev command + */ + async startDevServer( + projectPath: string, + worktreePath: string, + customCommand?: string + ): Promise<{ + success: boolean; + result?: { + worktreePath: string; + port: number; + url: string; + message: string; + }; + error?: string; + }> { + // Check if already running or starting + if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) { + const existing = this.runningServers.get(worktreePath); + if (existing) { + return { + success: true, + result: { + worktreePath: existing.worktreePath, + port: existing.port, + url: existing.url, + message: `Dev server already running on port ${existing.port}`, + }, + }; + } + return { + success: false, + error: 'Dev server is already starting', + }; + } + + this.startingServers.add(worktreePath); + + try { + // Verify the worktree exists + if (!(await this.fileExists(worktreePath))) { + return { + success: false, + error: `Worktree path does not exist: ${worktreePath}`, + }; + } + + // Determine the dev command to use + let devCommand: { cmd: string; args: string[] }; + + // Normalize custom command: trim whitespace and treat empty strings as undefined + const normalizedCustomCommand = customCommand?.trim(); + + if (normalizedCustomCommand) { + // Use the provided custom command + devCommand = this.parseCustomCommand(normalizedCustomCommand); + if (!devCommand.cmd) { + return { + success: false, + error: 'Invalid custom command: command cannot be empty', + }; + } + logger.debug(`Using custom command: ${normalizedCustomCommand}`); + } else { + // Check for package.json when auto-detecting + const packageJsonPath = path.join(worktreePath, 'package.json'); + if (!(await this.fileExists(packageJsonPath))) { + return { + success: false, + error: `No package.json found in: ${worktreePath}`, + }; + } + + // Get dev command from package manager detection + const detectedCommand = await this.getDevCommand(worktreePath); + if (!detectedCommand) { + return { + success: false, + error: `Could not determine dev command for: ${worktreePath}`, + }; + } + devCommand = detectedCommand; + } + + // Find available port + let port: number; + try { + port = await this.findAvailablePort(); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Port allocation failed', + }; + } + + // Reserve the port (port was already force-killed in findAvailablePort) + this.allocatedPorts.add(port); + + // Also kill common related ports (livereload, etc.) + // Some dev servers use fixed ports for HMR/livereload regardless of main port + for (const relatedPort of LIVERELOAD_PORTS) { + this.killProcessOnPort(relatedPort); + } + + // Small delay to ensure related ports are freed + await new Promise((resolve) => setTimeout(resolve, 100)); + + logger.info(`Starting dev server on port ${port}`); + logger.debug(`Working directory (cwd): ${worktreePath}`); + logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); + + // Emit starting only after preflight checks pass to avoid dangling starting state. + if (this.emitter) { + this.emitter.emit('dev-server:starting', { + worktreePath, + timestamp: new Date().toISOString(), + }); + } + + // Spawn the dev process with PORT environment variable + // FORCE_COLOR enables colored output even when not running in a TTY + const env = { + ...process.env, + PORT: String(port), + FORCE_COLOR: '1', + // Some tools use these additional env vars for color detection + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + }; + + const devProcess = spawn(devCommand.cmd, devCommand.args, { + cwd: worktreePath, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + + // Track if process failed early using object to work around TypeScript narrowing + const status = { error: null as string | null, exited: false }; + + // Create server info early so we can reference it in handlers + // We'll add it to runningServers after verifying the process started successfully + const fallbackHost = 'localhost'; + const serverInfo: DevServerInfo = { + worktreePath, + allocatedPort: port, // Immutable: records which port we reserved; never changed after this point + port, + url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput + process: devProcess, + startedAt: new Date(), + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + urlDetected: false, // Will be set to true when actual URL is detected from output + urlDetectionTimeout: null, // Will be set after server starts successfully + customCommand: normalizedCustomCommand, + }; + + // Capture stdout with buffer management and event emission + if (devProcess.stdout) { + devProcess.stdout.on('data', (data: Buffer) => { + this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { + logger.error('Failed to handle dev server stdout output:', error); + }); + }); + } + + // Capture stderr with buffer management and event emission + if (devProcess.stderr) { + devProcess.stderr.on('data', (data: Buffer) => { + this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { + logger.error('Failed to handle dev server stderr output:', error); + }); + }); + } + + // Helper to clean up resources and emit stop event + const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { + if (serverInfo.flushTimeout) { + clearTimeout(serverInfo.flushTimeout); + serverInfo.flushTimeout = null; + } + + // Clear URL detection timeout to prevent stale fallback emission + if (serverInfo.urlDetectionTimeout) { + clearTimeout(serverInfo.urlDetectionTimeout); + serverInfo.urlDetectionTimeout = null; + } + + // Emit stopped event (only if not already stopping - prevents duplicate events) + if (this.emitter && !serverInfo.stopping) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) + exitCode, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } + + this.allocatedPorts.delete(serverInfo.allocatedPort); + this.runningServers.delete(worktreePath); + + // Persist state change + this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err)); + }; + + devProcess.on('error', (error) => { + logger.error(`Process error:`, error); + status.error = error.message; + cleanupAndEmitStop(null, error.message); + }); + + devProcess.on('exit', (code) => { + logger.info(`Process for ${worktreePath} exited with code ${code}`); + status.exited = true; + cleanupAndEmitStop(code); + }); + + // Wait a moment to see if the process fails immediately + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (status.error) { + return { + success: false, + error: `Failed to start dev server: ${status.error}`, + }; + } + + if (status.exited) { + return { + success: false, + error: `Dev server process exited immediately. Check server logs for details.`, + }; + } + + // Server started successfully - add to running servers map + this.runningServers.set(worktreePath, serverInfo); + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in startDevServer:', err) + ); + + // Emit started event for WebSocket subscribers + if (this.emitter) { + this.emitter.emit('dev-server:started', { + worktreePath, + port, + url: serverInfo.url, + timestamp: new Date().toISOString(), + }); + } + + // Set up URL detection timeout fallback. + // If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if + // the allocated port is actually in use (server probably started successfully) + // and emit a url-detected event with the allocated port as fallback. + // Also re-scan the scrollback buffer in case the URL was printed before + // our patterns could match (e.g., it was split across multiple data chunks). + serverInfo.urlDetectionTimeout = setTimeout(async () => { + serverInfo.urlDetectionTimeout = null; + + // Only run fallback if server is still running and URL wasn't detected + if ( + serverInfo.stopping || + serverInfo.urlDetected || + !this.runningServers.has(worktreePath) + ) { + return; + } + + // Re-scan the entire scrollback buffer for URL patterns + // This catches cases where the URL was split across multiple output chunks + logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`); + await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) => + logger.error('Failed to re-scan scrollback buffer:', err) + ); + + // If still not detected after full rescan, use the allocated port as fallback + if (!serverInfo.urlDetected) { + logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); + const fallbackUrl = `http://${fallbackHost}:${port}`; + serverInfo.url = fallbackUrl; + serverInfo.urlDetected = true; + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in URL detection fallback:', err) + ); + + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: serverInfo.worktreePath, + url: fallbackUrl, + port, + timestamp: new Date().toISOString(), + }); + } + } + }, URL_DETECTION_TIMEOUT_MS); + + return { + success: true, + result: { + worktreePath: serverInfo.worktreePath, + port: serverInfo.port, + url: serverInfo.url, + message: `Dev server started on port ${port}`, + }, + }; + } finally { + this.startingServers.delete(worktreePath); + } + } + + /** + * Stop a dev server for a worktree + */ + async stopDevServer(worktreePath: string): Promise<{ + success: boolean; + result?: { worktreePath: string; message: string }; + error?: string; + }> { + const server = this.runningServers.get(worktreePath); + + // If we don't have a record of this server, it may have crashed/exited on its own + // Return success so the frontend can clear its state + if (!server) { + logger.debug(`No server record for ${worktreePath}, may have already stopped`); + return { + success: true, + result: { + worktreePath, + message: `Dev server already stopped`, + }, + }; + } + + logger.info(`Stopping dev server for ${worktreePath}`); + + // Mark as stopping to prevent further output events + server.stopping = true; + + // Clean up flush timeout to prevent memory leaks + if (server.flushTimeout) { + clearTimeout(server.flushTimeout); + server.flushTimeout = null; + } + + // Clean up URL detection timeout + if (server.urlDetectionTimeout) { + clearTimeout(server.urlDetectionTimeout); + server.urlDetectionTimeout = null; + } + + // Clear any pending output buffer + server.outputBuffer = ''; + + // Emit stopped event immediately so UI updates right away + if (this.emitter) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: server.port, + exitCode: null, // Will be populated by exit handler if process exits normally + timestamp: new Date().toISOString(), + }); + } + + // Kill the process; persisted/re-attached entries may not have a process handle. + if (server.process && !server.process.killed) { + server.process.kill('SIGTERM'); + } else { + this.killProcessOnPort(server.port); + } + + // Free the originally-reserved port slot (allocatedPort is immutable and always + // matches what was added to allocatedPorts in startDevServer; server.port may + // have been updated by detectUrlFromOutput to the actual detected port). + this.allocatedPorts.delete(server.allocatedPort); + this.runningServers.delete(worktreePath); + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in stopDevServer:', err) + ); + + return { + success: true, + result: { + worktreePath, + message: `Stopped dev server on port ${server.port}`, + }, + }; + } + + /** + * List all running dev servers + * Also verifies that each server's process is still alive, removing stale entries + */ + listDevServers(): { + success: boolean; + result: { + servers: Array<{ + worktreePath: string; + port: number; + url: string; + urlDetected: boolean; + startedAt: string; + }>; + }; + } { + // Prune any servers whose process has died without us being notified + // This handles edge cases where the process exited but the 'exit' event was missed + const stalePaths: string[] = []; + for (const [worktreePath, server] of this.runningServers) { + // Check if exitCode is a number (not null/undefined) - indicates process has exited + if (server.process && typeof server.process.exitCode === 'number') { + logger.info( + `Pruning stale server entry for ${worktreePath} (process exited with code ${server.process.exitCode})` + ); + stalePaths.push(worktreePath); + } + } + for (const stalePath of stalePaths) { + const server = this.runningServers.get(stalePath); + if (server) { + // Delegate to the shared helper so timers, ports, and the stopped event + // are all handled consistently with isRunning and getServerInfo. + this.pruneStaleServer(stalePath, server); + } + } + + const servers = Array.from(this.runningServers.values()).map((s) => ({ + worktreePath: s.worktreePath, + port: s.port, + url: s.url, + urlDetected: s.urlDetected, + startedAt: s.startedAt.toISOString(), + })); + + return { + success: true, + result: { servers }, + }; + } + + /** + * Check if a worktree has a running dev server. + * Also prunes stale entries where the process has exited. + */ + isRunning(worktreePath: string): boolean { + const server = this.runningServers.get(worktreePath); + if (!server) return false; + // Prune stale entry if the process has exited + if (server.process && typeof server.process.exitCode === 'number') { + this.pruneStaleServer(worktreePath, server); + return false; + } + return true; + } + + /** + * Get info for a specific worktree's dev server. + * Also prunes stale entries where the process has exited. + */ + getServerInfo(worktreePath: string): DevServerInfo | undefined { + const server = this.runningServers.get(worktreePath); + if (!server) return undefined; + // Prune stale entry if the process has exited + if (server.process && typeof server.process.exitCode === 'number') { + this.pruneStaleServer(worktreePath, server); + return undefined; + } + return server; + } + + /** + * Get buffered logs for a worktree's dev server + * Returns the scrollback buffer containing historical log output + * Used by the API to serve logs to clients on initial connection + */ + getServerLogs(worktreePath: string): { + success: boolean; + result?: { + worktreePath: string; + port: number; + url: string; + logs: string; + startedAt: string; + }; + error?: string; + } { + const server = this.runningServers.get(worktreePath); + + if (!server) { + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + + // Prune stale entry if the process has been killed or has exited + if (server.process && (server.process.killed || server.process.exitCode != null)) { + this.pruneStaleServer(worktreePath, server); + return { + success: false, + error: `No dev server running for worktree: ${worktreePath}`, + }; + } + + return { + success: true, + result: { + worktreePath: server.worktreePath, + port: server.port, + url: server.url, + logs: server.scrollbackBuffer, + startedAt: server.startedAt.toISOString(), + }, + }; + } + + /** + * Get all allocated ports + */ + getAllocatedPorts(): number[] { + return Array.from(this.allocatedPorts); + } + + /** + * Stop all running dev servers (for cleanup) + */ + async stopAll(): Promise { + logger.info(`Stopping all ${this.runningServers.size} dev servers`); + + for (const [worktreePath] of this.runningServers) { + await this.stopDevServer(worktreePath); + } + } +} + +// Singleton instance +let devServerServiceInstance: DevServerService | null = null; + +export function getDevServerService(): DevServerService { + if (!devServerServiceInstance) { + devServerServiceInstance = new DevServerService(); + } + return devServerServiceInstance; +} + +// Cleanup on process exit +process.on('SIGTERM', async () => { + if (devServerServiceInstance) { + await devServerServiceInstance.stopAll(); + } +}); + +process.on('SIGINT', async () => { + if (devServerServiceInstance) { + await devServerServiceInstance.stopAll(); + } +}); diff --git a/jules_branch/apps/server/src/services/event-history-service.ts b/jules_branch/apps/server/src/services/event-history-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7091725139abcb0edb111235947c4152c54f0c5 --- /dev/null +++ b/jules_branch/apps/server/src/services/event-history-service.ts @@ -0,0 +1,333 @@ +/** + * Event History Service - Stores and retrieves event records for debugging and replay + * + * Provides persistent storage for events in {projectPath}/.automaker/events/ + * Each event is stored as a separate JSON file with an index for quick listing. + * + * Features: + * - Store events when they occur + * - List and filter historical events + * - Replay events to test hook configurations + * - Delete old events to manage disk space + */ + +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { getEventHistoryIndexPath, getEventPath, ensureEventHistoryDir } from '@automaker/platform'; +import type { + StoredEvent, + StoredEventIndex, + StoredEventSummary, + EventHistoryFilter, + EventHookTrigger, +} from '@automaker/types'; +import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types'; +import { randomUUID } from 'crypto'; + +const logger = createLogger('EventHistoryService'); + +/** Maximum events to keep in the index (oldest are pruned) */ +const MAX_EVENTS_IN_INDEX = 1000; + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Input for storing a new event + */ +export interface StoreEventInput { + trigger: EventHookTrigger; + projectPath: string; + featureId?: string; + featureName?: string; + error?: string; + errorType?: string; + passes?: boolean; + metadata?: Record; +} + +/** + * EventHistoryService - Manages persistent storage of events + */ +export class EventHistoryService { + /** + * Store a new event to history + * + * @param input - Event data to store + * @returns Promise resolving to the stored event + */ + async storeEvent(input: StoreEventInput): Promise { + const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } = + input; + + // Ensure events directory exists + await ensureEventHistoryDir(projectPath); + + const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`; + const timestamp = new Date().toISOString(); + const projectName = this.extractProjectName(projectPath); + + const event: StoredEvent = { + id: eventId, + trigger, + timestamp, + projectPath, + projectName, + featureId, + featureName, + error, + errorType, + passes, + metadata, + }; + + // Write the full event to its own file + const eventPath = getEventPath(projectPath, eventId); + await atomicWriteJson(eventPath, event); + + // Update the index + await this.addToIndex(projectPath, event); + + logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`); + + return event; + } + + /** + * Get all events for a project with optional filtering + * + * @param projectPath - Absolute path to project directory + * @param filter - Optional filter criteria + * @returns Promise resolving to array of event summaries + */ + async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + let events = [...index.events]; + + // Apply filters + if (filter) { + if (filter.trigger) { + events = events.filter((e) => e.trigger === filter.trigger); + } + if (filter.featureId) { + events = events.filter((e) => e.featureId === filter.featureId); + } + if (filter.since) { + const sinceDate = new Date(filter.since).getTime(); + events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate); + } + if (filter.until) { + const untilDate = new Date(filter.until).getTime(); + events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate); + } + } + + // Sort by timestamp (newest first) + events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + // Apply pagination + if (filter?.offset) { + events = events.slice(filter.offset); + } + if (filter?.limit) { + events = events.slice(0, filter.limit); + } + + return events; + } + + /** + * Get a single event by ID + * + * @param projectPath - Absolute path to project directory + * @param eventId - Event identifier + * @returns Promise resolving to the full event or null if not found + */ + async getEvent(projectPath: string, eventId: string): Promise { + const eventPath = getEventPath(projectPath, eventId); + try { + const content = (await secureFs.readFile(eventPath, 'utf-8')) as string; + return JSON.parse(content) as StoredEvent; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Error reading event ${eventId}:`, error); + return null; + } + } + + /** + * Delete an event by ID + * + * @param projectPath - Absolute path to project directory + * @param eventId - Event identifier + * @returns Promise resolving to true if deleted + */ + async deleteEvent(projectPath: string, eventId: string): Promise { + // Remove from index + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const initialLength = index.events.length; + index.events = index.events.filter((e) => e.id !== eventId); + + if (index.events.length === initialLength) { + return false; // Event not found in index + } + + await atomicWriteJson(indexPath, index); + + // Delete the event file + const eventPath = getEventPath(projectPath, eventId); + try { + await secureFs.unlink(eventPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`Error deleting event file ${eventId}:`, error); + } + } + + logger.info(`Deleted event ${eventId}`); + return true; + } + + /** + * Clear all events for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of events cleared + */ + async clearEvents(projectPath: string): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const count = index.events.length; + + // Delete all event files + for (const event of index.events) { + const eventPath = getEventPath(projectPath, event.id); + try { + await secureFs.unlink(eventPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`Error deleting event file ${event.id}:`, error); + } + } + } + + // Reset the index + await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + logger.info(`Cleared ${count} events for project`); + return count; + } + + /** + * Get event count for a project + * + * @param projectPath - Absolute path to project directory + * @param filter - Optional filter criteria + * @returns Promise resolving to event count + */ + async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise { + const events = await this.getEvents(projectPath, { + ...filter, + limit: undefined, + offset: undefined, + }); + return events.length; + } + + /** + * Add an event to the index (internal) + */ + private async addToIndex(projectPath: string, event: StoredEvent): Promise { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(indexPath, DEFAULT_EVENT_HISTORY_INDEX); + + const summary: StoredEventSummary = { + id: event.id, + trigger: event.trigger, + timestamp: event.timestamp, + featureName: event.featureName, + featureId: event.featureId, + }; + + // Add to beginning (newest first) + index.events.unshift(summary); + + // Prune old events if over limit + if (index.events.length > MAX_EVENTS_IN_INDEX) { + const removed = index.events.splice(MAX_EVENTS_IN_INDEX); + // Delete the pruned event files + for (const oldEvent of removed) { + const eventPath = getEventPath(projectPath, oldEvent.id); + try { + await secureFs.unlink(eventPath); + } catch { + // Ignore deletion errors for pruned events + } + } + logger.info(`Pruned ${removed.length} old events from history`); + } + + await atomicWriteJson(indexPath, index); + } + + /** + * Extract project name from path + */ + private extractProjectName(projectPath: string): string { + const parts = projectPath.split(/[/\\]/); + return parts[parts.length - 1] || projectPath; + } +} + +// Singleton instance +let eventHistoryServiceInstance: EventHistoryService | null = null; + +/** + * Get the singleton event history service instance + */ +export function getEventHistoryService(): EventHistoryService { + if (!eventHistoryServiceInstance) { + eventHistoryServiceInstance = new EventHistoryService(); + } + return eventHistoryServiceInstance; +} diff --git a/jules_branch/apps/server/src/services/event-hook-service.ts b/jules_branch/apps/server/src/services/event-hook-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..816a6ed27ce9556f43d4c97688702d1d9fc1d512 --- /dev/null +++ b/jules_branch/apps/server/src/services/event-hook-service.ts @@ -0,0 +1,659 @@ +/** + * Event Hook Service - Executes custom actions when system events occur + * + * Listens to the event emitter and triggers configured hooks: + * - Shell commands: Executed with configurable timeout + * - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution + * + * Also stores events to history for debugging and replay. + * + * Supported events: + * - feature_created: A new feature was created + * - feature_success: Feature completed successfully + * - feature_error: Feature failed with an error + * - auto_mode_complete: Auto mode finished all features (idle state) + * - auto_mode_error: Auto mode encountered a critical error + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; +import type { EventHistoryService } from './event-history-service.js'; +import type { FeatureLoader } from './feature-loader.js'; +import type { + EventHook, + EventHookTrigger, + EventHookShellAction, + EventHookHttpAction, + EventHookNtfyAction, + NtfyEndpointConfig, + EventHookContext, +} from '@automaker/types'; +import { ntfyService, type NtfyContext } from './ntfy-service.js'; + +const execAsync = promisify(exec); +const logger = createLogger('EventHooks'); + +/** Default timeout for shell commands (30 seconds) */ +const DEFAULT_SHELL_TIMEOUT = 30000; + +/** Default timeout for HTTP requests (10 seconds) */ +const DEFAULT_HTTP_TIMEOUT = 10000; + +// Use the shared EventHookContext type (aliased locally as HookContext for clarity) +type HookContext = EventHookContext; + +/** + * Auto-mode event payload structure + */ +interface AutoModeEventPayload { + type?: string; + featureId?: string; + featureName?: string; + passes?: boolean; + executionMode?: 'auto' | 'manual'; + message?: string; + error?: string; + errorType?: string; + projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; +} + +/** + * Feature created event payload structure + */ +interface FeatureCreatedPayload { + featureId: string; + featureName?: string; + projectPath: string; +} + +/** + * Feature status changed event payload structure + */ +interface FeatureStatusChangedPayload { + featureId: string; + projectPath: string; + status: string; +} + +/** + * Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload + */ +function isFeatureStatusChangedPayload( + payload: AutoModeEventPayload +): payload is AutoModeEventPayload & FeatureStatusChangedPayload { + return ( + typeof payload.featureId === 'string' && + typeof payload.projectPath === 'string' && + typeof payload.status === 'string' + ); +} + +/** + * Feature completed event payload structure + */ +interface FeatureCompletedPayload { + featureId: string; + featureName?: string; + projectPath: string; + passes?: boolean; + message?: string; + executionMode?: 'auto' | 'manual'; +} + +/** + * Event Hook Service + * + * Manages execution of user-configured event hooks in response to system events. + * Also stores events to history for debugging and replay. + */ +export class EventHookService { + /** Feature status that indicates agent work is done and awaiting human review (tests skipped) */ + private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval'; + /** Feature status that indicates agent work passed automated verification */ + private static readonly STATUS_VERIFIED = 'verified'; + + private emitter: EventEmitter | null = null; + private settingsService: SettingsService | null = null; + private eventHistoryService: EventHistoryService | null = null; + private featureLoader: FeatureLoader | null = null; + private unsubscribe: (() => void) | null = null; + + /** + * Track feature IDs that have already had hooks fired via auto_mode_feature_complete + * to prevent double-firing when feature_status_changed also fires for the same feature. + * Entries are automatically cleaned up after 30 seconds. + */ + private recentlyHandledFeatures = new Set(); + + /** + * Timer IDs for pending cleanup of recentlyHandledFeatures entries, + * keyed by featureId. Stored so they can be cancelled in destroy(). + */ + private recentlyHandledTimers = new Map>(); + + /** + * Initialize the service with event emitter, settings service, event history service, and feature loader + */ + initialize( + emitter: EventEmitter, + settingsService: SettingsService, + eventHistoryService?: EventHistoryService, + featureLoader?: FeatureLoader + ): void { + this.emitter = emitter; + this.settingsService = settingsService; + this.eventHistoryService = eventHistoryService || null; + this.featureLoader = featureLoader || null; + + // Subscribe to events + this.unsubscribe = emitter.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEvent(payload as AutoModeEventPayload); + } else if (type === 'feature:created') { + this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload); + } else if (type === 'feature:completed') { + this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload); + } + }); + + logger.info('Event hook service initialized'); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + // Cancel all pending cleanup timers to avoid cross-session mutations + for (const timerId of this.recentlyHandledTimers.values()) { + clearTimeout(timerId); + } + this.recentlyHandledTimers.clear(); + this.recentlyHandledFeatures.clear(); + this.emitter = null; + this.settingsService = null; + this.eventHistoryService = null; + this.featureLoader = null; + } + + /** + * Handle auto-mode events and trigger matching hooks + */ + private async handleAutoModeEvent(payload: AutoModeEventPayload): Promise { + if (!payload.type) return; + + // Map internal event types to hook triggers + let trigger: EventHookTrigger | null = null; + + switch (payload.type) { + case 'auto_mode_feature_complete': + // Only map explicit auto-mode completion events. + // Manual feature completions are emitted as feature:completed. + if (payload.executionMode !== 'auto') return; + trigger = payload.passes ? 'feature_success' : 'feature_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } + break; + case 'auto_mode_error': + // Feature-level error (has featureId) vs auto-mode level error + trigger = payload.featureId ? 'feature_error' : 'auto_mode_error'; + // Track this feature so feature_status_changed doesn't double-fire hooks + if (payload.featureId) { + this.markFeatureHandled(payload.featureId); + } + break; + case 'auto_mode_idle': + trigger = 'auto_mode_complete'; + break; + case 'feature_status_changed': + if (isFeatureStatusChangedPayload(payload)) { + this.handleFeatureStatusChanged(payload); + } + return; + default: + // Other event types don't trigger hooks + return; + } + + if (!trigger) return; + + // Load feature name if we have featureId but no featureName + let featureName: string | undefined = undefined; + if (payload.featureId && payload.projectPath && this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error); + } + } + + // Build context for variable substitution + // Use loaded featureName (from feature.title) or fall back to payload.featureName + // Only populate error/errorType for error triggers - don't leak success messages into error fields + const isErrorTrigger = trigger === 'feature_error' || trigger === 'auto_mode_error'; + const context: HookContext = { + featureId: payload.featureId, + featureName: featureName || payload.featureName, + projectPath: payload.projectPath, + projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined, + error: isErrorTrigger ? payload.error || payload.message : undefined, + errorType: isErrorTrigger ? payload.errorType : undefined, + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + // Execute matching hooks (pass passes for feature completion events) + await this.executeHooksForTrigger(trigger, context, { passes: payload.passes }); + } + + /** + * Handle feature:completed events and trigger matching hooks + */ + private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise { + if (!payload.featureId || !payload.projectPath) return; + + // Mark as handled to prevent duplicate firing if feature_status_changed also fires + this.markFeatureHandled(payload.featureId); + + const passes = payload.passes ?? true; + const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error'; + + // Load feature name if we have featureId but no featureName + let featureName: string | undefined = undefined; + if (payload.projectPath && this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error); + } + } + + const isErrorTrigger = trigger === 'feature_error'; + const context: HookContext = { + featureId: payload.featureId, + featureName: featureName || payload.featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + error: isErrorTrigger ? payload.message : undefined, + errorType: undefined, + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + await this.executeHooksForTrigger(trigger, context, { passes }); + } + + /** + * Handle feature:created events and trigger matching hooks + */ + private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise { + const context: HookContext = { + featureId: payload.featureId, + featureName: payload.featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + timestamp: new Date().toISOString(), + eventType: 'feature_created', + }; + + await this.executeHooksForTrigger('feature_created', context); + } + + /** + * Handle feature_status_changed events for non-auto-mode feature completion. + * + * Auto-mode features already emit auto_mode_feature_complete which triggers hooks. + * This handler catches manual (non-auto-mode) feature completions by detecting + * status transitions to completion states (verified, waiting_approval). + */ + private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise { + // Skip if this feature was already handled via auto_mode_feature_complete + if (this.recentlyHandledFeatures.has(payload.featureId)) { + return; + } + + let trigger: EventHookTrigger | null = null; + + if ( + payload.status === EventHookService.STATUS_VERIFIED || + payload.status === EventHookService.STATUS_WAITING_APPROVAL + ) { + trigger = 'feature_success'; + } else { + // Only completion statuses trigger hooks from status changes + return; + } + + // Load feature name + let featureName: string | undefined = undefined; + if (this.featureLoader) { + try { + const feature = await this.featureLoader.get(payload.projectPath, payload.featureId); + if (feature?.title) { + featureName = feature.title; + } + } catch (error) { + logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error); + } + } + + const context: HookContext = { + featureId: payload.featureId, + featureName, + projectPath: payload.projectPath, + projectName: this.extractProjectName(payload.projectPath), + timestamp: new Date().toISOString(), + eventType: trigger, + }; + + await this.executeHooksForTrigger(trigger, context, { passes: true }); + } + + /** + * Mark a feature as recently handled to prevent double-firing hooks. + * Entries are cleaned up after 30 seconds. + */ + private markFeatureHandled(featureId: string): void { + // Cancel any existing timer for this feature before setting a new one + const existing = this.recentlyHandledTimers.get(featureId); + if (existing !== undefined) { + clearTimeout(existing); + } + this.recentlyHandledFeatures.add(featureId); + const timerId = setTimeout(() => { + this.recentlyHandledFeatures.delete(featureId); + this.recentlyHandledTimers.delete(featureId); + }, 30000); + this.recentlyHandledTimers.set(featureId, timerId); + } + + /** + * Execute all enabled hooks matching the given trigger and store event to history + */ + private async executeHooksForTrigger( + trigger: EventHookTrigger, + context: HookContext, + additionalData?: { passes?: boolean } + ): Promise { + // Store event to history (even if no hooks match) + if (this.eventHistoryService && context.projectPath) { + try { + await this.eventHistoryService.storeEvent({ + trigger, + projectPath: context.projectPath, + featureId: context.featureId, + featureName: context.featureName, + error: context.error, + errorType: context.errorType, + passes: additionalData?.passes, + }); + } catch (error) { + logger.error('Failed to store event to history:', error); + } + } + + if (!this.settingsService) { + logger.warn('Settings service not available'); + return; + } + + try { + const settings = await this.settingsService.getGlobalSettings(); + const hooks = settings.eventHooks || []; + + // Filter to enabled hooks matching this trigger + const matchingHooks = hooks.filter((hook) => hook.enabled && hook.trigger === trigger); + + if (matchingHooks.length === 0) { + return; + } + + logger.info(`Executing ${matchingHooks.length} hook(s) for trigger: ${trigger}`); + + // Execute hooks in parallel (don't wait for one to finish before starting next) + await Promise.allSettled(matchingHooks.map((hook) => this.executeHook(hook, context))); + } catch (error) { + logger.error('Error executing hooks:', error); + } + } + + /** + * Execute a single hook + */ + private async executeHook(hook: EventHook, context: HookContext): Promise { + const hookName = hook.name || hook.id; + + try { + if (hook.action.type === 'shell') { + await this.executeShellHook(hook.action, context, hookName); + } else if (hook.action.type === 'http') { + await this.executeHttpHook(hook.action, context, hookName); + } else if (hook.action.type === 'ntfy') { + await this.executeNtfyHook(hook.action, context, hookName); + } + } catch (error) { + logger.error(`Hook "${hookName}" failed:`, error); + } + } + + /** + * Execute a shell command hook + */ + private async executeShellHook( + action: EventHookShellAction, + context: HookContext, + hookName: string + ): Promise { + const command = this.substituteVariables(action.command, context); + const timeout = action.timeout || DEFAULT_SHELL_TIMEOUT; + + logger.info(`Executing shell hook "${hookName}": ${command}`); + + try { + const { stdout, stderr } = await execAsync(command, { + timeout, + maxBuffer: 1024 * 1024, // 1MB buffer + }); + + if (stdout) { + logger.debug(`Hook "${hookName}" stdout: ${stdout.trim()}`); + } + if (stderr) { + logger.warn(`Hook "${hookName}" stderr: ${stderr.trim()}`); + } + + logger.info(`Shell hook "${hookName}" completed successfully`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ETIMEDOUT') { + logger.error(`Shell hook "${hookName}" timed out after ${timeout}ms`); + } + throw error; + } + } + + /** + * Execute an HTTP webhook hook + */ + private async executeHttpHook( + action: EventHookHttpAction, + context: HookContext, + hookName: string + ): Promise { + const url = this.substituteVariables(action.url, context); + const method = action.method || 'POST'; + + // Substitute variables in headers + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (action.headers) { + for (const [key, value] of Object.entries(action.headers)) { + headers[key] = this.substituteVariables(value, context); + } + } + + // Substitute variables in body + let body: string | undefined; + if (action.body) { + body = this.substituteVariables(action.body, context); + } else if (method !== 'GET') { + // Default body with context information + body = JSON.stringify({ + eventType: context.eventType, + timestamp: context.timestamp, + featureId: context.featureId, + featureName: context.featureName, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + }); + } + + logger.info(`Executing HTTP hook "${hookName}": ${method} ${url}`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT); + + const response = await fetch(url, { + method, + headers, + body: method !== 'GET' ? body : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn(`HTTP hook "${hookName}" received status ${response.status}`); + } else { + logger.info(`HTTP hook "${hookName}" completed successfully (status: ${response.status})`); + } + } catch (error) { + if ((error as Error).name === 'AbortError') { + logger.error(`HTTP hook "${hookName}" timed out after ${DEFAULT_HTTP_TIMEOUT}ms`); + } + throw error; + } + } + + /** + * Execute an ntfy.sh notification hook + */ + private async executeNtfyHook( + action: EventHookNtfyAction, + context: HookContext, + hookName: string + ): Promise { + if (!this.settingsService) { + logger.warn('Settings service not available for ntfy hook'); + return; + } + + // Get the endpoint configuration + const settings = await this.settingsService.getGlobalSettings(); + const endpoints = settings.ntfyEndpoints || []; + const endpoint = endpoints.find((e) => e.id === action.endpointId); + + if (!endpoint) { + logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`); + return; + } + + // Convert HookContext to NtfyContext + const ntfyContext: NtfyContext = { + featureId: context.featureId, + featureName: context.featureName, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + errorType: context.errorType, + timestamp: context.timestamp, + eventType: context.eventType, + }; + + // Resolve click URL: action-level overrides endpoint default + let clickUrl = action.clickUrl || endpoint.defaultClickUrl; + + // Apply deep-link parameters to the resolved click URL + if (clickUrl && context.projectPath) { + try { + const url = new URL(clickUrl); + url.pathname = '/board'; + // Add projectPath so the UI can switch to the correct project + url.searchParams.set('projectPath', context.projectPath); + // Add featureId as query param for deep linking to board with feature output modal + if (context.featureId) { + url.searchParams.set('featureId', context.featureId); + } + clickUrl = url.toString(); + } catch (error) { + // If URL parsing fails, log warning and use as-is + logger.warn( + `Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`); + + const result = await ntfyService.sendNotification( + endpoint, + { + title: action.title, + body: action.body, + tags: action.tags, + emoji: action.emoji, + clickUrl, + priority: action.priority, + }, + ntfyContext + ); + + if (!result.success) { + logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`); + } else { + logger.info(`Ntfy hook "${hookName}" completed successfully`); + } + } + + /** + * Substitute {{variable}} placeholders in a string + */ + private substituteVariables(template: string, context: HookContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof HookContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + } + + /** + * Extract project name from path + */ + private extractProjectName(projectPath: string): string { + const parts = projectPath.split(/[/\\]/); + return parts[parts.length - 1] || projectPath; + } +} + +// Singleton instance +export const eventHookService = new EventHookService(); diff --git a/jules_branch/apps/server/src/services/execution-service.ts b/jules_branch/apps/server/src/services/execution-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..949f2e104f96413a39706715a98ec8bba25fa9b0 --- /dev/null +++ b/jules_branch/apps/server/src/services/execution-service.ts @@ -0,0 +1,611 @@ +/** + * ExecutionService - Feature execution lifecycle coordination + */ + +import path from 'path'; +import type { Feature } from '@automaker/types'; +import { createLogger, classifyError, loadContextFiles, recordMemoryUsage } from '@automaker/utils'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import { getFeatureDir } from '@automaker/platform'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; +import { validateWorkingDirectory } from '../lib/sdk-options.js'; +import { extractSummary } from './spec-parser.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; +import type { WorktreeResolver } from './worktree-resolver.js'; +import type { SettingsService } from './settings-service.js'; +import { pipelineService } from './pipeline-service.js'; + +// Re-export callback types from execution-types.ts for backward compatibility +export type { + RunAgentFn, + ExecutePipelineFn, + UpdateFeatureStatusFn, + LoadFeatureFn, + GetPlanningPromptPrefixFn, + SaveFeatureSummaryFn, + RecordLearningsFn, + ContextExistsFn, + ResumeFeatureFn, + TrackFailureFn, + SignalPauseFn, + RecordSuccessFn, + SaveExecutionStateFn, + LoadContextFilesFn, +} from './execution-types.js'; + +import type { + RunAgentFn, + ExecutePipelineFn, + UpdateFeatureStatusFn, + LoadFeatureFn, + GetPlanningPromptPrefixFn, + SaveFeatureSummaryFn, + RecordLearningsFn, + ContextExistsFn, + ResumeFeatureFn, + TrackFailureFn, + SignalPauseFn, + RecordSuccessFn, + SaveExecutionStateFn, + LoadContextFilesFn, +} from './execution-types.js'; + +const logger = createLogger('ExecutionService'); + +/** Marker written by agent-executor for each tool invocation. */ +const TOOL_USE_MARKER = '🔧 Tool:'; + +/** Minimum trimmed output length to consider agent work meaningful. */ +const MIN_MEANINGFUL_OUTPUT_LENGTH = 200; + +export class ExecutionService { + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private worktreeResolver: WorktreeResolver, + private settingsService: SettingsService | null, + // Callback dependencies for delegation + private runAgentFn: RunAgentFn, + private executePipelineFn: ExecutePipelineFn, + private updateFeatureStatusFn: UpdateFeatureStatusFn, + private loadFeatureFn: LoadFeatureFn, + private getPlanningPromptPrefixFn: GetPlanningPromptPrefixFn, + private saveFeatureSummaryFn: SaveFeatureSummaryFn, + private recordLearningsFn: RecordLearningsFn, + private contextExistsFn: ContextExistsFn, + private resumeFeatureFn: ResumeFeatureFn, + private trackFailureFn: TrackFailureFn, + private signalPauseFn: SignalPauseFn, + private recordSuccessFn: RecordSuccessFn, + private saveExecutionStateFn: SaveExecutionStateFn, + private loadContextFilesFn: LoadContextFilesFn + ) {} + + private acquireRunningFeature(options: { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + }): RunningFeature { + return this.concurrencyManager.acquire(options); + } + + private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { + this.concurrencyManager.release(featureId, options); + } + + private extractTitleFromDescription(description: string | undefined): string { + if (!description?.trim()) return 'Untitled Feature'; + const firstLine = description.split('\n')[0].trim(); + return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; + } + + /** + * Build feature description section (without implementation instructions). + * Used when planning mode is active — the planning prompt provides its own instructions. + */ + buildFeatureDescription(feature: Feature): string { + const title = this.extractTitleFromDescription(feature.description); + + let prompt = `## Feature 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`; + } + + return prompt; + } + + buildFeaturePrompt( + feature: Feature, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } + ): string { + let prompt = this.buildFeatureDescription(feature); + + prompt += feature.skipTests + ? `\n${taskExecutionPrompts.implementationInstructions}` + : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; + return prompt; + } + + async executeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + isAutoMode = false, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } + ): Promise { + const tempRunningFeature = this.acquireRunningFeature({ + featureId, + projectPath, + isAutoMode, + allowReuse: options?._calledInternally, + }); + const abortController = tempRunningFeature.abortController; + if (isAutoMode) await this.saveExecutionStateFn(projectPath); + let feature: Feature | null = null; + let pipelineCompleted = false; + + try { + validateWorkingDirectory(projectPath); + feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + + // Update status to in_progress immediately after acquiring the feature. + // This prevents a race condition where the UI reloads features and sees the + // feature still in 'backlog' status while it's actually being executed. + // Only do this for the initial call (not internal/recursive calls which would + // redundantly update the status). + if ( + !options?._calledInternally && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted') + ) { + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + } + + if (!options?.continuationPrompt) { + if (feature.planSpec?.status === 'approved') { + const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); + let continuationPrompt = prompts.taskExecution.continuationAfterApprovalTemplate; + continuationPrompt = continuationPrompt + .replace(/\{\{userFeedback\}\}/g, '') + .replace(/\{\{approvedPlan\}\}/g, feature.planSpec.content || ''); + return await this.executeFeature( + projectPath, + featureId, + useWorktrees, + isAutoMode, + providedWorktreePath, + { continuationPrompt, _calledInternally: true } + ); + } + if (await this.contextExistsFn(projectPath, featureId)) { + return await this.resumeFeatureFn(projectPath, featureId, useWorktrees, true); + } + } + + let worktreePath: string | null = providedWorktreePath ?? null; + const branchName = feature.branchName; + if (!worktreePath && useWorktrees && branchName) { + worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); + if (!worktreePath) { + throw new Error( + `Worktree enabled but no worktree found for feature branch "${branchName}".` + ); + } + logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + tempRunningFeature.worktreePath = worktreePath; + tempRunningFeature.branchName = branchName ?? null; + // Ensure status is in_progress (may already be set from the early update above, + // but internal/recursive calls skip the early update and need it here). + // Mirror the external guard: only transition when the feature is still in + // backlog, ready, or interrupted to avoid overwriting a concurrent terminal status. + if ( + options?._calledInternally && + (feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted') + ) { + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + } + this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: feature.branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Loading...', + description: feature.description || 'Feature is starting', + }, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[ExecutionService]' + ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[ExecutionService]' + ); + const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]'); + let prompt: string; + const contextResult = await this.loadContextFilesFn({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + taskContext: { + title: feature.title ?? '', + description: feature.description ?? '', + }, + }); + const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + + if (options?.continuationPrompt) { + prompt = options.continuationPrompt; + } else { + const planningPrefix = await this.getPlanningPromptPrefixFn(feature); + if (planningPrefix) { + // Planning mode active: use planning instructions + feature description only. + // Do NOT include implementationInstructions — they conflict with the planning + // prompt's "DO NOT proceed with implementation until approval" directive. + prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature); + } else { + prompt = this.buildFeaturePrompt(feature, prompts.taskExecution); + } + if (feature.planningMode && feature.planningMode !== 'skip') { + this.eventBus.emitAutoModeEvent('planning_started', { + featureId: feature.id, + mode: feature.planningMode, + message: `Starting ${feature.planningMode} planning phase`, + }); + } + } + + const imagePaths = feature.imagePaths?.map((img) => + typeof img === 'string' ? img : img.path + ); + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + tempRunningFeature.model = model; + tempRunningFeature.provider = ProviderFactory.getProviderNameForModel(model); + + await this.runAgentFn( + workDir, + featureId, + prompt, + abortController, + projectPath, + imagePaths, + model, + { + projectPath, + planningMode: feature.planningMode, + requirePlanApproval: feature.requirePlanApproval, + systemPrompt: combinedSystemPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + providerId: feature.providerId, + branchName: feature.branchName ?? null, + } + ); + + // Check for incomplete tasks after agent execution. + // The agent may have finished early (hit max turns, decided it was done, etc.) + // while tasks are still pending. If so, re-run the agent to complete remaining tasks. + const MAX_TASK_RETRY_ATTEMPTS = 3; + let taskRetryAttempts = 0; + while (!abortController.signal.aborted && taskRetryAttempts < MAX_TASK_RETRY_ATTEMPTS) { + const currentFeature = await this.loadFeatureFn(projectPath, featureId); + if (!currentFeature?.planSpec?.tasks) break; + + const pendingTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (pendingTasks.length === 0) break; + + taskRetryAttempts++; + const totalTasks = currentFeature.planSpec.tasks.length; + const completedTasks = currentFeature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + logger.info( + `[executeFeature] Feature ${featureId} has ${pendingTasks.length} incomplete tasks (${completedTasks}/${totalTasks} completed). Re-running agent (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})` + ); + + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName: feature.branchName ?? null, + content: `Agent finished with ${pendingTasks.length} tasks remaining. Re-running to complete tasks (attempt ${taskRetryAttempts}/${MAX_TASK_RETRY_ATTEMPTS})...`, + projectPath, + }); + + // Build a continuation prompt that tells the agent to finish remaining tasks + const remainingTasksList = pendingTasks + .map((t) => `- ${t.id}: ${t.description} (${t.status})`) + .join('\n'); + + const continuationPrompt = `## Continue Implementation - Incomplete Tasks + +The previous agent session ended before all tasks were completed. Please continue implementing the remaining tasks. + +**Completed:** ${completedTasks}/${totalTasks} tasks +**Remaining tasks:** +${remainingTasksList} + +Please continue from where you left off and complete all remaining tasks. Use the same [TASK_START:ID] and [TASK_COMPLETE:ID] markers for each task.`; + + await this.runAgentFn( + workDir, + featureId, + continuationPrompt, + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + systemPrompt: combinedSystemPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + providerId: feature.providerId, + branchName: feature.branchName ?? null, + } + ); + } + + // Log if tasks are still incomplete after retry attempts + if (taskRetryAttempts >= MAX_TASK_RETRY_ATTEMPTS) { + const finalFeature = await this.loadFeatureFn(projectPath, featureId); + const stillPending = finalFeature?.planSpec?.tasks?.filter( + (t) => t.status === 'pending' || t.status === 'in_progress' + ); + if (stillPending && stillPending.length > 0) { + logger.warn( + `[executeFeature] Feature ${featureId} still has ${stillPending.length} incomplete tasks after ${MAX_TASK_RETRY_ATTEMPTS} retry attempts. Moving to final status.` + ); + } + } + + const pipelineConfig = await pipelineService.getPipelineConfig(projectPath); + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + const sortedSteps = [...(pipelineConfig?.steps || [])] + .sort((a, b) => a.order - b.order) + .filter((step) => !excludedStepIds.has(step.id)); + if (sortedSteps.length > 0) { + await this.executePipelineFn({ + projectPath, + featureId, + feature, + steps: sortedSteps, + workDir, + worktreePath, + branchName: feature.branchName ?? null, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + testAttempts: 0, + maxTestAttempts: 5, + }); + pipelineCompleted = true; + // Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it + const refreshed = await this.loadFeatureFn(projectPath, featureId); + if (refreshed?.status === 'merge_conflict') { + return; + } + } + + // Read agent output before determining final status. + // CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing + // meaningful work. Check output to avoid prematurely marking as 'verified'. + const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let agentOutput = ''; + try { + agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string; + } catch { + /* */ + } + + // Determine if the agent did meaningful work by checking for tool usage + // indicators in the output. The agent executor writes "🔧 Tool:" markers + // each time a tool is invoked. No tool usage suggests the CLI exited + // without performing implementation work. + const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER); + const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH; + const agentDidWork = hasToolUsage && !isOutputTooShort; + + let finalStatus: 'verified' | 'waiting_approval'; + if (feature.skipTests) { + finalStatus = 'waiting_approval'; + } else if (!agentDidWork) { + // Agent didn't produce meaningful output (e.g., CLI exited quickly). + // Route to waiting_approval so the user can review and re-run. + finalStatus = 'waiting_approval'; + logger.warn( + `[executeFeature] Feature ${featureId}: agent produced insufficient output ` + + `(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` + + `Setting status to waiting_approval instead of verified.` + ); + } else { + finalStatus = 'verified'; + } + + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + this.recordSuccessFn(); + + // Check final task completion state for accurate reporting + const completedFeature = await this.loadFeatureFn(projectPath, featureId); + const totalTasks = completedFeature?.planSpec?.tasks?.length ?? 0; + const completedTasks = + completedFeature?.planSpec?.tasks?.filter((t) => t.status === 'completed').length ?? 0; + const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; + + try { + // Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps) + // This prevents overwriting accumulated summaries with just the last step's output + // The agent-executor already extracts and saves summaries during execution + if (agentOutput && !completedFeature?.summary) { + const summary = extractSummary(agentOutput); + if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary); + } + if (contextResult.memoryFiles.length > 0 && agentOutput) { + await recordMemoryUsage( + projectPath, + contextResult.memoryFiles, + agentOutput, + true, + secureFs as Parameters[4] + ); + } + await this.recordLearningsFn(projectPath, feature, agentOutput); + } catch { + /* learnings recording failed */ + } + + const elapsedSeconds = Math.round((Date.now() - tempRunningFeature.startTime) / 1000); + let completionMessage = `Feature completed in ${elapsedSeconds}s`; + if (finalStatus === 'verified') completionMessage += ' - auto-verified'; + if (hasIncompleteTasks) + completionMessage += ` (${completedTasks}/${totalTasks} tasks completed)`; + + if (isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: completionMessage, + projectPath, + model: tempRunningFeature.model, + provider: tempRunningFeature.provider, + }); + } + } catch (error) { + const errorInfo = classifyError(error); + if (errorInfo.isAbort) { + await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); + if (isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + executionMode: 'auto', + passes: false, + message: 'Feature stopped by user', + projectPath, + }); + } + } else { + logger.error(`Feature ${featureId} failed:`, error); + // If pipeline steps completed successfully, don't send the feature back to backlog. + // The pipeline work is done — set to waiting_approval so the user can review. + const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog'; + if (pipelineCompleted) { + logger.info( + `[executeFeature] Feature ${featureId} failed after pipeline completed. ` + + `Setting status to waiting_approval instead of backlog to preserve pipeline work.` + ); + } + // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution + let currentStatus: string | undefined; + try { + const currentFeature = await this.loadFeatureFn(projectPath, featureId); + currentStatus = currentFeature?.status; + } catch (loadErr) { + // If loading fails, log it and proceed with the status update anyway + logger.warn( + `[executeFeature] Failed to reload feature ${featureId} for status check:`, + loadErr + ); + } + if (currentStatus !== 'merge_conflict') { + await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus); + } + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: feature?.title, + branchName: feature?.branchName ?? null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + if (this.trackFailureFn({ type: errorInfo.type, message: errorInfo.message })) { + this.signalPauseFn({ type: errorInfo.type, message: errorInfo.message }); + } + } + } finally { + this.releaseRunningFeature(featureId); + if (isAutoMode && projectPath) await this.saveExecutionStateFn(projectPath); + } + } + + async stopFeature(featureId: string): Promise { + const running = this.concurrencyManager.getRunningFeature(featureId); + if (!running) return false; + const { projectPath } = running; + + // Immediately update feature status to 'interrupted' so the UI reflects + // the stop right away. CLI-based providers can take seconds to terminate + // their subprocess after the abort signal fires, leaving the feature stuck + // in 'in_progress' on the Kanban board until the executeFeature catch block + // eventually runs. By persisting and emitting the status change here, the + // board updates immediately regardless of how long the subprocess takes to stop. + try { + await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted'); + } catch (err) { + // Non-fatal: the abort still proceeds and executeFeature's catch block + // will attempt the same update once the subprocess terminates. + logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err); + } + + running.abortController.abort(); + this.releaseRunningFeature(featureId, { force: true }); + return true; + } +} diff --git a/jules_branch/apps/server/src/services/execution-types.ts b/jules_branch/apps/server/src/services/execution-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..765098ba1135bc3fdd8d7719c2a11e328f547d2b --- /dev/null +++ b/jules_branch/apps/server/src/services/execution-types.ts @@ -0,0 +1,215 @@ +/** + * Execution Types - Type definitions for ExecutionService and related services + * + * Contains callback types used by ExecutionService for dependency injection, + * allowing the service to delegate to other services without circular dependencies. + */ + +import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import type { loadContextFiles } from '@automaker/utils'; +import type { PipelineContext } from './pipeline-orchestrator.js'; + +// ============================================================================= +// ExecutionService Callback Types +// ============================================================================= + +/** + * Function to run the agent with a prompt + */ +export type RunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: { + projectPath?: string; + planningMode?: PlanningMode; + requirePlanApproval?: boolean; + previousContent?: string; + systemPrompt?: string; + autoLoadClaudeMd?: boolean; + useClaudeCodeSystemPrompt?: boolean; + thinkingLevel?: ThinkingLevel; + reasoningEffort?: ReasoningEffort; + providerId?: string; + branchName?: string | null; + } +) => Promise; + +/** + * Function to execute pipeline steps + */ +export type ExecutePipelineFn = (context: PipelineContext) => Promise; + +/** + * Function to update feature status + */ +export type UpdateFeatureStatusFn = ( + projectPath: string, + featureId: string, + status: string +) => Promise; + +/** + * Function to load a feature by ID + */ +export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise; + +/** + * Function to get the planning prompt prefix based on feature's planning mode + */ +export type GetPlanningPromptPrefixFn = (feature: Feature) => Promise; + +/** + * Function to save a feature summary + */ +export type SaveFeatureSummaryFn = ( + projectPath: string, + featureId: string, + summary: string +) => Promise; + +/** + * Function to record learnings from a completed feature + */ +export type RecordLearningsFn = ( + projectPath: string, + feature: Feature, + agentOutput: string +) => Promise; + +/** + * Function to check if context exists for a feature + */ +export type ContextExistsFn = (projectPath: string, featureId: string) => Promise; + +/** + * Function to resume a feature (continues from saved context or starts fresh) + */ +export type ResumeFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + _calledInternally: boolean +) => Promise; + +/** + * Function to track failure and check if pause threshold is reached + * Returns true if auto-mode should pause + */ +export type TrackFailureFn = (errorInfo: { type: string; message: string }) => boolean; + +/** + * Function to signal that auto-mode should pause due to failures + */ +export type SignalPauseFn = (errorInfo: { type: string; message: string }) => void; + +/** + * Function to record a successful execution (resets failure tracking) + */ +export type RecordSuccessFn = () => void; + +/** + * Function to save execution state + */ +export type SaveExecutionStateFn = (projectPath: string) => Promise; + +/** + * Type alias for loadContextFiles function + */ +export type LoadContextFilesFn = typeof loadContextFiles; + +// ============================================================================= +// PipelineOrchestrator Callback Types +// ============================================================================= + +/** + * Function to build feature prompt + */ +export type BuildFeaturePromptFn = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } +) => string; + +/** + * Function to execute a feature + */ +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } +) => Promise; + +/** + * Function to run agent (for PipelineOrchestrator) + */ +export type PipelineRunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: Record +) => Promise; + +// ============================================================================= +// AutoLoopCoordinator Callback Types +// ============================================================================= + +/** + * Function to execute a feature in auto-loop + */ +export type AutoLoopExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean +) => Promise; + +/** + * Function to load pending features for a worktree + */ +export type LoadPendingFeaturesFn = ( + projectPath: string, + branchName: string | null +) => Promise; + +/** + * Function to save execution state for auto-loop + */ +export type AutoLoopSaveExecutionStateFn = ( + projectPath: string, + branchName: string | null, + maxConcurrency: number +) => Promise; + +/** + * Function to clear execution state + */ +export type ClearExecutionStateFn = ( + projectPath: string, + branchName: string | null +) => Promise; + +/** + * Function to reset stuck features + */ +export type ResetStuckFeaturesFn = (projectPath: string) => Promise; + +/** + * Function to check if a feature is finished + */ +export type IsFeatureFinishedFn = (feature: Feature) => boolean; + +/** + * Function to check if a feature is running + */ +export type IsFeatureRunningFn = (featureId: string) => boolean; diff --git a/jules_branch/apps/server/src/services/feature-export-service.ts b/jules_branch/apps/server/src/services/feature-export-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd741dc2280b176f53f10f1f8cbbb5117faffd6b --- /dev/null +++ b/jules_branch/apps/server/src/services/feature-export-service.ts @@ -0,0 +1,539 @@ +/** + * Feature Export Service - Handles exporting and importing features in JSON/YAML formats + * + * Provides functionality to: + * - Export single features to JSON or YAML format + * - Export multiple features (bulk export) + * - Import features from JSON or YAML data + * - Validate import data for compatibility + */ + +import { createLogger } from '@automaker/utils'; +import { stringify as yamlStringify, parse as yamlParse } from 'yaml'; +import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types'; +import { FeatureLoader } from './feature-loader.js'; + +const logger = createLogger('FeatureExportService'); + +/** Current export format version */ +export const FEATURE_EXPORT_VERSION = '1.0.0'; + +/** Supported export formats */ +export type ExportFormat = 'json' | 'yaml'; + +/** Options for exporting features */ +export interface ExportOptions { + /** Format to export in (default: 'json') */ + format?: ExportFormat; + /** Whether to include description history (default: true) */ + includeHistory?: boolean; + /** Whether to include plan spec (default: true) */ + includePlanSpec?: boolean; + /** Optional metadata to include */ + metadata?: { + projectName?: string; + projectPath?: string; + branch?: string; + [key: string]: unknown; + }; + /** Who/what is performing the export */ + exportedBy?: string; + /** Pretty print output (default: true) */ + prettyPrint?: boolean; +} + +/** Options for bulk export */ +export interface BulkExportOptions extends ExportOptions { + /** Filter by category */ + category?: string; + /** Filter by status */ + status?: string; + /** Feature IDs to include (if not specified, exports all) */ + featureIds?: string[]; +} + +/** Result of a bulk export */ +export interface BulkExportResult { + /** Export format version */ + version: string; + /** ISO date string when the export was created */ + exportedAt: string; + /** Number of features exported */ + count: number; + /** The exported features */ + features: FeatureExport[]; + /** Export metadata */ + metadata?: { + projectName?: string; + projectPath?: string; + branch?: string; + [key: string]: unknown; + }; +} + +/** + * FeatureExportService - Manages feature export and import operations + */ +export class FeatureExportService { + private featureLoader: FeatureLoader; + + constructor(featureLoader?: FeatureLoader) { + this.featureLoader = featureLoader || new FeatureLoader(); + } + + /** + * Export a single feature to the specified format + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to export + * @param options - Export options + * @returns Promise resolving to the exported feature string + */ + async exportFeature( + projectPath: string, + featureId: string, + options: ExportOptions = {} + ): Promise { + const feature = await this.featureLoader.get(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + return this.exportFeatureData(feature, options); + } + + /** + * Export feature data to the specified format (without fetching from disk) + * + * @param feature - The feature to export + * @param options - Export options + * @returns The exported feature string + */ + exportFeatureData(feature: Feature, options: ExportOptions = {}): string { + const { + format = 'json', + includeHistory = true, + includePlanSpec = true, + metadata, + exportedBy, + prettyPrint = true, + } = options; + + // Prepare feature data, optionally excluding some fields + const featureData = this.prepareFeatureForExport(feature, { + includeHistory, + includePlanSpec, + }); + + const exportData: FeatureExport = { + version: FEATURE_EXPORT_VERSION, + feature: featureData, + exportedAt: new Date().toISOString(), + ...(exportedBy ? { exportedBy } : {}), + ...(metadata ? { metadata } : {}), + }; + + return this.serialize(exportData, format, prettyPrint); + } + + /** + * Export multiple features to the specified format + * + * @param projectPath - Path to the project + * @param options - Bulk export options + * @returns Promise resolving to the exported features string + */ + async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise { + const { + format = 'json', + category, + status, + featureIds, + includeHistory = true, + includePlanSpec = true, + metadata, + prettyPrint = true, + } = options; + + // Get all features + let features = await this.featureLoader.getAll(projectPath); + + // Apply filters + if (featureIds && featureIds.length > 0) { + const idSet = new Set(featureIds); + features = features.filter((f) => idSet.has(f.id)); + } + if (category) { + features = features.filter((f) => f.category === category); + } + if (status) { + features = features.filter((f) => f.status === status); + } + + // Generate timestamp once for consistent export time across all features + const exportedAt = new Date().toISOString(); + + // Prepare feature exports + const featureExports: FeatureExport[] = features.map((feature) => ({ + version: FEATURE_EXPORT_VERSION, + feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }), + exportedAt, + })); + + const bulkExport: BulkExportResult = { + version: FEATURE_EXPORT_VERSION, + exportedAt, + count: featureExports.length, + features: featureExports, + ...(metadata ? { metadata } : {}), + }; + + logger.info(`Exported ${featureExports.length} features from ${projectPath}`); + + return this.serialize(bulkExport, format, prettyPrint); + } + + /** + * Import a feature from JSON or YAML data + * + * @param projectPath - Path to the project + * @param importData - Import configuration + * @returns Promise resolving to the import result + */ + async importFeature( + projectPath: string, + importData: FeatureImport + ): Promise { + const warnings: string[] = []; + + try { + // Extract feature from data (handle both raw Feature and wrapped FeatureExport) + const feature = this.extractFeatureFromImport(importData.data); + if (!feature) { + return { + success: false, + importedAt: new Date().toISOString(), + errors: ['Invalid import data: could not extract feature'], + }; + } + + // Validate required fields + const validationErrors = this.validateFeature(feature); + if (validationErrors.length > 0) { + return { + success: false, + importedAt: new Date().toISOString(), + errors: validationErrors, + }; + } + + // Determine the feature ID to use + const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId(); + + // Check for existing feature + const existingFeature = await this.featureLoader.get(projectPath, featureId); + if (existingFeature && !importData.overwrite) { + return { + success: false, + importedAt: new Date().toISOString(), + errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`], + }; + } + + // Prepare feature for import + const featureToImport: Feature = { + ...feature, + id: featureId, + // Optionally override category + ...(importData.targetCategory ? { category: importData.targetCategory } : {}), + // Clear branch info if not preserving + ...(importData.preserveBranchInfo ? {} : { branchName: undefined }), + }; + + // Clear runtime-specific fields that shouldn't be imported + delete featureToImport.titleGenerating; + delete featureToImport.error; + + // Handle image paths - they won't be valid after import + if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) { + warnings.push( + `Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.` + ); + featureToImport.imagePaths = []; + } + + // Handle text file paths - they won't be valid after import + if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) { + warnings.push( + `Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.` + ); + featureToImport.textFilePaths = []; + } + + // Create or update the feature + if (existingFeature) { + await this.featureLoader.update(projectPath, featureId, featureToImport); + logger.info(`Updated feature ${featureId} via import`); + } else { + await this.featureLoader.create(projectPath, featureToImport); + logger.info(`Created feature ${featureId} via import`); + } + + return { + success: true, + featureId, + importedAt: new Date().toISOString(), + warnings: warnings.length > 0 ? warnings : undefined, + wasOverwritten: !!existingFeature, + }; + } catch (error) { + logger.error('Failed to import feature:', error); + return { + success: false, + importedAt: new Date().toISOString(), + errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`], + }; + } + } + + /** + * Import multiple features from JSON or YAML data + * + * @param projectPath - Path to the project + * @param data - Raw JSON or YAML string, or parsed data + * @param options - Import options applied to all features + * @returns Promise resolving to array of import results + */ + async importFeatures( + projectPath: string, + data: string | BulkExportResult, + options: Omit = {} + ): Promise { + let bulkData: BulkExportResult; + + // Parse if string + if (typeof data === 'string') { + const parsed = this.parseImportData(data); + if (!parsed || !this.isBulkExport(parsed)) { + return [ + { + success: false, + importedAt: new Date().toISOString(), + errors: ['Invalid bulk import data: expected BulkExportResult format'], + }, + ]; + } + bulkData = parsed as BulkExportResult; + } else { + bulkData = data; + } + + // Import each feature + const results: FeatureImportResult[] = []; + for (const featureExport of bulkData.features) { + const result = await this.importFeature(projectPath, { + data: featureExport, + ...options, + }); + results.push(result); + } + + const successCount = results.filter((r) => r.success).length; + logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`); + + return results; + } + + /** + * Parse import data from JSON or YAML string + * + * @param data - Raw JSON or YAML string + * @returns Parsed data or null if parsing fails + */ + parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null { + const trimmed = data.trim(); + + // Try JSON first + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return JSON.parse(trimmed); + } catch { + // Fall through to YAML + } + } + + // Try YAML + try { + return yamlParse(trimmed); + } catch (error) { + logger.error('Failed to parse import data:', error); + return null; + } + } + + /** + * Detect the format of import data + * + * @param data - Raw string data + * @returns Detected format or null if unknown + */ + detectFormat(data: string): ExportFormat | null { + const trimmed = data.trim(); + + // JSON detection + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + JSON.parse(trimmed); + return 'json'; + } catch { + // Not valid JSON + } + } + + // YAML detection (if it parses and wasn't JSON) + try { + yamlParse(trimmed); + return 'yaml'; + } catch { + // Not valid YAML either + } + + return null; + } + + /** + * Prepare a feature for export by optionally removing fields + */ + private prepareFeatureForExport( + feature: Feature, + options: { includeHistory?: boolean; includePlanSpec?: boolean } + ): Feature { + const { includeHistory = true, includePlanSpec = true } = options; + + // Clone to avoid modifying original + const exported: Feature = { ...feature }; + + // Remove transient fields that shouldn't be exported + delete exported.titleGenerating; + delete exported.error; + + // Optionally exclude history + if (!includeHistory) { + delete exported.descriptionHistory; + } + + // Optionally exclude plan spec + if (!includePlanSpec) { + delete exported.planSpec; + } + + return exported; + } + + /** + * Extract a Feature from import data (handles both raw and wrapped formats) + */ + private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null { + if (!data || typeof data !== 'object') { + return null; + } + + // Check if it's a FeatureExport wrapper + if ('version' in data && 'feature' in data && 'exportedAt' in data) { + const exportData = data as FeatureExport; + return exportData.feature; + } + + // Assume it's a raw Feature + return data as Feature; + } + + /** + * Check if parsed data is a bulk export + */ + isBulkExport(data: unknown): data is BulkExportResult { + if (!data || typeof data !== 'object') { + return false; + } + const obj = data as Record; + return 'version' in obj && 'features' in obj && Array.isArray(obj.features); + } + + /** + * Check if parsed data is a single FeatureExport + */ + isFeatureExport(data: unknown): data is FeatureExport { + if (!data || typeof data !== 'object') { + return false; + } + const obj = data as Record; + return ( + 'version' in obj && + 'feature' in obj && + 'exportedAt' in obj && + typeof obj.feature === 'object' && + obj.feature !== null && + 'id' in (obj.feature as Record) + ); + } + + /** + * Check if parsed data is a raw Feature + */ + isRawFeature(data: unknown): data is Feature { + if (!data || typeof data !== 'object') { + return false; + } + const obj = data as Record; + // A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport + return 'id' in obj && !('feature' in obj && 'version' in obj); + } + + /** + * Validate a feature has required fields + */ + private validateFeature(feature: Feature): string[] { + const errors: string[] = []; + + if (!feature.description && !feature.title) { + errors.push('Feature must have at least a title or description'); + } + + if (!feature.category) { + errors.push('Feature must have a category'); + } + + return errors; + } + + /** + * Serialize export data to string (handles both single feature and bulk exports) + */ + private serialize( + data: T, + format: ExportFormat, + prettyPrint: boolean + ): string { + if (format === 'yaml') { + return yamlStringify(data, { + indent: 2, + lineWidth: 120, + }); + } + + return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data); + } +} + +// Singleton instance +let featureExportServiceInstance: FeatureExportService | null = null; + +/** + * Get the singleton feature export service instance + */ +export function getFeatureExportService(): FeatureExportService { + if (!featureExportServiceInstance) { + featureExportServiceInstance = new FeatureExportService(); + } + return featureExportServiceInstance; +} diff --git a/jules_branch/apps/server/src/services/feature-loader.ts b/jules_branch/apps/server/src/services/feature-loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..31eca4a7a86089147ab68eae9bd512e6c100ced2 --- /dev/null +++ b/jules_branch/apps/server/src/services/feature-loader.ts @@ -0,0 +1,623 @@ +/** + * Feature Loader - Handles loading and managing features from individual feature folders + * Each feature is stored in .automaker/features/{featureId}/feature.json + */ + +import path from 'path'; +import type { Feature, DescriptionHistoryEntry } from '@automaker/types'; +import { + createLogger, + atomicWriteJson, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, +} from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getFeaturesDir, + getFeatureDir, + getFeatureImagesDir, + getAppSpecPath, + ensureAutomakerDir, +} from '@automaker/platform'; +import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js'; + +const logger = createLogger('FeatureLoader'); + +// Re-export Feature type for convenience +export type { Feature }; + +export class FeatureLoader { + /** + * Get the features directory path + */ + getFeaturesDir(projectPath: string): string { + return getFeaturesDir(projectPath); + } + + /** + * Get the images directory path for a feature + */ + getFeatureImagesDir(projectPath: string, featureId: string): string { + return getFeatureImagesDir(projectPath, featureId); + } + + /** + * Delete images that were removed from a feature + */ + private async deleteOrphanedImages( + projectPath: string, + oldPaths: Array | undefined, + newPaths: Array | undefined + ): Promise { + if (!oldPaths || oldPaths.length === 0) { + return; + } + + // Build sets of paths for comparison + const oldPathSet = new Set(oldPaths.map((p) => (typeof p === 'string' ? p : p.path))); + const newPathSet = new Set((newPaths || []).map((p) => (typeof p === 'string' ? p : p.path))); + + // Find images that were removed + for (const oldPath of oldPathSet) { + if (!newPathSet.has(oldPath)) { + try { + // Paths are now absolute + await secureFs.unlink(oldPath); + logger.info(`Deleted orphaned image: ${oldPath}`); + } catch (error) { + // Ignore errors when deleting (file may already be gone) + logger.warn(`Failed to delete image: ${oldPath}`, error); + } + } + } + } + + /** + * Copy images from temp directory to feature directory and update paths + */ + private async migrateImages( + projectPath: string, + featureId: string, + imagePaths?: Array + ): Promise | undefined> { + if (!imagePaths || imagePaths.length === 0) { + return imagePaths; + } + + const featureImagesDir = this.getFeatureImagesDir(projectPath, featureId); + await secureFs.mkdir(featureImagesDir, { recursive: true }); + + const updatedPaths: Array = []; + + for (const imagePath of imagePaths) { + try { + const originalPath = typeof imagePath === 'string' ? imagePath : imagePath.path; + + // Skip if already in feature directory (already absolute path in external storage) + if (originalPath.includes(`/features/${featureId}/images/`)) { + updatedPaths.push(imagePath); + continue; + } + + // Resolve the full path + const fullOriginalPath = path.isAbsolute(originalPath) + ? originalPath + : path.join(projectPath, originalPath); + + // Check if file exists + try { + await secureFs.access(fullOriginalPath); + } catch { + logger.warn(`Image not found, skipping: ${fullOriginalPath}`); + continue; + } + + // Get filename and create new path in external storage + const filename = path.basename(originalPath); + const newPath = path.join(featureImagesDir, filename); + + // Copy the file + await secureFs.copyFile(fullOriginalPath, newPath); + logger.info(`Copied image: ${originalPath} -> ${newPath}`); + + // Try to delete the original temp file + try { + await secureFs.unlink(fullOriginalPath); + } catch { + // Ignore errors when deleting temp file + } + + // Update the path in the result (use absolute path) + if (typeof imagePath === 'string') { + updatedPaths.push(newPath); + } else { + updatedPaths.push({ ...imagePath, path: newPath }); + } + } catch (error) { + logger.error(`Failed to migrate image:`, error); + // Rethrow error to let caller decide how to handle it + // Keeping original path could lead to broken references + throw error; + } + } + + return updatedPaths; + } + + /** + * Get the path to a specific feature folder + */ + getFeatureDir(projectPath: string, featureId: string): string { + return getFeatureDir(projectPath, featureId); + } + + /** + * Get the path to a feature's feature.json file + */ + getFeatureJsonPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), 'feature.json'); + } + + /** + * Get the path to a feature's agent-output.md file + */ + getAgentOutputPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), 'agent-output.md'); + } + + /** + * Get the path to a feature's raw-output.jsonl file + */ + getRawOutputPath(projectPath: string, featureId: string): string { + return path.join(this.getFeatureDir(projectPath, featureId), 'raw-output.jsonl'); + } + + /** + * Generate a new feature ID + */ + generateFeatureId(): string { + return `feature-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + /** + * Get all features for a project + */ + async getAll(projectPath: string): Promise { + try { + const featuresDir = this.getFeaturesDir(projectPath); + + // Check if features directory exists + try { + await secureFs.access(featuresDir); + } catch { + return []; + } + + // Read all feature directories + // secureFs.readdir returns Dirent[] but typed as generic; cast to access isDirectory() + const entries = (await secureFs.readdir(featuresDir, { + withFileTypes: true, + })) as import('fs').Dirent[]; + const featureDirs = entries.filter((entry) => entry.isDirectory()); + + // Load all features concurrently with automatic recovery from backups + const featurePromises = featureDirs.map(async (dir) => { + const featureId = dir.name; + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + // Use recovery-enabled read to handle corrupted files + const result = await readJsonWithRecovery(featureJsonPath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + + if (!feature) { + return null; + } + + if (!feature.id) { + logger.warn(`Feature ${featureId} missing required 'id' field, skipping`); + return null; + } + + // Clear transient runtime flag - titleGenerating is only meaningful during + // the current session's async title generation. If it was persisted (e.g., + // app closed before generation completed), it would cause the UI to show + // "Generating title..." indefinitely. + if (feature.titleGenerating) { + delete feature.titleGenerating; + } + + return feature; + }); + + const results = await Promise.all(featurePromises); + const features = results.filter((f): f is Feature => f !== null); + + // Sort by creation order (feature IDs contain timestamp) + features.sort((a, b) => { + const aTime = a.id ? parseInt(a.id.split('-')[1] || '0') : 0; + const bTime = b.id ? parseInt(b.id.split('-')[1] || '0') : 0; + return aTime - bTime; + }); + + return features; + } catch (error) { + logger.error('Failed to get all features:', error); + return []; + } + } + + /** + * Normalize a title for comparison (case-insensitive, trimmed) + */ + private normalizeTitle(title: string): string { + return title.toLowerCase().trim(); + } + + /** + * Find a feature by its title (case-insensitive match) + * @param projectPath - Path to the project + * @param title - Title to search for + * @returns The matching feature or null if not found + */ + async findByTitle(projectPath: string, title: string): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + + /** + * Check if a title already exists on another feature (for duplicate detection) + * @param projectPath - Path to the project + * @param title - Title to check + * @param excludeFeatureId - Optional feature ID to exclude from the check (for updates) + * @returns The duplicate feature if found, null otherwise + */ + async findDuplicateTitle( + projectPath: string, + title: string, + excludeFeatureId?: string + ): Promise { + if (!title || !title.trim()) { + return null; + } + + const normalizedTitle = this.normalizeTitle(title); + const features = await this.getAll(projectPath); + + for (const feature of features) { + // Skip the feature being updated (if provided) + if (excludeFeatureId && feature.id === excludeFeatureId) { + continue; + } + + if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) { + return feature; + } + } + + return null; + } + + /** + * Get a single feature by ID + * Uses automatic recovery from backups if the main file is corrupted + */ + async get(projectPath: string, featureId: string): Promise { + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + // Use recovery-enabled read to handle corrupted files + const result = await readJsonWithRecovery(featureJsonPath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + + // Clear transient runtime flag (same as in getAll) + if (feature?.titleGenerating) { + delete feature.titleGenerating; + } + + return feature; + } + + /** + * Create a new feature + */ + async create(projectPath: string, featureData: Partial): Promise { + const featureId = featureData.id || this.generateFeatureId(); + const featureDir = this.getFeatureDir(projectPath, featureId); + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + + // Ensure automaker directory exists + await ensureAutomakerDir(projectPath); + + // Create feature directory + await secureFs.mkdir(featureDir, { recursive: true }); + + // Migrate images from temp directory to feature directory + const migratedImagePaths = await this.migrateImages( + projectPath, + featureId, + featureData.imagePaths + ); + + // Initialize description history with the initial description + const initialHistory: DescriptionHistoryEntry[] = []; + if (featureData.description && featureData.description.trim()) { + initialHistory.push({ + description: featureData.description, + timestamp: new Date().toISOString(), + source: 'initial', + }); + } + + // Ensure feature has required fields + const feature: Feature = { + category: featureData.category || 'Uncategorized', + description: featureData.description || '', + ...featureData, + id: featureId, + createdAt: featureData.createdAt || new Date().toISOString(), + imagePaths: migratedImagePaths, + descriptionHistory: initialHistory, + }; + + // Remove transient runtime fields before persisting to disk. + // titleGenerating is UI-only state that tracks in-flight async title generation. + // Persisting it can cause cards to show "Generating title..." indefinitely + // if the app restarts before generation completes. + const featureToWrite = { ...feature }; + delete featureToWrite.titleGenerating; + + // Write feature.json atomically with backup support + await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); + + logger.info(`Created feature ${featureId}`); + return feature; + } + + /** + * Update a feature (partial updates supported) + * @param projectPath - Path to the project + * @param featureId - ID of the feature to update + * @param updates - Partial feature updates + * @param descriptionHistorySource - Source of description change ('enhance' or 'edit') + * @param enhancementMode - Enhancement mode if source is 'enhance' + * @param preEnhancementDescription - Description before enhancement (for restoring original) + */ + async update( + projectPath: string, + featureId: string, + updates: Partial, + descriptionHistorySource?: 'enhance' | 'edit', + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' | 'ux-reviewer', + preEnhancementDescription?: string + ): Promise { + const feature = await this.get(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Handle image path changes + let updatedImagePaths = updates.imagePaths; + if (updates.imagePaths !== undefined) { + // Delete orphaned images (images that were removed) + await this.deleteOrphanedImages(projectPath, feature.imagePaths, updates.imagePaths); + + // Migrate any new images + updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths); + } + + // Track description history if description changed + let updatedHistory = feature.descriptionHistory || []; + if ( + updates.description !== undefined && + updates.description !== feature.description && + updates.description.trim() + ) { + const timestamp = new Date().toISOString(); + + // If this is an enhancement and we have the pre-enhancement description, + // add the original text to history first (so user can restore to it) + if ( + descriptionHistorySource === 'enhance' && + preEnhancementDescription && + preEnhancementDescription.trim() + ) { + // Check if this pre-enhancement text is different from the last history entry + const lastEntry = updatedHistory[updatedHistory.length - 1]; + if (!lastEntry || lastEntry.description !== preEnhancementDescription) { + const preEnhanceEntry: DescriptionHistoryEntry = { + description: preEnhancementDescription, + timestamp, + source: updatedHistory.length === 0 ? 'initial' : 'edit', + }; + updatedHistory = [...updatedHistory, preEnhanceEntry]; + } + } + + // Add the new/enhanced description to history + const historyEntry: DescriptionHistoryEntry = { + description: updates.description, + timestamp, + source: descriptionHistorySource || 'edit', + ...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}), + }; + updatedHistory = [...updatedHistory, historyEntry]; + } + + // Merge updates + const updatedFeature: Feature = { + ...feature, + ...updates, + ...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}), + descriptionHistory: updatedHistory, + }; + + // Remove transient runtime fields before persisting (same as create) + const featureToWrite = { ...updatedFeature }; + delete featureToWrite.titleGenerating; + + // Write back to file atomically with backup support + const featureJsonPath = this.getFeatureJsonPath(projectPath, featureId); + await atomicWriteJson(featureJsonPath, featureToWrite, { backupCount: DEFAULT_BACKUP_COUNT }); + + logger.info(`Updated feature ${featureId}`); + return updatedFeature; + } + + /** + * Delete a feature + */ + async delete(projectPath: string, featureId: string): Promise { + try { + const featureDir = this.getFeatureDir(projectPath, featureId); + await secureFs.rm(featureDir, { recursive: true, force: true }); + logger.info(`Deleted feature ${featureId}`); + return true; + } catch (error) { + logger.error(`Failed to delete feature ${featureId}:`, error); + return false; + } + } + + /** + * Get agent output for a feature + */ + async getAgentOutput(projectPath: string, featureId: string): Promise { + try { + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + const content = (await secureFs.readFile(agentOutputPath, 'utf-8')) as string; + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to get agent output for ${featureId}:`, error); + throw error; + } + } + + /** + * Get raw output for a feature (JSONL format for debugging) + */ + async getRawOutput(projectPath: string, featureId: string): Promise { + try { + const rawOutputPath = this.getRawOutputPath(projectPath, featureId); + const content = (await secureFs.readFile(rawOutputPath, 'utf-8')) as string; + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to get raw output for ${featureId}:`, error); + throw error; + } + } + + /** + * Save agent output for a feature + */ + async saveAgentOutput(projectPath: string, featureId: string, content: string): Promise { + const featureDir = this.getFeatureDir(projectPath, featureId); + await secureFs.mkdir(featureDir, { recursive: true }); + + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + await secureFs.writeFile(agentOutputPath, content, 'utf-8'); + } + + /** + * Delete agent output for a feature + */ + async deleteAgentOutput(projectPath: string, featureId: string): Promise { + try { + const agentOutputPath = this.getAgentOutputPath(projectPath, featureId); + await secureFs.unlink(agentOutputPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + + /** + * Sync a completed feature to the app_spec.txt implemented_features section + * + * When a feature is completed, this method adds it to the implemented_features + * section of the project's app_spec.txt file. This keeps the spec in sync + * with the actual state of the codebase. + * + * @param projectPath - Path to the project + * @param feature - The feature to sync (must have title or description) + * @param fileLocations - Optional array of file paths where the feature was implemented + * @returns True if the spec was updated, false if no spec exists or feature was skipped + */ + async syncFeatureToAppSpec( + projectPath: string, + feature: Feature, + fileLocations?: string[] + ): Promise { + try { + const appSpecPath = getAppSpecPath(projectPath); + + // Read the current app_spec.txt + let specContent: string; + try { + specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`); + return false; + } + throw error; + } + + // Build the implemented feature entry + const featureName = feature.title || `Feature: ${feature.id}`; + const implementedFeature: ImplementedFeature = { + name: featureName, + description: feature.description, + ...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}), + }; + + // Add the feature to the implemented_features section + const updatedSpecContent = addImplementedFeature(specContent, implementedFeature); + + // Check if the content actually changed (feature might already exist) + if (updatedSpecContent === specContent) { + logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`); + return false; + } + + // Write the updated spec back to the file + await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8'); + + logger.info(`Synced feature "${featureName}" to app_spec.txt`); + return true; + } catch (error) { + logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error); + throw error; + } + } +} diff --git a/jules_branch/apps/server/src/services/feature-state-manager.ts b/jules_branch/apps/server/src/services/feature-state-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..4500489670c7507c4cf89174b064f911f38d88f1 --- /dev/null +++ b/jules_branch/apps/server/src/services/feature-state-manager.ts @@ -0,0 +1,874 @@ +/** + * 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, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; +import { + atomicWriteJson, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, + createLogger, +} from '@automaker/utils'; +import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { AutoModeEventType } from './typed-event-bus.js'; +import { getNotificationService } from './notification-service.js'; +import { FeatureLoader } from './feature-loader.js'; +import { pipelineService } from './pipeline-service.js'; + +const logger = createLogger('FeatureStateManager'); + +// Notification type constants +const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval'; +const NOTIFICATION_TYPE_VERIFIED = 'feature_verified'; +const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error'; +const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error'; + +// Notification title constants +const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review'; +const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified'; +const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed'; +const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error'; + +/** + * Auto-mode event payload structure + * This is the payload that comes with 'auto-mode:event' events + */ +interface AutoModeEventPayload { + type?: string; + featureId?: string; + featureName?: string; + passes?: boolean; + executionMode?: 'auto' | 'manual'; + message?: string; + error?: string; + errorType?: string; + projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; +} + +/** + * 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; + private unsubscribe: (() => void) | null = null; + + constructor(events: EventEmitter, featureLoader: FeatureLoader) { + this.events = events; + this.featureLoader = featureLoader; + + // Subscribe to error events to create notifications + this.unsubscribe = events.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEventError(payload as AutoModeEventPayload); + } + }); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + } + + /** + * Load a feature from disk with recovery support + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to load + * @returns The feature data, or null if not found/recoverable + */ + async loadFeature(projectPath: string, featureId: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + logRecoveryWarning(result, `Feature ${featureId}`, logger); + return result.data; + } catch { + return null; + } + } + + /** + * Update feature status with proper persistence and event ordering. + * + * IMPORTANT: Persists to disk BEFORE emitting events to prevent stale data + * on client refresh (Pitfall 2 from research). + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to update + * @param status - New status value + */ + async updateFeatureStatus(projectPath: string, featureId: string, status: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + // Use recovery-enabled read for corrupted file handling + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + feature.status = status; + feature.updatedAt = new Date().toISOString(); + + // Handle justFinishedAt timestamp based on status + const shouldSetJustFinishedAt = status === 'waiting_approval'; + const shouldClearJustFinishedAt = status !== 'waiting_approval'; + if (shouldSetJustFinishedAt) { + feature.justFinishedAt = new Date().toISOString(); + } else if (shouldClearJustFinishedAt) { + feature.justFinishedAt = undefined; + } + + // Finalize in-progress tasks when reaching terminal states (waiting_approval or verified) + if (status === 'waiting_approval' || status === 'verified') { + this.finalizeInProgressTasks(feature, featureId, status); + } + + // PERSIST BEFORE EMIT (Pitfall 2) + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit status change event so UI can react without polling + this.emitAutoModeEvent('feature_status_changed', { + featureId, + projectPath, + status, + }); + + // Create notifications for important status changes + // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below + try { + const notificationService = getNotificationService(); + const displayName = this.getFeatureDisplayName(feature, featureId); + + if (status === 'waiting_approval') { + await notificationService.createNotification({ + type: NOTIFICATION_TYPE_WAITING_APPROVAL, + title: displayName, + message: NOTIFICATION_TITLE_WAITING_APPROVAL, + featureId, + projectPath, + }); + } else if (status === 'verified') { + await notificationService.createNotification({ + type: NOTIFICATION_TYPE_VERIFIED, + title: displayName, + message: NOTIFICATION_TITLE_VERIFIED, + featureId, + projectPath, + }); + } + } catch (notificationError) { + logger.warn(`Failed to create notification for feature ${featureId}:`, notificationError); + } + + // Sync completed/verified features to app_spec.txt + if (status === 'verified' || status === 'completed') { + try { + await this.featureLoader.syncFeatureToAppSpec(projectPath, feature); + } catch (syncError) { + // Log but don't fail the status update if sync fails + logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError); + } + } + } catch (error) { + logger.error(`Failed to update feature status for ${featureId}:`, error); + } + } + + /** + * Mark a feature as interrupted due to server restart or other interruption. + * + * This is a convenience helper that updates the feature status to 'interrupted', + * indicating the feature was in progress but execution was disrupted (e.g., server + * restart, process crash, or manual stop). Features with this status can be + * resumed later using the resume functionality. + * + * Note: Features with pipeline_* statuses are preserved rather than overwritten + * to 'interrupted'. This ensures that resumePipelineFeature() can pick up from + * the correct pipeline step after a restart. + * + * @param projectPath - Path to the project + * @param featureId - ID of the feature to mark as interrupted + * @param reason - Optional reason for the interruption (logged for debugging) + */ + async markFeatureInterrupted( + projectPath: string, + featureId: string, + reason?: string + ): Promise { + // Load the feature to check its current status + const feature = await this.loadFeature(projectPath, featureId); + const currentStatus = feature?.status; + + // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step + if (isPipelineStatus(currentStatus)) { + logger.info( + `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` + ); + return; + } + + if (reason) { + logger.info(`Marking feature ${featureId} as interrupted: ${reason}`); + } else { + logger.info(`Marking feature ${featureId} as interrupted`); + } + + await this.updateFeatureStatus(projectPath, featureId, 'interrupted'); + } + + /** + * Shared helper that scans features in a project directory and resets any stuck + * in transient states (in_progress, interrupted) back to resting states. + * Pipeline_* statuses are preserved so they can be resumed. + * + * Also resets: + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * @param projectPath - The project path to scan + * @param callerLabel - Label for log messages (e.g., 'resetStuckFeatures', 'reconcileAllFeatureStates') + * @returns Object with reconciledFeatures (id + status info), reconciledCount, and scanned count + */ + private async scanAndResetFeatures( + projectPath: string, + callerLabel: string + ): Promise<{ + reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }>; + reconciledFeatureIds: string[]; + reconciledCount: number; + scanned: number; + }> { + const featuresDir = getFeaturesDir(projectPath); + let scanned = 0; + let reconciledCount = 0; + const reconciledFeatureIds: string[] = []; + const reconciledFeatures: Array<{ + id: string; + previousStatus: string | undefined; + newStatus: string | undefined; + }> = []; + + try { + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + scanned++; + const featurePath = path.join(featuresDir, entry.name, 'feature.json'); + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + const feature = result.data; + if (!feature) continue; + + let needsUpdate = false; + const originalStatus = feature.status; + + // Reset features in active execution states back to a resting state + // After a server restart, no processes are actually running + const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted'; + + if (isActiveState) { + const hasApprovedPlan = feature.planSpec?.status === 'approved'; + feature.status = hasApprovedPlan ? 'ready' : 'backlog'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} from ${originalStatus} to ${feature.status}` + ); + } + + // Handle pipeline_* statuses separately: preserve them so they can be resumed + // but still count them as needing attention if they were stuck. + if (isPipelineStatus(originalStatus)) { + // We don't change the status, but we still want to reset planSpec/task states + // if they were stuck in transient generation/execution modes. + // No feature.status change here. + logger.debug( + `[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}` + ); + } + + // Reset generating planSpec status back to pending (spec generation was interrupted) + if (feature.planSpec?.status === 'generating') { + feature.planSpec.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset feature ${feature.id} planSpec status from generating to pending` + ); + } + + // Reset any in_progress tasks back to pending (task execution was interrupted) + if (feature.planSpec?.tasks) { + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'pending'; + needsUpdate = true; + logger.info( + `[${callerLabel}] Reset task ${task.id} for feature ${feature.id} from in_progress to pending` + ); + // Clear currentTaskId if it points to this reverted task + if (feature.planSpec?.currentTaskId === task.id) { + feature.planSpec.currentTaskId = undefined; + logger.info( + `[${callerLabel}] Cleared planSpec.currentTaskId for feature ${feature.id} (was pointing to reverted task ${task.id})` + ); + } + } + } + } + + if (needsUpdate) { + feature.updatedAt = new Date().toISOString(); + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + reconciledCount++; + reconciledFeatureIds.push(feature.id); + reconciledFeatures.push({ + id: feature.id, + previousStatus: originalStatus, + newStatus: feature.status, + }); + } + } + } catch (error) { + // If features directory doesn't exist, that's fine + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + logger.error(`[${callerLabel}] Error resetting features for ${projectPath}:`, error); + } + } + + return { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned }; + } + + /** + * Reset features that were stuck in transient states due to server crash. + * Called when auto mode is enabled to clean up from previous session. + * + * Resets: + * - in_progress features back to ready (if has plan) or backlog (if no plan) + * - interrupted features back to ready (if has plan) or backlog (if no plan) + * - generating planSpec status back to pending + * - in_progress tasks back to pending + * + * Preserves: + * - pipeline_* statuses (so resumePipelineFeature can resume from correct step) + * + * @param projectPath - The project path to reset features for + */ + async resetStuckFeatures(projectPath: string): Promise { + const { reconciledCount, scanned } = await this.scanAndResetFeatures( + projectPath, + 'resetStuckFeatures' + ); + + logger.info( + `[resetStuckFeatures] Scanned ${scanned} features, reset ${reconciledCount} features for ${projectPath}` + ); + } + + /** + * Reconcile all feature states on server startup. + * + * This method resets all features stuck in transient states (in_progress, + * interrupted, pipeline_*) and emits events so connected UI clients + * immediately reflect the corrected states. + * + * Should be called once during server initialization, before the UI is served, + * to ensure feature state consistency after any type of restart (clean, forced, crash). + * + * @param projectPath - The project path to reconcile features for + * @returns The number of features that were reconciled + */ + async reconcileAllFeatureStates(projectPath: string): Promise { + logger.info(`[reconcileAllFeatureStates] Starting reconciliation for ${projectPath}`); + + const { reconciledFeatures, reconciledFeatureIds, reconciledCount, scanned } = + await this.scanAndResetFeatures(projectPath, 'reconcileAllFeatureStates'); + + // Emit per-feature status change events so UI invalidates its cache + for (const { id, previousStatus, newStatus } of reconciledFeatures) { + this.emitAutoModeEvent('feature_status_changed', { + featureId: id, + projectPath, + status: newStatus, + previousStatus, + reason: 'server_restart_reconciliation', + }); + } + + // Emit a bulk reconciliation event for the UI + if (reconciledCount > 0) { + this.emitAutoModeEvent('features_reconciled', { + projectPath, + reconciledCount, + reconciledFeatureIds, + message: `Reconciled ${reconciledCount} feature(s) after server restart`, + }); + } + + logger.info( + `[reconcileAllFeatureStates] Scanned ${scanned} features, reconciled ${reconciledCount} for ${projectPath}` + ); + + return reconciledCount; + } + + /** + * Update the planSpec of a feature with partial updates. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param updates - Partial PlanSpec updates to apply + */ + async updateFeaturePlanSpec( + projectPath: string, + featureId: string, + updates: Partial + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + // Initialize planSpec if it doesn't exist + if (!feature.planSpec) { + feature.planSpec = { + status: 'pending', + version: 1, + reviewedByUser: false, + }; + } + + // Capture old content BEFORE applying updates for version comparison + const oldContent = feature.planSpec.content; + + // Apply updates + Object.assign(feature.planSpec, updates); + + // If content is being updated and it's different from old content, increment version + if (updates.content !== undefined && updates.content !== oldContent) { + feature.planSpec.version = (feature.planSpec.version || 0) + 1; + } + + feature.updatedAt = new Date().toISOString(); + + // PERSIST BEFORE EMIT + await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); + + // Emit event for UI update + this.emitAutoModeEvent('plan_spec_updated', { + featureId, + projectPath, + planSpec: feature.planSpec, + }); + } catch (error) { + logger.error(`Failed to update planSpec for ${featureId}:`, error); + } + } + + /** + * Save the extracted summary to a feature's summary field. + * This is called after agent execution completes to save a summary + * extracted from the agent's output using tags. + * + * For pipeline features (status starts with pipeline_), summaries are accumulated + * across steps with a header identifying each step. For non-pipeline features, + * the summary is replaced entirely. + * + * @param projectPath - The project path + * @param featureId - The feature ID + * @param summary - The summary text to save + */ + async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + const normalizedSummary = summary.trim(); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature) { + logger.warn(`Feature ${featureId} not found or could not be recovered`); + return; + } + + if (!normalizedSummary) { + logger.debug( + `[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")` + ); + return; + } + + // For pipeline features, accumulate summaries across steps + if (isPipelineStatus(feature.status)) { + // If we already have a non-phase summary (typically the initial implementation + // summary from in_progress), normalize it into a named phase before appending + // pipeline step summaries. This keeps the format consistent for UI phase parsing. + const implementationHeader = '### Implementation'; + if (feature.summary && !feature.summary.trimStart().startsWith('### ')) { + feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`; + } + + const stepName = await this.getPipelineStepName(projectPath, feature.status); + const stepHeader = `### ${stepName}`; + const stepSection = `${stepHeader}\n\n${normalizedSummary}`; + + if (feature.summary) { + // Check if this step already exists in the summary (e.g., if retried) + // Use section splitting to only match real section boundaries, not text in body content + const separator = '\n\n---\n\n'; + const sections = feature.summary.split(separator); + let replaced = false; + const updatedSections = sections.map((section) => { + if (section.startsWith(`${stepHeader}\n\n`)) { + replaced = true; + return stepSection; + } + return section; + }); + + if (replaced) { + feature.summary = updatedSections.join(separator); + logger.info( + `[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"` + ); + } else { + // Append as a new section + feature.summary = `${feature.summary}${separator}${stepSection}`; + logger.info( + `[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"` + ); + } + } else { + feature.summary = stepSection; + logger.info( + `[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"` + ); + } + } else { + feature.summary = normalizedSummary; + } + + 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: feature.summary, + }); + } catch (error) { + logger.error(`Failed to save summary for ${featureId}:`, error); + } + } + + /** + * Look up the pipeline step name from the current pipeline status. + * + * @param projectPath - The project path + * @param status - The current pipeline status (e.g., 'pipeline_abc123') + * @returns The step name, or a fallback based on the step ID + */ + private async getPipelineStepName(projectPath: string, status: string): Promise { + try { + const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline); + if (stepId) { + const step = await pipelineService.getStep(projectPath, stepId); + if (step) return step.name; + } + } catch (error) { + logger.debug( + `[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`, + error + ); + } + // Fallback: derive a human-readable name from the status suffix + // e.g., 'pipeline_code_review' → 'Code Review' + const suffix = status.replace('pipeline_', ''); + return suffix + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + /** + * 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'], + summary?: string + ): Promise { + const featureDir = getFeatureDir(projectPath, featureId); + const featurePath = path.join(featureDir, 'feature.json'); + + try { + const result = await readJsonWithRecovery(featurePath, null, { + maxBackups: DEFAULT_BACKUP_COUNT, + autoRestore: true, + }); + + logRecoveryWarning(result, `Feature ${featureId}`, logger); + + const feature = result.data; + if (!feature || !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; + if (summary) { + task.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_task_status', { + featureId, + projectPath, + taskId, + status, + summary, + tasks: feature.planSpec.tasks, + }); + } else { + const availableIds = feature.planSpec.tasks.map((t) => t.id).join(', '); + logger.warn( + `[updateTaskStatus] Task ${taskId} not found in feature ${featureId} (${projectPath}). Available task IDs: [${availableIds}]` + ); + } + } catch (error) { + logger.error(`Failed to update task ${taskId} status for ${featureId}:`, error); + } + } + + /** + * Get the display name for a feature, preferring title over feature ID. + * Empty string titles are treated as missing and fallback to featureId. + * + * @param feature - The feature to get the display name for + * @param featureId - The feature ID to use as fallback + * @returns The display name (title or feature ID) + */ + private getFeatureDisplayName(feature: Feature, featureId: string): string { + // Use title if it's a non-empty string, otherwise fallback to featureId + return feature.title && feature.title.trim() ? feature.title : featureId; + } + + /** + * Handle auto-mode events to create error notifications. + * This listens for error events and creates notifications to alert users. + */ + private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise { + if (!payload.type) return; + + // Only handle error events + if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') { + return; + } + + // For auto_mode_feature_complete, only notify on failures (passes === false) + if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) { + return; + } + + // Get project path - handle different event formats + const projectPath = payload.projectPath; + if (!projectPath) return; + + try { + const notificationService = getNotificationService(); + + // Determine notification type and title based on event type + // Only auto_mode_feature_complete events should create feature_error notifications + const isFeatureError = payload.type === 'auto_mode_feature_complete'; + const notificationType = isFeatureError + ? NOTIFICATION_TYPE_FEATURE_ERROR + : NOTIFICATION_TYPE_AUTO_MODE_ERROR; + const notificationTitle = isFeatureError + ? NOTIFICATION_TITLE_FEATURE_ERROR + : NOTIFICATION_TITLE_AUTO_MODE_ERROR; + + // Build error message + let errorMessage = payload.message || 'An error occurred'; + if (payload.error) { + errorMessage = payload.error; + } + + // Use feature title as notification title when available, fall back to gesture name + let title = notificationTitle; + if (payload.featureId) { + const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId); + if (displayName) { + title = displayName; + errorMessage = `${notificationTitle}: ${errorMessage}`; + } + } + + await notificationService.createNotification({ + type: notificationType, + title, + message: errorMessage, + featureId: payload.featureId, + projectPath, + }); + } catch (notificationError) { + logger.warn(`Failed to create error notification:`, notificationError); + } + } + + /** + * Get feature display name by loading the feature directly. + */ + private async getFeatureDisplayNameById( + projectPath: string, + featureId: string + ): Promise { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) return null; + return this.getFeatureDisplayName(feature, featureId); + } + + /** + * Finalize in-progress tasks when a feature reaches a terminal state. + * Marks in_progress tasks as completed but leaves pending tasks untouched. + * + * @param feature - The feature whose tasks should be finalized + * @param featureId - The feature ID for logging + * @param targetStatus - The status the feature is transitioning to + */ + private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void { + if (!feature.planSpec?.tasks) { + return; + } + + let tasksFinalized = 0; + let tasksPending = 0; + + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; + } + } + + // Update tasksCompleted count to reflect actual completed tasks + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}` + ); + } + + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } + } + + /** + * Emit an auto-mode event via the event emitter + * + * @param eventType - The event type (e.g., 'auto_mode_summary') + * @param data - The event payload + */ + private emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit('auto-mode:event', { + type: eventType, + ...data, + }); + } +} diff --git a/jules_branch/apps/server/src/services/gemini-usage-service.ts b/jules_branch/apps/server/src/services/gemini-usage-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fba8bda3403c3690a5b760396ec2d50239c9a2ac --- /dev/null +++ b/jules_branch/apps/server/src/services/gemini-usage-service.ts @@ -0,0 +1,817 @@ +/** + * Gemini Usage Service + * + * Service for tracking Gemini CLI usage and quota. + * Uses the internal Google Cloud quota API (same as CodexBar). + * See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md + * + * OAuth credentials are extracted from the Gemini CLI installation, + * not hardcoded, to ensure compatibility with CLI updates. + */ + +import { createLogger } from '@automaker/utils'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; + +const logger = createLogger('GeminiUsage'); + +// Quota API endpoint (internal Google Cloud API) +const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + +// Code Assist endpoint for getting project ID and tier info +const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; + +// Google OAuth endpoints for token refresh +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + +/** TTL for cached credentials in milliseconds (5 minutes) */ +const CREDENTIALS_CACHE_TTL_MS = 5 * 60 * 1000; + +export interface GeminiQuotaBucket { + /** Model ID this quota applies to */ + modelId: string; + /** Remaining fraction (0-1) */ + remainingFraction: number; + /** ISO-8601 reset time */ + resetTime: string; +} + +/** Simplified quota info for a model tier (Flash or Pro) */ +export interface GeminiTierQuota { + /** Used percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (0-100) */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; +} + +export interface GeminiUsageData { + /** Whether authenticated via CLI */ + authenticated: boolean; + /** Authentication method */ + authMethod: 'cli_login' | 'api_key' | 'none'; + /** Usage percentage (100 - remainingFraction * 100) - overall most constrained */ + usedPercent: number; + /** Remaining percentage - overall most constrained */ + remainingPercent: number; + /** Reset time as human-readable string */ + resetText?: string; + /** ISO-8601 reset time */ + resetTime?: string; + /** Model ID with lowest remaining quota */ + constrainedModel?: string; + /** Flash tier quota (aggregated from all flash models) */ + flashQuota?: GeminiTierQuota; + /** Pro tier quota (aggregated from all pro models) */ + proQuota?: GeminiTierQuota; + /** Raw quota buckets for detailed view */ + quotaBuckets?: GeminiQuotaBucket[]; + /** When this data was last fetched */ + lastUpdated: string; + /** Optional error message */ + error?: string; +} + +interface OAuthCredentials { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expiry_date?: number; + client_id?: string; + client_secret?: string; +} + +interface OAuthClientCredentials { + clientId: string; + clientSecret: string; +} + +interface QuotaResponse { + // The actual API returns 'buckets', not 'quotaBuckets' + buckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; + // Legacy field name (in case API changes) + quotaBuckets?: Array<{ + modelId?: string; + remainingFraction?: number; + resetTime?: string; + tokenType?: string; + }>; +} + +/** + * Gemini Usage Service + * + * Provides real usage/quota data for Gemini CLI users. + * Extracts OAuth credentials from the Gemini CLI installation. + */ +export class GeminiUsageService { + private cachedCredentials: OAuthCredentials | null = null; + private cachedCredentialsAt: number | null = null; + private cachedClientCredentials: OAuthClientCredentials | null = null; + private credentialsPath: string; + /** The actual path from which credentials were loaded (for write-back) */ + private loadedCredentialsPath: string | null = null; + + constructor() { + // Default credentials path for Gemini CLI + this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + } + + /** + * Check if Gemini CLI is authenticated + */ + async isAvailable(): Promise { + const creds = await this.loadCredentials(); + return Boolean(creds?.access_token || creds?.refresh_token); + } + + /** + * Fetch quota/usage data from Google Cloud API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + + const creds = await this.loadCredentials(); + + if (!creds || (!creds.access_token && !creds.refresh_token)) { + logger.info('[fetchUsageData] No credentials found'); + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Not authenticated. Run "gemini auth login" to authenticate.', + }; + } + + try { + // Get a valid access token (refresh if needed) + const accessToken = await this.getValidAccessToken(creds); + + if (!accessToken) { + return { + authenticated: false, + authMethod: 'none', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: 'Failed to obtain access token. Try running "gemini auth login" again.', + }; + } + + // First, get the project ID from loadCodeAssist endpoint + // This is required to get accurate quota data + let projectId: string | undefined; + try { + const codeAssistResponse = await fetch(CODE_ASSIST_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (codeAssistResponse.ok) { + const codeAssistData = (await codeAssistResponse.json()) as { + cloudaicompanionProject?: string; + currentTier?: { id?: string; name?: string }; + }; + projectId = codeAssistData.cloudaicompanionProject; + logger.debug('[fetchUsageData] Got project ID:', projectId); + } + } catch (e) { + logger.debug('[fetchUsageData] Failed to get project ID:', e); + } + + // Fetch quota from Google Cloud API + // Pass project ID to get accurate quota (without it, returns default 100%) + const response = await fetch(QUOTA_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(projectId ? { project: projectId } : {}), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + logger.error('[fetchUsageData] Quota API error:', response.status, errorText); + + // Still authenticated, but quota API failed + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Quota API unavailable (${response.status})`, + }; + } + + const data = (await response.json()) as QuotaResponse; + + // API returns 'buckets', with fallback to 'quotaBuckets' for compatibility + const apiBuckets = data.buckets || data.quotaBuckets; + + logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets)); + + if (!apiBuckets || apiBuckets.length === 0) { + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + }; + } + + // Group buckets into Flash and Pro tiers + // Flash: any model with "flash" in the name + // Pro: any model with "pro" in the name + let flashLowestRemaining = 1.0; + let flashResetTime: string | undefined; + let hasFlashModels = false; + let proLowestRemaining = 1.0; + let proResetTime: string | undefined; + let hasProModels = false; + let overallLowestRemaining = 1.0; + let constrainedModel: string | undefined; + let overallResetTime: string | undefined; + + const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => { + const remaining = bucket.remainingFraction ?? 1.0; + const modelId = bucket.modelId?.toLowerCase() || ''; + + // Track overall lowest + if (remaining < overallLowestRemaining) { + overallLowestRemaining = remaining; + constrainedModel = bucket.modelId; + overallResetTime = bucket.resetTime; + } + + // Group into Flash or Pro tier + if (modelId.includes('flash')) { + hasFlashModels = true; + if (remaining < flashLowestRemaining) { + flashLowestRemaining = remaining; + flashResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!flashResetTime && bucket.resetTime) { + flashResetTime = bucket.resetTime; + } + } else if (modelId.includes('pro')) { + hasProModels = true; + if (remaining < proLowestRemaining) { + proLowestRemaining = remaining; + proResetTime = bucket.resetTime; + } + // Also track reset time even if at 100% + if (!proResetTime && bucket.resetTime) { + proResetTime = bucket.resetTime; + } + } + + return { + modelId: bucket.modelId || 'unknown', + remainingFraction: remaining, + resetTime: bucket.resetTime || '', + }; + }); + + const usedPercent = Math.round((1 - overallLowestRemaining) * 100); + const remainingPercent = Math.round(overallLowestRemaining * 100); + + // Build tier quotas (only include if we found models for that tier) + const flashQuota: GeminiTierQuota | undefined = hasFlashModels + ? { + usedPercent: Math.round((1 - flashLowestRemaining) * 100), + remainingPercent: Math.round(flashLowestRemaining * 100), + resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined, + resetTime: flashResetTime, + } + : undefined; + + const proQuota: GeminiTierQuota | undefined = hasProModels + ? { + usedPercent: Math.round((1 - proLowestRemaining) * 100), + remainingPercent: Math.round(proLowestRemaining * 100), + resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined, + resetTime: proResetTime, + } + : undefined; + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent, + remainingPercent, + resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined, + resetTime: overallResetTime, + constrainedModel, + flashQuota, + proQuota, + quotaBuckets, + lastUpdated: new Date().toISOString(), + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[fetchUsageData] Error:', errorMsg); + + return { + authenticated: true, + authMethod: 'cli_login', + usedPercent: 0, + remainingPercent: 100, + lastUpdated: new Date().toISOString(), + error: `Failed to fetch quota: ${errorMsg}`, + }; + } + } + + /** + * Load OAuth credentials from file. + * Implements TTL-based cache invalidation and file mtime checks. + */ + private async loadCredentials(): Promise { + // Check if cached credentials are still valid + if (this.cachedCredentials && this.cachedCredentialsAt) { + const now = Date.now(); + const cacheAge = now - this.cachedCredentialsAt; + + if (cacheAge < CREDENTIALS_CACHE_TTL_MS) { + // Cache is within TTL - also check file mtime + const sourcePath = this.loadedCredentialsPath || this.credentialsPath; + try { + const stat = fs.statSync(sourcePath); + if (stat.mtimeMs <= this.cachedCredentialsAt) { + // File hasn't been modified since we cached - use cache + return this.cachedCredentials; + } + // File has been modified, fall through to re-read + logger.debug('[loadCredentials] File modified since cache, re-reading'); + } catch { + // File doesn't exist or can't stat - use cache + return this.cachedCredentials; + } + } else { + // Cache TTL expired, discard + logger.debug('[loadCredentials] Cache TTL expired, re-reading'); + } + + // Invalidate cached credentials + this.cachedCredentials = null; + this.cachedCredentialsAt = null; + } + + // Build unique possible paths (deduplicate) + const rawPaths = [ + this.credentialsPath, + path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'), + ]; + const possiblePaths = [...new Set(rawPaths)]; + + for (const credPath of possiblePaths) { + try { + if (fs.existsSync(credPath)) { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + + // Handle different credential formats + if (creds.access_token || creds.refresh_token) { + this.cachedCredentials = creds; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; + logger.info('[loadCredentials] Loaded from:', credPath); + return creds; + } + + // Some formats nest credentials under 'web' or 'installed' + if (creds.web?.client_id || creds.installed?.client_id) { + const clientCreds = creds.web || creds.installed; + this.cachedCredentials = { + client_id: clientCreds.client_id, + client_secret: clientCreds.client_secret, + }; + this.cachedCredentialsAt = Date.now(); + this.loadedCredentialsPath = credPath; + return this.cachedCredentials; + } + } + } catch (error) { + logger.debug('[loadCredentials] Failed to load from', credPath, error); + } + } + + return null; + } + + /** + * Find the Gemini CLI binary path + */ + private findGeminiBinaryPath(): string | null { + // Try 'which' on Unix-like systems, 'where' on Windows + const whichCmd = process.platform === 'win32' ? 'where' : 'which'; + try { + const whichResult = execFileSync(whichCmd, ['gemini'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + // 'where' on Windows may return multiple lines; take the first + const firstLine = whichResult.split('\n')[0]?.trim(); + if (firstLine && fs.existsSync(firstLine)) { + return firstLine; + } + } catch { + // Ignore errors from 'which'/'where' + } + + // Check common installation paths + const possiblePaths = [ + // npm global installs + path.join(os.homedir(), '.npm-global', 'bin', 'gemini'), + '/usr/local/bin/gemini', + '/usr/bin/gemini', + // Homebrew + '/opt/homebrew/bin/gemini', + '/usr/local/opt/gemini/bin/gemini', + // nvm/fnm node installs + path.join(os.homedir(), '.nvm', 'versions', 'node'), + path.join(os.homedir(), '.fnm', 'node-versions'), + // Windows + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'), + path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'), + ]; + + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + return p; + } + } + + return null; + } + + /** + * Extract OAuth client credentials from Gemini CLI installation + * This mimics CodexBar's approach of finding oauth2.js in the CLI + */ + private extractOAuthClientCredentials(): OAuthClientCredentials | null { + if (this.cachedClientCredentials) { + return this.cachedClientCredentials; + } + + const geminiBinary = this.findGeminiBinaryPath(); + if (!geminiBinary) { + logger.debug('[extractOAuthClientCredentials] Gemini binary not found'); + return null; + } + + // Resolve symlinks to find actual location + let resolvedPath = geminiBinary; + try { + resolvedPath = fs.realpathSync(geminiBinary); + } catch { + // Use original path if realpath fails + } + + const baseDir = path.dirname(resolvedPath); + logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir); + + // Possible locations for oauth2.js relative to the binary + // Based on CodexBar's search patterns + const possibleOAuth2Paths = [ + // npm global install structure + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Homebrew/libexec structure + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + 'libexec', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + // Direct sibling + path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'), + path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'), + // Alternative node_modules structures + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + path.join( + baseDir, + '..', + '..', + 'lib', + 'node_modules', + '@google', + 'gemini-cli-core', + 'dist', + 'src', + 'code_assist', + 'oauth2.js' + ), + ]; + + for (const oauth2Path of possibleOAuth2Paths) { + try { + const normalizedPath = path.normalize(oauth2Path); + if (fs.existsSync(normalizedPath)) { + logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath); + const content = fs.readFileSync(normalizedPath, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI'); + return creds; + } + } + } catch (error) { + logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error); + } + } + + // Try finding oauth2.js by searching in node_modules (POSIX only) + if (process.platform !== 'win32') { + try { + const searchBase = path.resolve(baseDir, '..'); + const searchResult = execFileSync( + 'find', + [searchBase, '-name', 'oauth2.js', '-path', '*gemini*', '-path', '*code_assist*'], + { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] } + ) + .trim() + .split('\n')[0]; // Take first result + + if (searchResult && fs.existsSync(searchResult)) { + logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult); + const content = fs.readFileSync(searchResult, 'utf8'); + const creds = this.parseOAuthCredentialsFromSource(content); + if (creds) { + this.cachedClientCredentials = creds; + logger.info( + '[extractOAuthClientCredentials] Extracted credentials from CLI (via search)' + ); + return creds; + } + } + } catch { + // Ignore search errors + } + } + + logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI'); + return null; + } + + /** + * Parse OAuth client credentials from oauth2.js source code + */ + private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null { + // Patterns based on CodexBar's regex extraction + // Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..." + const clientIdPatterns = [ + /OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/, + /clientId\s*[:=]\s*["']([^"']+)["']/, + /client_id\s*[:=]\s*["']([^"']+)["']/, + /"clientId"\s*:\s*["']([^"']+)["']/, + ]; + + const clientSecretPatterns = [ + /OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/, + /clientSecret\s*[:=]\s*["']([^"']+)["']/, + /client_secret\s*[:=]\s*["']([^"']+)["']/, + /"clientSecret"\s*:\s*["']([^"']+)["']/, + ]; + + let clientId: string | null = null; + let clientSecret: string | null = null; + + for (const pattern of clientIdPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientId = match[1]; + break; + } + } + + for (const pattern of clientSecretPatterns) { + const match = content.match(pattern); + if (match && match[1]) { + clientSecret = match[1]; + break; + } + } + + if (clientId && clientSecret) { + logger.debug('[parseOAuthCredentialsFromSource] Found client credentials'); + return { clientId, clientSecret }; + } + + return null; + } + + /** + * Get a valid access token, refreshing if necessary + */ + private async getValidAccessToken(creds: OAuthCredentials): Promise { + // Check if current token is still valid (with 5 min buffer) + if (creds.access_token && creds.expiry_date) { + const now = Date.now(); + if (creds.expiry_date > now + 5 * 60 * 1000) { + logger.debug('[getValidAccessToken] Using existing token (not expired)'); + return creds.access_token; + } + } + + // If we have a refresh token, try to refresh + if (creds.refresh_token) { + // Try to extract credentials from CLI first + const extractedCreds = this.extractOAuthClientCredentials(); + + // Use extracted credentials, then fall back to credentials in file + const clientId = extractedCreds?.clientId || creds.client_id; + const clientSecret = extractedCreds?.clientSecret || creds.client_secret; + + if (!clientId || !clientSecret) { + logger.error('[getValidAccessToken] No client credentials available for token refresh'); + // Return existing token even if expired - it might still work + return creds.access_token || null; + } + + try { + logger.debug('[getValidAccessToken] Refreshing token...'); + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: creds.refresh_token, + grant_type: 'refresh_token', + }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + if (response.ok) { + const data = (await response.json()) as { access_token?: string; expires_in?: number }; + const newAccessToken = data.access_token; + const expiresIn = data.expires_in || 3600; + + if (newAccessToken) { + logger.info('[getValidAccessToken] Token refreshed successfully'); + + // Update cached credentials + this.cachedCredentials = { + ...creds, + access_token: newAccessToken, + expiry_date: Date.now() + expiresIn * 1000, + }; + this.cachedCredentialsAt = Date.now(); + + // Save back to the file the credentials were loaded from + const writePath = this.loadedCredentialsPath || this.credentialsPath; + try { + fs.writeFileSync(writePath, JSON.stringify(this.cachedCredentials, null, 2)); + } catch (e) { + logger.debug('[getValidAccessToken] Could not save refreshed token:', e); + } + + return newAccessToken; + } + } else { + const errorText = await response.text().catch(() => ''); + logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText); + } + } catch (error) { + logger.error('[getValidAccessToken] Token refresh error:', error); + } + } + + // Return current access token even if it might be expired + return creds.access_token || null; + } + + /** + * Format reset time as human-readable string + */ + private formatResetTime(isoTime: string): string { + try { + const resetDate = new Date(isoTime); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + + if (diff < 0) { + return 'Resetting soon'; + } + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`; + } + + return `Resets in ${minutes}m`; + } catch { + return ''; + } + } + + /** + * Clear cached credentials (useful after logout) + */ + clearCache(): void { + this.cachedCredentials = null; + this.cachedCredentialsAt = null; + this.cachedClientCredentials = null; + } +} + +// Singleton instance +let usageServiceInstance: GeminiUsageService | null = null; + +/** + * Get the singleton instance of GeminiUsageService + */ +export function getGeminiUsageService(): GeminiUsageService { + if (!usageServiceInstance) { + usageServiceInstance = new GeminiUsageService(); + } + return usageServiceInstance; +} diff --git a/jules_branch/apps/server/src/services/github-pr-comment.service.ts b/jules_branch/apps/server/src/services/github-pr-comment.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b49d417a7a48b01f0067863607f0c984d9e2c275 --- /dev/null +++ b/jules_branch/apps/server/src/services/github-pr-comment.service.ts @@ -0,0 +1,103 @@ +/** + * GitHub PR Comment Service + * + * Domain logic for resolving/unresolving PR review threads via the + * GitHub GraphQL API. Extracted from the route handler so the route + * only deals with request/response plumbing. + */ + +import { spawn } from 'child_process'; +import { execEnv } from '../lib/exec-utils.js'; + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +interface GraphQLMutationResponse { + data?: { + resolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + unresolveReviewThread?: { + thread?: { isResolved: boolean; id: string } | null; + } | null; + }; + errors?: Array<{ message: string }>; +} + +/** + * Execute a GraphQL mutation to resolve or unresolve a review thread. + */ +export async function executeReviewThreadMutation( + projectPath: string, + threadId: string, + resolve: boolean +): Promise<{ isResolved: boolean }> { + const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread'; + + const mutation = ` + mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) { + ${mutationName}(input: { threadId: $threadId }) { + thread { + id + isResolved + } + } + }`; + + const variables = { threadId }; + const requestBody = JSON.stringify({ query: mutation, variables }); + + // Declare timeoutId before registering the error handler to avoid TDZ confusion + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((res, rej) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + rej(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + rej(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return rej(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + res(JSON.parse(stdout)); + } catch (e) { + rej(e); + } + }); + + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + const threadData = resolve + ? response.data?.resolveReviewThread?.thread + : response.data?.unresolveReviewThread?.thread; + + if (!threadData) { + throw new Error('No thread data returned from GitHub API'); + } + + return { isResolved: threadData.isResolved }; +} diff --git a/jules_branch/apps/server/src/services/ideation-service.ts b/jules_branch/apps/server/src/services/ideation-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bbea03b77f2c317d25b7bc63443d8e49c881928 --- /dev/null +++ b/jules_branch/apps/server/src/services/ideation-service.ts @@ -0,0 +1,1868 @@ +/** + * Ideation Service - Manages brainstorming sessions and ideas + * Provides AI-powered ideation, project analysis, and idea-to-feature conversion + */ + +import path from 'path'; +import * as secureFs from '../lib/secure-fs.js'; +import type { EventEmitter } from '../lib/events.js'; +import type { Feature, ExecuteOptions } from '@automaker/types'; +import type { + Idea, + IdeaCategory, + IdeaStatus, + IdeationSession, + IdeationSessionWithMessages, + IdeationMessage, + ProjectAnalysisResult, + AnalysisSuggestion, + AnalysisFileInfo, + CreateIdeaInput, + UpdateIdeaInput, + StartSessionOptions, + SendMessageOptions, + PromptCategory, + IdeationPrompt, + IdeationContextSources, +} from '@automaker/types'; +import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types'; +import { + getIdeasDir, + getIdeaDir, + getIdeaPath, + getIdeationSessionsDir, + getIdeationSessionPath, + getIdeationAnalysisPath, + getAppSpecPath, + ensureIdeationDir, +} from '@automaker/platform'; +import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js'; +import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils'; +import { ProviderFactory } from '../providers/provider-factory.js'; +import type { SettingsService } from './settings-service.js'; +import type { FeatureLoader } from './feature-loader.js'; +import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js'; +import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver'; +import { stripProviderPrefix } from '@automaker/types'; +import { + getPromptCustomization, + getProviderByModelId, + getPhaseModelWithOverrides, +} from '../lib/settings-helpers.js'; + +const logger = createLogger('IdeationService'); + +interface ActiveSession { + session: IdeationSession; + messages: IdeationMessage[]; + isRunning: boolean; + abortController: AbortController | null; +} + +export class IdeationService { + private activeSessions = new Map(); + private events: EventEmitter; + private settingsService: SettingsService | null = null; + private featureLoader: FeatureLoader | null = null; + + constructor( + events: EventEmitter, + settingsService?: SettingsService, + featureLoader?: FeatureLoader + ) { + this.events = events; + this.settingsService = settingsService ?? null; + this.featureLoader = featureLoader ?? null; + } + + // ============================================================================ + // Session Management + // ============================================================================ + + /** + * Start a new ideation session + */ + async startSession(projectPath: string, options?: StartSessionOptions): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + const sessionId = this.generateId('session'); + const now = new Date().toISOString(); + + const session: IdeationSession = { + id: sessionId, + projectPath, + promptCategory: options?.promptCategory, + promptId: options?.promptId, + status: 'active', + createdAt: now, + updatedAt: now, + }; + + const activeSession: ActiveSession = { + session, + messages: [], + isRunning: false, + abortController: null, + }; + + this.activeSessions.set(sessionId, activeSession); + await this.saveSessionToDisk(projectPath, session, []); + + this.events.emit('ideation:session-started', { sessionId, projectPath }); + + // If there's an initial message from a prompt, send it + if (options?.initialMessage) { + await this.sendMessage(sessionId, options.initialMessage); + } + + return session; + } + + /** + * Get an existing session + */ + async getSession( + projectPath: string, + sessionId: string + ): Promise { + // Check if session is already active in memory + let activeSession = this.activeSessions.get(sessionId); + + if (!activeSession) { + // Try to load from disk + const loaded = await this.loadSessionFromDisk(projectPath, sessionId); + if (!loaded) return null; + + activeSession = { + session: loaded.session, + messages: loaded.messages, + isRunning: false, + abortController: null, + }; + this.activeSessions.set(sessionId, activeSession); + } + + return { + ...activeSession.session, + messages: activeSession.messages, + }; + } + + /** + * Send a message in an ideation session + */ + async sendMessage( + sessionId: string, + message: string, + options?: SendMessageOptions + ): Promise { + const activeSession = this.activeSessions.get(sessionId); + if (!activeSession) { + throw new Error(`Session ${sessionId} not found`); + } + + if (activeSession.isRunning) { + throw new Error('Session is already processing a message'); + } + + activeSession.isRunning = true; + activeSession.abortController = new AbortController(); + + // Add user message + const userMessage: IdeationMessage = { + id: this.generateId('msg'), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + }; + activeSession.messages.push(userMessage); + + // Emit user message + this.events.emit('ideation:stream', { + sessionId, + type: 'message', + message: userMessage, + }); + + try { + const projectPath = activeSession.session.projectPath; + + // Build conversation history + const conversationHistory = activeSession.messages.slice(0, -1).map((msg) => ({ + role: msg.role, + content: msg.content, + })); + + // Load context files + const contextResult = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + }); + + // Gather existing features and ideas to prevent duplicate suggestions + const existingWorkContext = await this.gatherExistingWorkContext(projectPath); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); + + // Build system prompt for ideation + const systemPrompt = this.buildIdeationSystemPrompt( + prompts.ideation.ideationSystemPrompt, + contextResult.formattedPrompt, + activeSession.session.promptCategory, + existingWorkContext + ); + + // Resolve model alias to canonical identifier (with prefix) + let modelId = resolveModelString(options?.model ?? 'sonnet'); + + // Try to find a provider for this model (e.g., GLM, MiniMax models) + let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; + let credentials = await this.settingsService?.getCredentials(); + + if (this.settingsService && options?.model) { + const providerResult = await getProviderByModelId( + options.model, + this.settingsService, + '[IdeationService]' + ); + if (providerResult.provider) { + claudeCompatibleProvider = providerResult.provider; + // CRITICAL: For custom providers, use the provider's model ID (e.g. "GLM-4.7") + // for the API call, NOT the resolved Claude model - otherwise we get "model not found" + modelId = options.model; + credentials = providerResult.credentials ?? credentials; + } + } + + // Create SDK options + const sdkOptions = createChatOptions({ + cwd: projectPath, + model: modelId, + systemPrompt, + abortController: activeSession.abortController!, + }); + + const provider = ProviderFactory.getProviderForModel(modelId); + + // Strip provider prefix - providers need bare model IDs + const bareModel = stripProviderPrefix(modelId); + + const executeOptions: ExecuteOptions = { + prompt: message, + model: bareModel, + originalModel: modelId, + cwd: projectPath, + systemPrompt: sdkOptions.systemPrompt, + maxTurns: 1, // Single turn for ideation + abortController: activeSession.abortController!, + conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }; + + const stream = provider.executeQuery(executeOptions); + + let responseText = ''; + const assistantMessage: IdeationMessage = { + id: this.generateId('msg'), + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + }; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + assistantMessage.content = responseText; + + this.events.emit('ideation:stream', { + sessionId, + type: 'stream', + content: responseText, + done: false, + }); + } + } + } else if (msg.type === 'result') { + if (msg.subtype === 'success' && msg.result) { + assistantMessage.content = msg.result; + responseText = msg.result; + } + } + } + + activeSession.messages.push(assistantMessage); + + this.events.emit('ideation:stream', { + sessionId, + type: 'message-complete', + message: assistantMessage, + content: responseText, + done: true, + }); + + // Save session + await this.saveSessionToDisk(projectPath, activeSession.session, activeSession.messages); + } catch (error) { + if (isAbortError(error)) { + this.events.emit('ideation:stream', { + sessionId, + type: 'aborted', + }); + } else { + logger.error('Error in ideation message:', error); + this.events.emit('ideation:stream', { + sessionId, + type: 'error', + error: (error as Error).message, + }); + } + } finally { + activeSession.isRunning = false; + activeSession.abortController = null; + } + } + + /** + * Stop an active session + */ + async stopSession(sessionId: string): Promise { + const activeSession = this.activeSessions.get(sessionId); + if (!activeSession) return; + + if (activeSession.abortController) { + activeSession.abortController.abort(); + } + + activeSession.isRunning = false; + activeSession.abortController = null; + activeSession.session.status = 'completed'; + + await this.saveSessionToDisk( + activeSession.session.projectPath, + activeSession.session, + activeSession.messages + ); + + this.events.emit('ideation:session-ended', { sessionId }); + } + + // ============================================================================ + // Ideas CRUD + // ============================================================================ + + /** + * Create a new idea + */ + async createIdea(projectPath: string, input: CreateIdeaInput): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + const ideaId = this.generateId('idea'); + const now = new Date().toISOString(); + + const idea: Idea = { + id: ideaId, + title: input.title, + description: input.description, + category: input.category, + status: input.status || 'raw', + impact: input.impact || 'medium', + effort: input.effort || 'medium', + conversationId: input.conversationId, + sourcePromptId: input.sourcePromptId, + userStories: input.userStories, + notes: input.notes, + createdAt: now, + updatedAt: now, + }; + + // Save to disk + const ideaDir = getIdeaDir(projectPath, ideaId); + await secureFs.mkdir(ideaDir, { recursive: true }); + await secureFs.writeFile( + getIdeaPath(projectPath, ideaId), + JSON.stringify(idea, null, 2), + 'utf-8' + ); + + return idea; + } + + /** + * Get all ideas for a project + */ + async getIdeas(projectPath: string): Promise { + try { + const ideasDir = getIdeasDir(projectPath); + + try { + await secureFs.access(ideasDir); + } catch { + return []; + } + + const entries = (await secureFs.readdir(ideasDir, { + withFileTypes: true, + })) as import('fs').Dirent[]; + const ideaDirs = entries.filter((entry) => entry.isDirectory()); + + const ideas: Idea[] = []; + for (const dir of ideaDirs) { + try { + const ideaPath = getIdeaPath(projectPath, dir.name); + const content = (await secureFs.readFile(ideaPath, 'utf-8')) as string; + ideas.push(JSON.parse(content)); + } catch (error) { + logger.warn(`Failed to load idea ${dir.name}:`, error); + } + } + + // Sort by updatedAt descending + return ideas.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + } catch (error) { + logger.error('Failed to get ideas:', error); + return []; + } + } + + /** + * Get a single idea + */ + async getIdea(projectPath: string, ideaId: string): Promise { + try { + const ideaPath = getIdeaPath(projectPath, ideaId); + const content = (await secureFs.readFile(ideaPath, 'utf-8')) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + /** + * Update an idea + */ + async updateIdea( + projectPath: string, + ideaId: string, + updates: UpdateIdeaInput + ): Promise { + const idea = await this.getIdea(projectPath, ideaId); + if (!idea) return null; + + const updatedIdea: Idea = { + ...idea, + ...updates, + updatedAt: new Date().toISOString(), + }; + + await secureFs.writeFile( + getIdeaPath(projectPath, ideaId), + JSON.stringify(updatedIdea, null, 2), + 'utf-8' + ); + + return updatedIdea; + } + + /** + * Delete an idea + */ + async deleteIdea(projectPath: string, ideaId: string): Promise { + const ideaDir = getIdeaDir(projectPath, ideaId); + try { + await secureFs.rm(ideaDir, { recursive: true }); + } catch { + // Ignore if doesn't exist + } + } + + /** + * Archive an idea + */ + async archiveIdea(projectPath: string, ideaId: string): Promise { + return this.updateIdea(projectPath, ideaId, { + status: 'archived' as IdeaStatus, + }); + } + + // ============================================================================ + // Project Analysis + // ============================================================================ + + /** + * Analyze project structure and generate suggestions + */ + async analyzeProject(projectPath: string): Promise { + validateWorkingDirectory(projectPath); + await ensureIdeationDir(projectPath); + + this.emitAnalysisEvent('ideation:analysis-started', { + projectPath, + message: 'Starting project analysis...', + }); + + try { + // Gather project structure + const structure = await this.gatherProjectStructure(projectPath); + + this.emitAnalysisEvent('ideation:analysis-progress', { + projectPath, + progress: 30, + message: 'Analyzing codebase structure...', + }); + + // Use AI to generate suggestions + const suggestions = await this.generateAnalysisSuggestions(projectPath, structure); + + this.emitAnalysisEvent('ideation:analysis-progress', { + projectPath, + progress: 80, + message: 'Generating improvement suggestions...', + }); + + const result: ProjectAnalysisResult = { + projectPath, + analyzedAt: new Date().toISOString(), + totalFiles: structure.totalFiles, + routes: structure.routes, + components: structure.components, + services: structure.services, + framework: structure.framework, + language: structure.language, + dependencies: structure.dependencies, + suggestions, + summary: this.generateAnalysisSummary(structure, suggestions), + }; + + // Cache the result + await secureFs.writeFile( + getIdeationAnalysisPath(projectPath), + JSON.stringify(result, null, 2), + 'utf-8' + ); + + this.emitAnalysisEvent('ideation:analysis-complete', { + projectPath, + result, + }); + + return result; + } catch (error) { + logger.error('Project analysis failed:', error); + this.emitAnalysisEvent('ideation:analysis-error', { + projectPath, + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Emit analysis event wrapped in ideation:analysis format + */ + private emitAnalysisEvent(eventType: string, data: Record): void { + this.events.emit('ideation:analysis', { + type: eventType, + ...data, + }); + } + + /** + * Check if a session is currently running (processing a message) + */ + isSessionRunning(sessionId: string): boolean { + const activeSession = this.activeSessions.get(sessionId); + return activeSession?.isRunning ?? false; + } + + /** + * Get cached analysis result + */ + async getCachedAnalysis(projectPath: string): Promise { + try { + const content = (await secureFs.readFile( + getIdeationAnalysisPath(projectPath), + 'utf-8' + )) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + // ============================================================================ + // Convert to Feature + // ============================================================================ + + /** + * Convert an idea to a feature + */ + async convertToFeature(projectPath: string, ideaId: string): Promise { + const idea = await this.getIdea(projectPath, ideaId); + if (!idea) { + throw new Error(`Idea ${ideaId} not found`); + } + + // Build feature description from idea + let description = idea.description; + if (idea.userStories && idea.userStories.length > 0) { + description += '\n\n## User Stories\n' + idea.userStories.map((s) => `- ${s}`).join('\n'); + } + if (idea.notes) { + description += '\n\n## Notes\n' + idea.notes; + } + + const feature: Feature = { + id: this.generateId('feature'), + title: idea.title, + category: this.mapIdeaCategoryToFeatureCategory(idea.category), + description, + status: 'backlog', + }; + + return feature; + } + + // ============================================================================ + // Generate Suggestions + // ============================================================================ + + /** + * Generate structured suggestions for a prompt + * Returns parsed suggestions that can be directly added to the board + */ + async generateSuggestions( + projectPath: string, + promptId: string, + category: IdeaCategory, + count: number = 10, + contextSources?: IdeationContextSources + ): Promise { + const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20); + // Merge with defaults for backward compatibility + const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources }; + validateWorkingDirectory(projectPath); + + // Get the prompt + const prompt = this.getAllPrompts().find((p) => p.id === promptId); + if (!prompt) { + throw new Error(`Prompt ${promptId} not found`); + } + + // Emit start event + this.events.emit('ideation:suggestions', { + type: 'started', + promptId, + category, + }); + + try { + // Load context files (respecting toggle settings) + const contextResult = await loadContextFiles({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + includeContextFiles: sources.useContextFiles, + includeMemory: sources.useMemoryFiles, + }); + + // Build context from multiple sources + let contextPrompt = contextResult.formattedPrompt; + + // Add app spec context if enabled + if (sources.useAppSpec) { + const appSpecContext = await this.buildAppSpecContext(projectPath); + if (appSpecContext) { + contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext; + } + } + + // If no context was found, try to gather basic project info + if (!contextPrompt) { + const projectInfo = await this.gatherBasicProjectInfo(projectPath); + if (projectInfo) { + contextPrompt = projectInfo; + } + } + + // Gather existing features and ideas to prevent duplicates (respecting toggle settings) + const existingWorkContext = await this.gatherExistingWorkContext(projectPath, { + includeFeatures: sources.useExistingFeatures, + includeIdeas: sources.useExistingIdeas, + }); + + // Get customized prompts from settings + const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]'); + + // Build system prompt for structured suggestions + const systemPrompt = this.buildSuggestionsSystemPrompt( + prompts.ideation.suggestionsSystemPrompt, + contextPrompt, + category, + suggestionCount, + existingWorkContext + ); + + // Get model from phase settings with provider info (ideationModel) + const phaseResult = await getPhaseModelWithOverrides( + 'ideationModel', + this.settingsService, + projectPath, + '[IdeationService]' + ); + const resolved = resolvePhaseModel(phaseResult.phaseModel); + // resolvePhaseModel already resolves model aliases internally - no need to call resolveModelString again + const modelId = resolved.model; + const claudeCompatibleProvider = phaseResult.provider; + const credentials = phaseResult.credentials; + + logger.info( + 'generateSuggestions using model:', + modelId, + claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API' + ); + + // Create SDK options + const sdkOptions = createChatOptions({ + cwd: projectPath, + model: modelId, + systemPrompt, + abortController: new AbortController(), + }); + + const provider = ProviderFactory.getProviderForModel(modelId); + + // Strip provider prefix - providers need bare model IDs + const bareModel = stripProviderPrefix(modelId); + + const executeOptions: ExecuteOptions = { + prompt: prompt.prompt, + model: bareModel, + originalModel: modelId, + cwd: projectPath, + systemPrompt: sdkOptions.systemPrompt, + maxTurns: 1, + // Disable all tools - we just want text generation, not codebase analysis + allowedTools: [], + abortController: new AbortController(), + readOnly: true, // Suggestions only need to return JSON, never write files + claudeCompatibleProvider, // Pass provider for alternative endpoint configuration + credentials, // Pass credentials for resolving 'credentials' apiKeySource + }; + + const stream = provider.executeQuery(executeOptions); + + let responseText = ''; + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) { + responseText = msg.result; + } + } + + // Parse the response into structured suggestions + const suggestions = this.parseSuggestionsFromResponse( + responseText, + category, + suggestionCount + ); + + // Emit complete event + this.events.emit('ideation:suggestions', { + type: 'complete', + promptId, + category, + suggestions, + }); + + return suggestions; + } catch (error) { + logger.error('Failed to generate suggestions:', error); + this.events.emit('ideation:suggestions', { + type: 'error', + promptId, + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Build system prompt for structured suggestion generation + * @param basePrompt - The base system prompt from settings + * @param contextFilesPrompt - Project context from loaded files + * @param category - The idea category to focus on + * @param count - Number of suggestions to generate + * @param existingWorkContext - Context about existing features/ideas + */ + private buildSuggestionsSystemPrompt( + basePrompt: string, + contextFilesPrompt: string | undefined, + category: IdeaCategory, + count: number = 10, + existingWorkContext?: string + ): string { + const contextSection = contextFilesPrompt + ? `## Project Context\n${contextFilesPrompt}` + : `## No Project Context Available\nNo context files were found. Generate suggestions based on the user's prompt and general best practices for the type of application being described.`; + + const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; + + // Replace placeholder {{count}} if present, otherwise append count instruction + let prompt = basePrompt; + if (prompt.includes('{{count}}')) { + prompt = prompt.replace(/\{\{count\}\}/g, String(count)); + } else { + prompt += `\n\nGenerate exactly ${count} suggestions.`; + } + + return `${prompt} + +Focus area: ${this.getCategoryDescription(category)} + +${contextSection}${existingWorkSection}`; + } + + /** + * Parse AI response into structured suggestions + */ + private parseSuggestionsFromResponse( + response: string, + category: IdeaCategory, + count: number + ): AnalysisSuggestion[] { + try { + // Try to extract JSON from the response + const jsonMatch = response.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + logger.warn('No JSON array found in response, falling back to text parsing'); + return this.parseTextResponse(response, category, count); + } + + const parsed = JSON.parse(jsonMatch[0]); + if (!Array.isArray(parsed)) { + return this.parseTextResponse(response, category, count); + } + + return parsed + .map( + ( + item: { + title?: string; + description?: string; + rationale?: string; + priority?: 'low' | 'medium' | 'high'; + relatedFiles?: string[]; + }, + index: number + ) => ({ + id: this.generateId('sug'), + category, + title: item.title || `Suggestion ${index + 1}`, + description: item.description || '', + rationale: item.rationale || '', + priority: item.priority || ('medium' as const), + relatedFiles: item.relatedFiles || [], + }) + ) + .slice(0, count); + } catch (error) { + logger.warn('Failed to parse JSON response:', error); + return this.parseTextResponse(response, category, count); + } + } + + /** + * Fallback: parse text response into suggestions + */ + private parseTextResponse( + response: string, + category: IdeaCategory, + count: number + ): AnalysisSuggestion[] { + const suggestions: AnalysisSuggestion[] = []; + + // Try to find numbered items or headers + const lines = response.split('\n'); + let currentSuggestion: Partial | null = null; + let currentContent: string[] = []; + + for (const line of lines) { + // Check for numbered items or markdown headers + const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/); + + if (titleMatch) { + // Save previous suggestion + if (currentSuggestion && currentSuggestion.title) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: currentSuggestion.title, + description: currentContent.join(' ').trim() || currentSuggestion.title, + rationale: '', + priority: 'medium', + ...currentSuggestion, + } as AnalysisSuggestion); + } + + // Start new suggestion + currentSuggestion = { + title: titleMatch[1].replace(/\*{1,2}/g, '').trim(), + }; + currentContent = []; + } else if (currentSuggestion && line.trim()) { + currentContent.push(line.trim()); + } + } + + // Don't forget the last suggestion + if (currentSuggestion && currentSuggestion.title) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: currentSuggestion.title, + description: currentContent.join(' ').trim() || currentSuggestion.title, + rationale: '', + priority: 'medium', + } as AnalysisSuggestion); + } + + // If no suggestions found, create one from the whole response + if (suggestions.length === 0 && response.trim()) { + suggestions.push({ + id: this.generateId('sug'), + category, + title: 'AI Suggestion', + description: response.slice(0, 500), + rationale: '', + priority: 'medium', + }); + } + + return suggestions.slice(0, count); + } + + // ============================================================================ + // Guided Prompts + // ============================================================================ + + /** + * Get all prompt categories + */ + getPromptCategories(): PromptCategory[] { + return [ + { + id: 'feature', + name: 'Features', + icon: 'Zap', + description: 'New capabilities and functionality', + }, + { + id: 'ux-ui', + name: 'UX/UI', + icon: 'Palette', + description: 'Design and user experience improvements', + }, + { + id: 'dx', + name: 'Developer Experience', + icon: 'Code', + description: 'Developer tooling and workflows', + }, + { + id: 'growth', + name: 'Growth', + icon: 'TrendingUp', + description: 'User engagement and retention', + }, + { + id: 'technical', + name: 'Technical', + icon: 'Cpu', + description: 'Architecture and infrastructure', + }, + { + id: 'security', + name: 'Security', + icon: 'Shield', + description: 'Security improvements and vulnerability fixes', + }, + { + id: 'performance', + name: 'Performance', + icon: 'Gauge', + description: 'Performance optimization and speed improvements', + }, + { + id: 'accessibility', + name: 'Accessibility', + icon: 'Accessibility', + description: 'Accessibility features and inclusive design', + }, + { + id: 'analytics', + name: 'Analytics', + icon: 'BarChart', + description: 'Analytics, monitoring, and insights features', + }, + ]; + } + + /** + * Get prompts for a specific category + */ + getPromptsByCategory(category: IdeaCategory): IdeationPrompt[] { + const allPrompts = this.getAllPrompts(); + return allPrompts.filter((p) => p.category === category); + } + + /** + * Get all guided prompts + * This is the single source of truth for guided prompts data. + * Frontend fetches this data via /api/ideation/prompts endpoint. + */ + getAllPrompts(): IdeationPrompt[] { + return [ + // Feature prompts + { + id: 'feature-missing', + category: 'feature', + title: 'Missing Features', + description: 'Discover features users might expect', + prompt: + "Based on the project context provided, identify features that users of similar applications typically expect but might be missing. Consider the app's domain, target users, and common patterns in similar products.", + }, + { + id: 'feature-automation', + category: 'feature', + title: 'Automation Opportunities', + description: 'Find manual processes that could be automated', + prompt: + 'Based on the project context, identify manual processes or repetitive tasks that could be automated. Look for patterns where users might be doing things repeatedly that software could handle.', + }, + { + id: 'feature-integrations', + category: 'feature', + title: 'Integration Ideas', + description: 'Identify valuable third-party integrations', + prompt: + "Based on the project context, what third-party services or APIs would provide value if integrated? Consider the app's domain and what complementary services users might need.", + }, + { + id: 'feature-workflow', + category: 'feature', + title: 'Workflow Improvements', + description: 'Streamline user workflows', + prompt: + 'Based on the project context, analyze the user workflows. What steps could be combined, eliminated, or automated? Where are users likely spending too much time on repetitive tasks?', + }, + + // UX/UI prompts + { + id: 'ux-friction', + category: 'ux-ui', + title: 'Friction Points', + description: 'Identify where users might get stuck', + prompt: + 'Based on the project context, identify potential user friction points. Where might users get confused, stuck, or frustrated? Consider form submissions, navigation, error states, and complex interactions.', + }, + { + id: 'ux-empty-states', + category: 'ux-ui', + title: 'Empty States', + description: 'Improve empty state experiences', + prompt: + "Based on the project context, identify empty states that could be improved. How can we guide users when there's no content? Consider onboarding, helpful prompts, and sample data.", + }, + { + id: 'ux-accessibility', + category: 'ux-ui', + title: 'Accessibility Improvements', + description: 'Enhance accessibility and inclusivity', + prompt: + 'Based on the project context, suggest accessibility improvements. Consider keyboard navigation, screen reader support, color contrast, focus states, and ARIA labels. What specific improvements would make this more accessible?', + }, + { + id: 'ux-mobile', + category: 'ux-ui', + title: 'Mobile Experience', + description: 'Optimize for mobile users', + prompt: + 'Based on the project context, suggest improvements for the mobile user experience. Consider touch targets, responsive layouts, and mobile-specific interactions.', + }, + { + id: 'ux-feedback', + category: 'ux-ui', + title: 'User Feedback', + description: 'Improve feedback and status indicators', + prompt: + 'Based on the project context, analyze how the application communicates with users. Where are loading states, success messages, or error handling missing or unclear? What feedback would help users understand what is happening?', + }, + + // DX prompts + { + id: 'dx-documentation', + category: 'dx', + title: 'Documentation Gaps', + description: 'Identify missing documentation', + prompt: + 'Based on the project context, identify areas that could benefit from better documentation. What would help new developers understand the architecture, APIs, and conventions? Consider inline comments, READMEs, and API docs.', + }, + { + id: 'dx-testing', + category: 'dx', + title: 'Testing Improvements', + description: 'Enhance test coverage and quality', + prompt: + 'Based on the project context, suggest areas that need better test coverage. What types of tests might be missing? Consider unit tests, integration tests, and end-to-end tests.', + }, + { + id: 'dx-tooling', + category: 'dx', + title: 'Developer Tooling', + description: 'Improve development workflows', + prompt: + 'Based on the project context, suggest improvements to development workflows. What improvements would speed up development? Consider build times, hot reload, debugging tools, and developer scripts.', + }, + { + id: 'dx-error-handling', + category: 'dx', + title: 'Error Handling', + description: 'Improve error messages and debugging', + prompt: + 'Based on the project context, analyze error handling. Where are error messages unclear or missing? What would help developers debug issues faster? Consider logging, error boundaries, and stack traces.', + }, + + // Growth prompts + { + id: 'growth-onboarding', + category: 'growth', + title: 'Onboarding Flow', + description: 'Improve new user experience', + prompt: + 'Based on the project context, suggest improvements to the onboarding experience. How can we help new users understand the value and get started quickly? Consider tutorials, progressive disclosure, and quick wins.', + }, + { + id: 'growth-engagement', + category: 'growth', + title: 'User Engagement', + description: 'Increase user retention and activity', + prompt: + 'Based on the project context, suggest features that would increase user engagement and retention. What would bring users back daily? Consider notifications, streaks, social features, and personalization.', + }, + { + id: 'growth-sharing', + category: 'growth', + title: 'Shareability', + description: 'Make the app more shareable', + prompt: + 'Based on the project context, suggest ways to make the application more shareable. What features would encourage users to invite others or share their work? Consider collaboration, public profiles, and export features.', + }, + { + id: 'growth-monetization', + category: 'growth', + title: 'Monetization Ideas', + description: 'Identify potential revenue streams', + prompt: + 'Based on the project context, what features or tiers could support monetization? Consider premium features, usage limits, team features, and integrations that users would pay for.', + }, + + // Technical prompts + { + id: 'tech-performance', + category: 'technical', + title: 'Performance Optimization', + description: 'Identify performance bottlenecks', + prompt: + 'Based on the project context, suggest performance optimization opportunities. Where might bottlenecks exist? Consider database queries, API calls, bundle size, rendering, and caching strategies.', + }, + { + id: 'tech-architecture', + category: 'technical', + title: 'Architecture Review', + description: 'Evaluate and improve architecture', + prompt: + 'Based on the project context, suggest architectural improvements. What would make the codebase more maintainable, scalable, or testable? Consider separation of concerns, dependency management, and patterns.', + }, + { + id: 'tech-debt', + category: 'technical', + title: 'Technical Debt', + description: 'Identify areas needing refactoring', + prompt: + 'Based on the project context, identify potential technical debt. What areas might be becoming hard to maintain or understand? What refactoring would have the highest impact? Consider duplicated code, complexity, and outdated patterns.', + }, + { + id: 'tech-security', + category: 'technical', + title: 'Security Review', + description: 'Identify security improvements', + prompt: + 'Based on the project context, review for security improvements. What best practices are missing? Consider authentication, authorization, input validation, and data protection. Note: This is for improvement suggestions, not a security audit.', + }, + + // Security prompts + { + id: 'security-auth', + category: 'security', + title: 'Authentication Security', + description: 'Review authentication mechanisms', + prompt: + 'Based on the project context, analyze the authentication system. What security improvements would strengthen user authentication? Consider password policies, session management, MFA, and token handling.', + }, + { + id: 'security-data', + category: 'security', + title: 'Data Protection', + description: 'Protect sensitive user data', + prompt: + 'Based on the project context, review how sensitive data is handled. What improvements would better protect user privacy? Consider encryption, data minimization, secure storage, and data retention policies.', + }, + { + id: 'security-input', + category: 'security', + title: 'Input Validation', + description: 'Prevent injection attacks', + prompt: + 'Based on the project context, analyze input handling. Where could input validation be strengthened? Consider SQL injection, XSS, command injection, and file upload vulnerabilities.', + }, + { + id: 'security-api', + category: 'security', + title: 'API Security', + description: 'Secure API endpoints', + prompt: + 'Based on the project context, review API security. What improvements would make the API more secure? Consider rate limiting, authorization, CORS, and request validation.', + }, + + // Performance prompts + { + id: 'perf-frontend', + category: 'performance', + title: 'Frontend Performance', + description: 'Optimize UI rendering and loading', + prompt: + 'Based on the project context, analyze frontend performance. What optimizations would improve load times and responsiveness? Consider bundle splitting, lazy loading, memoization, and render optimization.', + }, + { + id: 'perf-backend', + category: 'performance', + title: 'Backend Performance', + description: 'Optimize server-side operations', + prompt: + 'Based on the project context, review backend performance. What optimizations would improve response times? Consider database queries, caching strategies, async operations, and resource pooling.', + }, + { + id: 'perf-database', + category: 'performance', + title: 'Database Optimization', + description: 'Improve query performance', + prompt: + 'Based on the project context, analyze database interactions. What optimizations would improve data access performance? Consider indexing, query optimization, denormalization, and connection pooling.', + }, + { + id: 'perf-caching', + category: 'performance', + title: 'Caching Strategies', + description: 'Implement effective caching', + prompt: + 'Based on the project context, review caching opportunities. Where would caching provide the most benefit? Consider API responses, computed values, static assets, and session data.', + }, + + // Accessibility prompts + { + id: 'a11y-keyboard', + category: 'accessibility', + title: 'Keyboard Navigation', + description: 'Enable full keyboard access', + prompt: + 'Based on the project context, analyze keyboard accessibility. What improvements would enable users to navigate entirely with keyboard? Consider focus management, tab order, and keyboard shortcuts.', + }, + { + id: 'a11y-screen-reader', + category: 'accessibility', + title: 'Screen Reader Support', + description: 'Improve screen reader experience', + prompt: + 'Based on the project context, review screen reader compatibility. What improvements would help users with visual impairments? Consider ARIA labels, semantic HTML, live regions, and alt text.', + }, + { + id: 'a11y-visual', + category: 'accessibility', + title: 'Visual Accessibility', + description: 'Improve visual design for all users', + prompt: + 'Based on the project context, analyze visual accessibility. What improvements would help users with visual impairments? Consider color contrast, text sizing, focus indicators, and reduced motion.', + }, + { + id: 'a11y-forms', + category: 'accessibility', + title: 'Accessible Forms', + description: 'Make forms usable for everyone', + prompt: + 'Based on the project context, review form accessibility. What improvements would make forms more accessible? Consider labels, error messages, required field indicators, and input assistance.', + }, + + // Analytics prompts + { + id: 'analytics-tracking', + category: 'analytics', + title: 'User Tracking', + description: 'Track key user behaviors', + prompt: + 'Based on the project context, analyze analytics opportunities. What user behaviors should be tracked to understand engagement? Consider page views, feature usage, conversion funnels, and session duration.', + }, + { + id: 'analytics-metrics', + category: 'analytics', + title: 'Key Metrics', + description: 'Define success metrics', + prompt: + 'Based on the project context, what key metrics should be tracked? Consider user acquisition, retention, engagement, and feature adoption. What dashboards would be most valuable?', + }, + { + id: 'analytics-errors', + category: 'analytics', + title: 'Error Monitoring', + description: 'Track and analyze errors', + prompt: + 'Based on the project context, review error handling for monitoring opportunities. What error tracking would help identify and fix issues faster? Consider error aggregation, alerting, and stack traces.', + }, + { + id: 'analytics-performance', + category: 'analytics', + title: 'Performance Monitoring', + description: 'Track application performance', + prompt: + 'Based on the project context, analyze performance monitoring opportunities. What metrics would help identify bottlenecks? Consider load times, API response times, and resource usage.', + }, + ]; + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private buildIdeationSystemPrompt( + basePrompt: string, + contextFilesPrompt: string | undefined, + category?: IdeaCategory, + existingWorkContext?: string + ): string { + const categoryContext = category + ? `\n\nFocus area: ${this.getCategoryDescription(category)}` + : ''; + + const contextSection = contextFilesPrompt + ? `\n\n## Project Context\n${contextFilesPrompt}` + : ''; + + const existingWorkSection = existingWorkContext ? `\n\n${existingWorkContext}` : ''; + + return basePrompt + categoryContext + contextSection + existingWorkSection; + } + + private getCategoryDescription(category: IdeaCategory): string { + const descriptions: Record = { + feature: 'New features and capabilities that add value for users', + 'ux-ui': 'User interface and user experience improvements', + dx: 'Developer experience and tooling improvements', + growth: 'User acquisition, engagement, and retention', + technical: 'Architecture, performance, and infrastructure', + security: 'Security improvements and vulnerability fixes', + performance: 'Performance optimization and speed improvements', + accessibility: 'Accessibility features and inclusive design', + analytics: 'Analytics, monitoring, and insights features', + }; + return descriptions[category] || ''; + } + + /** + * Build context from app_spec.txt for suggestion generation + * Extracts project name, overview, capabilities, and implemented features + */ + private async buildAppSpecContext(projectPath: string): Promise { + try { + const specPath = getAppSpecPath(projectPath); + const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string; + + const parts: string[] = []; + parts.push('## App Specification'); + + // Extract project name + const projectNames = extractXmlElements(specContent, 'project_name'); + if (projectNames.length > 0 && projectNames[0]) { + parts.push(`**Project:** ${projectNames[0]}`); + } + + // Extract overview + const overviews = extractXmlElements(specContent, 'overview'); + if (overviews.length > 0 && overviews[0]) { + parts.push(`**Overview:** ${overviews[0]}`); + } + + // Extract core capabilities + const capabilities = extractXmlElements(specContent, 'capability'); + if (capabilities.length > 0) { + parts.push('**Core Capabilities:**'); + for (const cap of capabilities) { + parts.push(`- ${cap}`); + } + } + + // Extract implemented features + const implementedFeatures = extractImplementedFeatures(specContent); + if (implementedFeatures.length > 0) { + parts.push('**Implemented Features:**'); + for (const feature of implementedFeatures) { + if (feature.description) { + parts.push(`- ${feature.name}: ${feature.description}`); + } else { + parts.push(`- ${feature.name}`); + } + } + } + + // Only return content if we extracted something meaningful + if (parts.length > 1) { + return parts.join('\n'); + } + return ''; + } catch (error) { + // If file doesn't exist, return empty string silently + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return ''; + } + // For other errors, log and return empty string + logger.warn('Failed to build app spec context:', error); + return ''; + } + } + + /** + * Gather basic project information for context when no context files exist + */ + private async gatherBasicProjectInfo(projectPath: string): Promise { + const parts: string[] = []; + + // Try to read package.json + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + const content = (await secureFs.readFile(packageJsonPath, 'utf-8')) as string; + const pkg = JSON.parse(content); + + parts.push('## Project Information (from package.json)'); + if (pkg.name) parts.push(`**Name:** ${pkg.name}`); + if (pkg.description) parts.push(`**Description:** ${pkg.description}`); + if (pkg.version) parts.push(`**Version:** ${pkg.version}`); + + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + const depNames = Object.keys(allDeps); + + // Detect framework and language + let framework = 'Unknown'; + if (allDeps.react) framework = allDeps.next ? 'Next.js' : 'React'; + else if (allDeps.vue) framework = allDeps.nuxt ? 'Nuxt' : 'Vue'; + else if (allDeps['@angular/core']) framework = 'Angular'; + else if (allDeps.svelte) framework = 'Svelte'; + else if (allDeps.express) framework = 'Express'; + else if (allDeps.fastify) framework = 'Fastify'; + else if (allDeps.koa) framework = 'Koa'; + + const language = allDeps.typescript ? 'TypeScript' : 'JavaScript'; + parts.push(`**Tech Stack:** ${framework} with ${language}`); + + // Key dependencies + const keyDeps = depNames + .filter( + (d) => !d.startsWith('@types/') && !['typescript', 'eslint', 'prettier'].includes(d) + ) + .slice(0, 15); + if (keyDeps.length > 0) { + parts.push(`**Key Dependencies:** ${keyDeps.join(', ')}`); + } + + // Scripts + if (pkg.scripts) { + const scriptNames = Object.keys(pkg.scripts).slice(0, 10); + parts.push(`**Available Scripts:** ${scriptNames.join(', ')}`); + } + } catch { + // No package.json, try other files + } + + // Try to read README.md (first 500 chars) + try { + const readmePath = path.join(projectPath, 'README.md'); + const content = (await secureFs.readFile(readmePath, 'utf-8')) as string; + if (content) { + parts.push('\n## README.md (excerpt)'); + parts.push(content.slice(0, 1000)); + } + } catch { + // No README + } + + // Try to get cached analysis + const cachedAnalysis = await this.getCachedAnalysis(projectPath); + if (cachedAnalysis) { + parts.push('\n## Project Structure Analysis'); + parts.push(cachedAnalysis.summary || ''); + if (cachedAnalysis.routes && cachedAnalysis.routes.length > 0) { + parts.push(`**Routes:** ${cachedAnalysis.routes.map((r) => r.name).join(', ')}`); + } + if (cachedAnalysis.components && cachedAnalysis.components.length > 0) { + parts.push( + `**Components:** ${cachedAnalysis.components + .slice(0, 10) + .map((c) => c.name) + .join( + ', ' + )}${cachedAnalysis.components.length > 10 ? ` and ${cachedAnalysis.components.length - 10} more` : ''}` + ); + } + } + + if (parts.length === 0) { + return null; + } + + return parts.join('\n'); + } + + /** + * Gather existing features and ideas to prevent duplicate suggestions + * Returns a concise list of titles grouped by status to avoid polluting context + */ + private async gatherExistingWorkContext( + projectPath: string, + options?: { includeFeatures?: boolean; includeIdeas?: boolean } + ): Promise { + const { includeFeatures = true, includeIdeas = true } = options ?? {}; + const parts: string[] = []; + + // Load existing features from the board + if (includeFeatures && this.featureLoader) { + try { + const features = await this.featureLoader.getAll(projectPath); + if (features.length > 0) { + parts.push('## Existing Features (Do NOT regenerate these)'); + parts.push( + 'The following features already exist on the board. Do NOT suggest similar ideas:\n' + ); + + // Group features by status for clarity + const byStatus: Record = { + done: [], + 'in-review': [], + 'in-progress': [], + backlog: [], + }; + + for (const feature of features) { + const status = feature.status || 'backlog'; + const title = feature.title || 'Untitled'; + if (byStatus[status]) { + byStatus[status].push(title); + } else { + byStatus['backlog'].push(title); + } + } + + // Output completed features first (most important to not duplicate) + if (byStatus['done'].length > 0) { + parts.push(`**Completed:** ${byStatus['done'].join(', ')}`); + } + if (byStatus['in-review'].length > 0) { + parts.push(`**In Review:** ${byStatus['in-review'].join(', ')}`); + } + if (byStatus['in-progress'].length > 0) { + parts.push(`**In Progress:** ${byStatus['in-progress'].join(', ')}`); + } + if (byStatus['backlog'].length > 0) { + parts.push(`**Backlog:** ${byStatus['backlog'].join(', ')}`); + } + parts.push(''); + } + } catch (error) { + logger.warn('Failed to load existing features:', error); + } + } + + // Load existing ideas + if (includeIdeas) { + try { + const ideas = await this.getIdeas(projectPath); + // Filter out archived ideas + const activeIdeas = ideas.filter((idea) => idea.status !== 'archived'); + + if (activeIdeas.length > 0) { + parts.push('## Existing Ideas (Do NOT regenerate these)'); + parts.push( + 'The following ideas have already been captured. Do NOT suggest similar ideas:\n' + ); + + // Group by category for organization + const byCategory: Record = {}; + for (const idea of activeIdeas) { + const cat = idea.category || 'feature'; + if (!byCategory[cat]) { + byCategory[cat] = []; + } + byCategory[cat].push(idea.title); + } + + for (const [category, titles] of Object.entries(byCategory)) { + parts.push(`**${category}:** ${titles.join(', ')}`); + } + parts.push(''); + } + } catch (error) { + logger.warn('Failed to load existing ideas:', error); + } + } + + return parts.join('\n'); + } + + private async gatherProjectStructure(projectPath: string): Promise<{ + totalFiles: number; + routes: AnalysisFileInfo[]; + components: AnalysisFileInfo[]; + services: AnalysisFileInfo[]; + framework?: string; + language?: string; + dependencies?: string[]; + }> { + const routes: AnalysisFileInfo[] = []; + const components: AnalysisFileInfo[] = []; + const services: AnalysisFileInfo[] = []; + let totalFiles = 0; + let framework: string | undefined; + let language: string | undefined; + const dependencies: string[] = []; + + // Check for package.json to detect framework and dependencies + try { + const packageJsonPath = path.join(projectPath, 'package.json'); + const content = (await secureFs.readFile(packageJsonPath, 'utf-8')) as string; + const pkg = JSON.parse(content); + + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + dependencies.push(...Object.keys(allDeps).slice(0, 20)); // Top 20 deps + + if (allDeps.react) framework = 'React'; + else if (allDeps.vue) framework = 'Vue'; + else if (allDeps.angular) framework = 'Angular'; + else if (allDeps.next) framework = 'Next.js'; + else if (allDeps.express) framework = 'Express'; + + language = allDeps.typescript ? 'TypeScript' : 'JavaScript'; + } catch { + // No package.json + } + + // Scan common directories + const scanPatterns = [ + { dir: 'src/routes', type: 'route' as const }, + { dir: 'src/pages', type: 'route' as const }, + { dir: 'app', type: 'route' as const }, + { dir: 'src/components', type: 'component' as const }, + { dir: 'components', type: 'component' as const }, + { dir: 'src/services', type: 'service' as const }, + { dir: 'src/lib', type: 'service' as const }, + { dir: 'lib', type: 'service' as const }, + ]; + + for (const pattern of scanPatterns) { + const fullPath = path.join(projectPath, pattern.dir); + try { + const files = await this.scanDirectory(fullPath, pattern.type); + totalFiles += files.length; + + if (pattern.type === 'route') routes.push(...files); + else if (pattern.type === 'component') components.push(...files); + else if (pattern.type === 'service') services.push(...files); + } catch { + // Directory doesn't exist + } + } + + return { + totalFiles, + routes: routes.slice(0, 20), + components: components.slice(0, 30), + services: services.slice(0, 20), + framework, + language, + dependencies, + }; + } + + private async scanDirectory( + dirPath: string, + type: 'route' | 'component' | 'service' | 'model' | 'config' | 'test' | 'other' + ): Promise { + const results: AnalysisFileInfo[] = []; + + try { + const entries = (await secureFs.readdir(dirPath, { + withFileTypes: true, + })) as import('fs').Dirent[]; + + for (const entry of entries) { + if (entry.isDirectory()) { + const subResults = await this.scanDirectory(path.join(dirPath, entry.name), type); + results.push(...subResults); + } else if (entry.isFile() && this.isCodeFile(entry.name)) { + results.push({ + path: path.join(dirPath, entry.name), + type, + name: entry.name.replace(/\.(tsx?|jsx?|vue)$/, ''), + }); + } + } + } catch { + // Ignore errors + } + + return results; + } + + private isCodeFile(filename: string): boolean { + return ( + /\.(tsx?|jsx?|vue|svelte)$/.test(filename) && + !filename.includes('.test.') && + !filename.includes('.spec.') + ); + } + + private async generateAnalysisSuggestions( + _projectPath: string, + structure: Awaited> + ): Promise { + // Generate basic suggestions based on project structure analysis + const suggestions: AnalysisSuggestion[] = []; + + if (structure.routes.length > 0 && structure.routes.length < 5) { + suggestions.push({ + id: this.generateId('sug'), + category: 'feature', + title: 'Expand Core Functionality', + description: 'The app has a small number of routes. Consider adding more features.', + rationale: `Only ${structure.routes.length} routes detected. Most apps benefit from additional navigation options.`, + priority: 'medium', + }); + } + + if ( + !structure.dependencies?.includes('react-query') && + !structure.dependencies?.includes('@tanstack/react-query') + ) { + suggestions.push({ + id: this.generateId('sug'), + category: 'technical', + title: 'Add Data Fetching Library', + description: 'Consider adding React Query or similar for better data management.', + rationale: + 'Data fetching libraries provide caching, background updates, and better loading states.', + priority: 'low', + }); + } + + return suggestions; + } + + private generateAnalysisSummary( + structure: Awaited>, + suggestions: AnalysisSuggestion[] + ): string { + const parts: string[] = []; + + if (structure.framework) { + parts.push(`${structure.framework} ${structure.language || ''} application`); + } + + parts.push(`with ${structure.totalFiles} code files`); + parts.push(`${structure.routes.length} routes`); + parts.push(`${structure.components.length} components`); + parts.push(`${structure.services.length} services`); + + const summary = parts.join(', '); + const highPriority = suggestions.filter((s) => s.priority === 'high').length; + + return `${summary}. Found ${suggestions.length} improvement opportunities${highPriority > 0 ? ` (${highPriority} high priority)` : ''}.`; + } + + /** + * Map idea category to feature category + * Used internally for idea-to-feature conversion + */ + private mapIdeaCategoryToFeatureCategory(category: IdeaCategory): string { + return this.mapSuggestionCategoryToFeatureCategory(category); + } + + /** + * Map suggestion/idea category to feature category + * This is the single source of truth for category mapping. + * Used by both idea-to-feature conversion and suggestion-to-feature conversion. + */ + mapSuggestionCategoryToFeatureCategory(category: IdeaCategory): string { + const mapping: Record = { + feature: 'ui', + 'ux-ui': 'enhancement', + dx: 'chore', + growth: 'feature', + technical: 'refactor', + security: 'bug', + performance: 'enhancement', + accessibility: 'enhancement', + analytics: 'feature', + }; + return mapping[category] || 'feature'; + } + + private async saveSessionToDisk( + projectPath: string, + session: IdeationSession, + messages: IdeationMessage[] + ): Promise { + await secureFs.mkdir(getIdeationSessionsDir(projectPath), { recursive: true }); + const data = { session, messages }; + await secureFs.writeFile( + getIdeationSessionPath(projectPath, session.id), + JSON.stringify(data, null, 2), + 'utf-8' + ); + } + + private async loadSessionFromDisk( + projectPath: string, + sessionId: string + ): Promise<{ session: IdeationSession; messages: IdeationMessage[] } | null> { + try { + const content = (await secureFs.readFile( + getIdeationSessionPath(projectPath, sessionId), + 'utf-8' + )) as string; + return JSON.parse(content); + } catch { + return null; + } + } + + private generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/jules_branch/apps/server/src/services/init-script-service.ts b/jules_branch/apps/server/src/services/init-script-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7731c5ee542464b2c12bda78e6f75c4268723bcc --- /dev/null +++ b/jules_branch/apps/server/src/services/init-script-service.ts @@ -0,0 +1,360 @@ +/** + * Init Script Service - Executes worktree initialization scripts + * + * Runs the .automaker/worktree-init.sh script after worktree creation. + * Uses Git Bash on Windows for cross-platform shell script compatibility. + */ + +import { spawn } from 'child_process'; +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform'; +import { findCommand } from '../lib/cli-detection.js'; +import type { EventEmitter } from '../lib/events.js'; +import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js'; +import * as secureFs from '../lib/secure-fs.js'; + +const logger = createLogger('InitScript'); + +export interface InitScriptOptions { + /** Absolute path to the project root */ + projectPath: string; + /** Absolute path to the worktree directory */ + worktreePath: string; + /** Branch name for this worktree */ + branch: string; + /** Event emitter for streaming output */ + emitter: EventEmitter; +} + +interface ShellCommand { + shell: string; + args: string[]; +} + +/** + * Init Script Service + * + * Handles execution of worktree initialization scripts with cross-platform + * shell detection and proper streaming of output via WebSocket events. + */ +export class InitScriptService { + private cachedShellCommand: ShellCommand | null | undefined = undefined; + + /** + * Get the path to the init script for a project + */ + getInitScriptPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'worktree-init.sh'); + } + + /** + * Check if the init script has already been run for a worktree + */ + async hasInitScriptRun(projectPath: string, branch: string): Promise { + const metadata = await readWorktreeMetadata(projectPath, branch); + return metadata?.initScriptRan === true; + } + + /** + * Find the appropriate shell for running scripts + * Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH + */ + async findShellCommand(): Promise { + // Return cached result if available + if (this.cachedShellCommand !== undefined) { + return this.cachedShellCommand; + } + + if (process.platform === 'win32') { + // On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe) + // WSL bash may not be properly configured and causes ENOENT errors + + // First try known Git Bash installation paths + const gitBashPath = await findGitBashPath(); + if (gitBashPath) { + logger.debug(`Found Git Bash at: ${gitBashPath}`); + this.cachedShellCommand = { shell: gitBashPath, args: [] }; + return this.cachedShellCommand; + } + + // Fall back to finding bash in PATH, but skip WSL bash + const bashInPath = await findCommand(['bash']); + if (bashInPath && !bashInPath.toLowerCase().includes('system32')) { + logger.debug(`Found bash in PATH at: ${bashInPath}`); + this.cachedShellCommand = { shell: bashInPath, args: [] }; + return this.cachedShellCommand; + } + + logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.'); + this.cachedShellCommand = null; + return null; + } + + // Unix-like systems: use getShellPaths() and check existence + const shellPaths = getShellPaths(); + const posixShells = shellPaths.filter( + (p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh' + ); + + for (const shellPath of posixShells) { + try { + if (systemPathExists(shellPath)) { + this.cachedShellCommand = { shell: shellPath, args: [] }; + return this.cachedShellCommand; + } + } catch { + // Path not allowed or doesn't exist, continue + } + } + + // Ultimate fallback + if (systemPathExists('/bin/sh')) { + this.cachedShellCommand = { shell: '/bin/sh', args: [] }; + return this.cachedShellCommand; + } + + this.cachedShellCommand = null; + return null; + } + + /** + * Run the worktree initialization script + * Non-blocking - returns immediately after spawning + */ + async runInitScript(options: InitScriptOptions): Promise { + const { projectPath, worktreePath, branch, emitter } = options; + + const scriptPath = this.getInitScriptPath(projectPath); + + // Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY) + try { + await secureFs.access(scriptPath); + } catch { + logger.debug(`No init script found at ${scriptPath}`); + return; + } + + // Check if already run + if (await this.hasInitScriptRun(projectPath, branch)) { + logger.info(`Init script already ran for branch "${branch}", skipping`); + return; + } + + // Get shell command + const shellCmd = await this.findShellCommand(); + if (!shellCmd) { + const error = + process.platform === 'win32' + ? 'Git Bash not found. Please install Git for Windows to run init scripts.' + : 'No shell found (/bin/bash or /bin/sh)'; + logger.error(error); + + // Update metadata with error, preserving existing metadata + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error, + }); + + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error, + }); + return; + } + + logger.info(`Running init script for branch "${branch}" in ${worktreePath}`); + logger.debug(`Using shell: ${shellCmd.shell}`); + + // Update metadata to mark as running + const existingMetadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: existingMetadata?.createdAt || new Date().toISOString(), + pr: existingMetadata?.pr, + initScriptRan: false, + initScriptStatus: 'running', + }); + + // Emit started event + emitter.emit('worktree:init-started', { + projectPath, + worktreePath, + branch, + }); + + // Build safe environment - only pass necessary variables, not all of process.env + // This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY + const safeEnv: Record = { + // Automaker-specific variables + AUTOMAKER_PROJECT_PATH: projectPath, + AUTOMAKER_WORKTREE_PATH: worktreePath, + AUTOMAKER_BRANCH: branch, + + // Essential system variables + PATH: process.env.PATH || '', + HOME: process.env.HOME || '', + USER: process.env.USER || '', + TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp', + + // Shell and locale + SHELL: process.env.SHELL || '', + LANG: process.env.LANG || 'en_US.UTF-8', + LC_ALL: process.env.LC_ALL || '', + + // Force color output even though we're not a TTY + FORCE_COLOR: '1', + npm_config_color: 'always', + CLICOLOR_FORCE: '1', + + // Git configuration + GIT_TERMINAL_PROMPT: '0', + }; + + // Platform-specific additions + if (process.platform === 'win32') { + safeEnv.USERPROFILE = process.env.USERPROFILE || ''; + safeEnv.APPDATA = process.env.APPDATA || ''; + safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || ''; + safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows'; + safeEnv.TEMP = process.env.TEMP || ''; + } + + // Spawn the script with safe environment + const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], { + cwd: worktreePath, + env: safeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Stream stdout + child.stdout?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stdout', + content, + }); + }); + + // Stream stderr + child.stderr?.on('data', (data: Buffer) => { + const content = data.toString(); + emitter.emit('worktree:init-output', { + projectPath, + branch, + type: 'stderr', + content, + }); + }); + + // Handle completion + child.on('exit', async (code) => { + const success = code === 0; + const status = success ? 'success' : 'failed'; + + logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: status, + initScriptError: success ? undefined : `Exit code: ${code}`, + }); + + // Emit completion event + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success, + exitCode: code, + }); + }); + + child.on('error', async (error) => { + logger.error(`Init script error for branch "${branch}":`, error); + + // Update metadata + const metadata = await readWorktreeMetadata(projectPath, branch); + await writeWorktreeMetadata(projectPath, branch, { + branch, + createdAt: metadata?.createdAt || new Date().toISOString(), + pr: metadata?.pr, + initScriptRan: true, + initScriptStatus: 'failed', + initScriptError: error.message, + }); + + // Emit completion with error + emitter.emit('worktree:init-completed', { + projectPath, + worktreePath, + branch, + success: false, + error: error.message, + }); + }); + } + + /** + * Force re-run the worktree initialization script + * Ignores the initScriptRan flag - useful for testing or re-setup + */ + async forceRunInitScript(options: InitScriptOptions): Promise { + const { projectPath, branch } = options; + + // Reset the initScriptRan flag so the script will run + const metadata = await readWorktreeMetadata(projectPath, branch); + if (metadata) { + await writeWorktreeMetadata(projectPath, branch, { + ...metadata, + initScriptRan: false, + initScriptStatus: undefined, + initScriptError: undefined, + }); + } + + // Now run the script + await this.runInitScript(options); + } +} + +// Singleton instance for convenience +let initScriptService: InitScriptService | null = null; + +/** + * Get the singleton InitScriptService instance + */ +export function getInitScriptService(): InitScriptService { + if (!initScriptService) { + initScriptService = new InitScriptService(); + } + return initScriptService; +} + +// Export convenience functions that use the singleton +export const getInitScriptPath = (projectPath: string) => + getInitScriptService().getInitScriptPath(projectPath); + +export const hasInitScriptRun = (projectPath: string, branch: string) => + getInitScriptService().hasInitScriptRun(projectPath, branch); + +export const runInitScript = (options: InitScriptOptions) => + getInitScriptService().runInitScript(options); + +export const forceRunInitScript = (options: InitScriptOptions) => + getInitScriptService().forceRunInitScript(options); diff --git a/jules_branch/apps/server/src/services/mcp-test-service.ts b/jules_branch/apps/server/src/services/mcp-test-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..4232de60167d295d4c5108e2d812d356d382fab4 --- /dev/null +++ b/jules_branch/apps/server/src/services/mcp-test-service.ts @@ -0,0 +1,251 @@ +/** + * MCP Test Service + * + * Provides functionality to test MCP server connections and list available tools. + * Supports stdio, SSE, and HTTP transport types. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import type { MCPServerConfig, MCPToolInfo } from '@automaker/types'; +import type { SettingsService } from './settings-service.js'; + +const execAsync = promisify(exec); +const DEFAULT_TIMEOUT = 10000; // 10 seconds +const IS_WINDOWS = process.platform === 'win32'; + +export interface MCPTestResult { + success: boolean; + tools?: MCPToolInfo[]; + error?: string; + connectionTime?: number; + serverInfo?: { + name?: string; + version?: string; + }; +} + +/** + * MCP Test Service for testing server connections and listing tools + */ +export class MCPTestService { + private settingsService: SettingsService; + + constructor(settingsService: SettingsService) { + this.settingsService = settingsService; + } + + /** + * Test connection to an MCP server and list its tools + */ + async testServer(serverConfig: MCPServerConfig): Promise { + const startTime = Date.now(); + let client: Client | null = null; + let transport: + | StdioClientTransport + | SSEClientTransport + | StreamableHTTPClientTransport + | null = null; + + try { + client = new Client({ + name: 'automaker-mcp-test', + version: '1.0.0', + }); + + // Create transport based on server type + transport = await this.createTransport(serverConfig); + + // Connect with timeout + await Promise.race([ + client.connect(transport), + this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'), + ]); + + // List tools with timeout + const toolsResult = await Promise.race([ + client.listTools(), + this.timeout<{ + tools: Array<{ + name: string; + description?: string; + inputSchema?: Record; + }>; + }>(DEFAULT_TIMEOUT, 'List tools timeout'), + ]); + + const connectionTime = Date.now() - startTime; + + // Convert tools to MCPToolInfo format + const tools: MCPToolInfo[] = (toolsResult.tools || []).map( + (tool: { name: string; description?: string; inputSchema?: Record }) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + enabled: true, + }) + ); + + return { + success: true, + tools, + connectionTime, + serverInfo: { + name: serverConfig.name, + version: undefined, // Could be extracted from server info if available + }, + }; + } catch (error) { + const connectionTime = Date.now() - startTime; + return { + success: false, + error: this.getErrorMessage(error), + connectionTime, + }; + } finally { + // Clean up client connection and ensure process termination + await this.cleanupConnection(client, transport); + } + } + + /** + * Clean up MCP client connection and terminate spawned processes + * + * On Windows, child processes spawned via 'cmd /c' don't get terminated when the + * parent process is killed. We use taskkill with /t flag to kill the entire process tree. + * This prevents orphaned MCP server processes that would spam logs with ping warnings. + * + * IMPORTANT: We must run taskkill BEFORE client.close() because: + * - client.close() kills only the parent cmd.exe process + * - This orphans the child node.exe processes before we can kill them + * - taskkill /t needs the parent PID to exist to traverse the process tree + */ + private async cleanupConnection( + client: Client | null, + transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null + ): Promise { + // Get the PID before any cleanup (only available for stdio transports) + const pid = transport instanceof StdioClientTransport ? transport.pid : null; + + // On Windows with stdio transport, kill the entire process tree FIRST + // This must happen before client.close() which would orphan child processes + if (IS_WINDOWS && pid) { + try { + // taskkill /f = force, /t = kill process tree, /pid = process ID + await execAsync(`taskkill /f /t /pid ${pid}`); + } catch { + // Process may have already exited, which is fine + } + } + + // Now do the standard close (may be a no-op if taskkill already killed everything) + if (client) { + try { + await client.close(); + } catch { + // Expected if taskkill already terminated the process + } + } + } + + /** + * Test server by ID (looks up config from settings) + */ + async testServerById(serverId: string): Promise { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId); + + if (!serverConfig) { + return { + success: false, + error: `Server with ID "${serverId}" not found`, + }; + } + + return this.testServer(serverConfig); + } catch (error) { + return { + success: false, + error: this.getErrorMessage(error), + }; + } + } + + /** + * Create appropriate transport based on server type + */ + private async createTransport( + config: MCPServerConfig + ): Promise { + if (config.type === 'sse') { + if (!config.url) { + throw new Error('URL is required for SSE transport'); + } + // Use eventSourceInit workaround for SSE headers (SDK bug workaround) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436 + const headers = config.headers; + return new SSEClientTransport(new URL(config.url), { + requestInit: headers ? { headers } : undefined, + eventSourceInit: headers + ? { + fetch: (url: string | URL | Request, init?: RequestInit) => { + const fetchHeaders = new Headers(init?.headers || {}); + for (const [key, value] of Object.entries(headers)) { + fetchHeaders.set(key, value); + } + return fetch(url, { ...init, headers: fetchHeaders }); + }, + } + : undefined, + }); + } + + if (config.type === 'http') { + if (!config.url) { + throw new Error('URL is required for HTTP transport'); + } + return new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: config.headers + ? { + headers: config.headers, + } + : undefined, + }); + } + + // Default to stdio + if (!config.command) { + throw new Error('Command is required for stdio transport'); + } + + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + }); + } + + /** + * Create a timeout promise + */ + private timeout(ms: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); + } + + /** + * Extract error message from unknown error + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); + } +} diff --git a/jules_branch/apps/server/src/services/merge-service.ts b/jules_branch/apps/server/src/services/merge-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0301d4a860c6b2773b4562d1a6bcc49598675ca7 --- /dev/null +++ b/jules_branch/apps/server/src/services/merge-service.ts @@ -0,0 +1,299 @@ +/** + * MergeService - Direct merge operations without HTTP + * + * Extracted from worktree merge route to allow internal service calls. + */ + +import { createLogger, isValidBranchName, isValidRemoteName } from '@automaker/utils'; +import { type EventEmitter } from '../lib/events.js'; +import { execGitCommand } from '@automaker/git-utils'; +const logger = createLogger('MergeService'); + +export interface MergeOptions { + squash?: boolean; + message?: string; + deleteWorktreeAndBranch?: boolean; + /** Remote name to fetch from before merging (defaults to 'origin') */ + remote?: string; +} + +export interface MergeServiceResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + conflictFiles?: string[]; + mergedBranch?: string; + targetBranch?: string; + deleted?: { + worktreeDeleted: boolean; + branchDeleted: boolean; + }; +} + +/** + * Perform a git merge operation directly without HTTP. + * + * @param projectPath - Path to the git repository + * @param branchName - Source branch to merge + * @param worktreePath - Path to the worktree (used for deletion if requested) + * @param targetBranch - Branch to merge into (defaults to 'main') + * @param options - Merge options + * @param options.squash - If true, perform a squash merge + * @param options.message - Custom merge commit message + * @param options.deleteWorktreeAndBranch - If true, delete worktree and branch after merge + * @param options.remote - Remote name to fetch from before merging (defaults to 'origin') + */ +export async function performMerge( + projectPath: string, + branchName: string, + worktreePath: string, + targetBranch: string = 'main', + options?: MergeOptions, + emitter?: EventEmitter +): Promise { + if (!projectPath || !branchName || !worktreePath) { + return { + success: false, + error: 'projectPath, branchName, and worktreePath are required', + }; + } + + const mergeTo = targetBranch || 'main'; + + // Validate branch names early to reject invalid input before any git operations + if (!isValidBranchName(branchName)) { + return { + success: false, + error: `Invalid source branch name: "${branchName}"`, + }; + } + if (!isValidBranchName(mergeTo)) { + return { + success: false, + error: `Invalid target branch name: "${mergeTo}"`, + }; + } + + // Validate source branch exists (using safe array-based command) + try { + await execGitCommand(['rev-parse', '--verify', branchName], projectPath); + } catch { + return { + success: false, + error: `Branch "${branchName}" does not exist`, + }; + } + + // Validate target branch exists (using safe array-based command) + try { + await execGitCommand(['rev-parse', '--verify', mergeTo], projectPath); + } catch { + return { + success: false, + error: `Target branch "${mergeTo}" does not exist`, + }; + } + + // Validate the remote name to prevent git option injection. + // Reject invalid remote names so the caller knows their input was wrong, + // consistent with how invalid branch names are handled above. + const remote = options?.remote || 'origin'; + if (!isValidRemoteName(remote)) { + logger.warn('Invalid remote name supplied to merge-service', { + remote, + projectPath, + }); + return { + success: false, + error: `Invalid remote name: "${remote}"`, + }; + } + + // Fetch latest from remote before merging to ensure we have up-to-date refs + try { + await execGitCommand(['fetch', remote], projectPath); + } catch (fetchError) { + logger.warn('Failed to fetch from remote before merge; proceeding with local refs', { + remote, + projectPath, + error: (fetchError as Error).message, + }); + // Non-fatal: proceed with local refs if fetch fails (e.g. offline) + } + + // Emit merge:start after validating inputs + emitter?.emit('merge:start', { branchName, targetBranch: mergeTo, worktreePath }); + + // Merge the feature branch into the target branch (using safe array-based commands) + const mergeMessage = options?.message || `Merge ${branchName} into ${mergeTo}`; + const mergeArgs = options?.squash + ? ['merge', '--squash', branchName] + : ['merge', branchName, '-m', mergeMessage]; + + try { + // Set LC_ALL=C so git always emits English output regardless of the system + // locale, making text-based conflict detection reliable. + await execGitCommand(mergeArgs, projectPath, { LC_ALL: 'C' }); + } catch (mergeError: unknown) { + // Check if this is a merge conflict. We use a multi-layer strategy so + // that detection is reliable even when locale settings vary or git's text + // output changes across versions: + // + // 1. Primary (text-based): scan the error output for well-known English + // conflict markers. Because we pass LC_ALL=C above these strings are + // always in English, but we keep the check as one layer among several. + // + // 2. Unmerged-path check: run `git diff --name-only --diff-filter=U` + // (locale-stable) and treat any non-empty output as a conflict + // indicator, capturing the file list at the same time. + // + // 3. Fallback status check: run `git status --porcelain` and look for + // lines whose first two characters indicate an unmerged state + // (UU, AA, DD, AU, UA, DU, UD). + // + // hasConflicts is true when ANY of the three layers returns positive. + const err = mergeError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + + // Layer 1 – text matching (locale-safe because we set LC_ALL=C above). + const textIndicatesConflict = + output.includes('CONFLICT') || output.includes('Automatic merge failed'); + + // Layers 2 & 3 – repository state inspection (locale-independent). + // Layer 2: get conflicted files via diff (also locale-stable output). + let conflictFiles: string[] | undefined; + let diffIndicatesConflict = false; + try { + const diffOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + projectPath, + { LC_ALL: 'C' } + ); + const files = diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + if (files.length > 0) { + diffIndicatesConflict = true; + conflictFiles = files; + } + } catch { + // If we can't get the file list, leave conflictFiles undefined so callers + // can distinguish "no conflicts" (empty array) from "unknown due to diff failure" (undefined) + } + + // Layer 3: check for unmerged paths via machine-readable git status. + let hasUnmergedPaths = false; + try { + const statusOutput = await execGitCommand(['status', '--porcelain'], projectPath, { + LC_ALL: 'C', + }); + // Unmerged status codes occupy the first two characters of each line. + // Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD. + const unmergedLines = statusOutput + .split('\n') + .filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + hasUnmergedPaths = unmergedLines.length > 0; + + // If Layer 2 did not populate conflictFiles (e.g. diff failed or returned + // nothing) but Layer 3 does detect unmerged paths, parse the status lines + // to extract filenames and assign them to conflictFiles so callers always + // receive an accurate file list when conflicts are present. + if (hasUnmergedPaths && conflictFiles === undefined) { + const parsedFiles = unmergedLines + .map((line) => line.slice(2).trim()) + .filter((f) => f.length > 0); + // Deduplicate (e.g. rename entries can appear twice) + conflictFiles = [...new Set(parsedFiles)]; + } + } catch { + // git status failing is itself a sign something is wrong; leave + // hasUnmergedPaths as false and rely on the other layers. + } + + const hasConflicts = textIndicatesConflict || diffIndicatesConflict || hasUnmergedPaths; + + if (hasConflicts) { + // Emit merge:conflict event with conflict details + emitter?.emit('merge:conflict', { branchName, targetBranch: mergeTo, conflictFiles }); + + return { + success: false, + error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`, + hasConflicts: true, + conflictFiles, + }; + } + + // Emit merge:error for non-conflict errors before re-throwing + emitter?.emit('merge:error', { + branchName, + targetBranch: mergeTo, + error: err.message || String(mergeError), + }); + + // Re-throw non-conflict errors + throw mergeError; + } + + // If squash merge, need to commit (using safe array-based command) + if (options?.squash) { + const squashMessage = options?.message || `Merge ${branchName} (squash)`; + try { + await execGitCommand(['commit', '-m', squashMessage], projectPath); + } catch (commitError: unknown) { + const err = commitError as { message?: string }; + // Emit merge:error so subscribers always receive either merge:success or merge:error + emitter?.emit('merge:error', { + branchName, + targetBranch: mergeTo, + error: err.message || String(commitError), + }); + throw commitError; + } + } + + // Optionally delete the worktree and branch after merging + let worktreeDeleted = false; + let branchDeleted = false; + + if (options?.deleteWorktreeAndBranch) { + // Remove the worktree + try { + await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath); + worktreeDeleted = true; + } catch { + // Try with prune if remove fails + try { + await execGitCommand(['worktree', 'prune'], projectPath); + worktreeDeleted = true; + } catch { + logger.warn(`Failed to remove worktree: ${worktreePath}`); + } + } + + // Delete the branch (but not main/master) + if (branchName !== 'main' && branchName !== 'master') { + try { + await execGitCommand(['branch', '-D', branchName], projectPath); + branchDeleted = true; + } catch { + logger.warn(`Failed to delete branch: ${branchName}`); + } + } + } + + // Emit merge:success with merged branch, target branch, and deletion info + emitter?.emit('merge:success', { + mergedBranch: branchName, + targetBranch: mergeTo, + deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + }); + + return { + success: true, + mergedBranch: branchName, + targetBranch: mergeTo, + deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined, + }; +} diff --git a/jules_branch/apps/server/src/services/notification-service.ts b/jules_branch/apps/server/src/services/notification-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..216853089d4addf1e5535ef1d8367d8c33f38f10 --- /dev/null +++ b/jules_branch/apps/server/src/services/notification-service.ts @@ -0,0 +1,280 @@ +/** + * Notification Service - Handles reading/writing notifications to JSON files + * + * Provides persistent storage for project-level notifications in + * {projectPath}/.automaker/notifications.json + * + * Notifications alert users when: + * - Features reach specific statuses (waiting_approval, verified) + * - Long-running operations complete (spec generation) + */ + +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform'; +import type { Notification, NotificationsFile, NotificationType } from '@automaker/types'; +import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types'; +import type { EventEmitter } from '../lib/events.js'; +import { randomUUID } from 'crypto'; + +const logger = createLogger('NotificationService'); + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + // Clean up temp file if it exists + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Input for creating a new notification + */ +export interface CreateNotificationInput { + type: NotificationType; + title: string; + message: string; + featureId?: string; + projectPath: string; +} + +/** + * NotificationService - Manages persistent storage of notifications + * + * Handles reading and writing notifications to JSON files with atomic operations + * for reliability. Each project has its own notifications.json file. + */ +export class NotificationService { + private events: EventEmitter | null = null; + + /** + * Set the event emitter for broadcasting notification events + */ + setEventEmitter(events: EventEmitter): void { + this.events = events; + } + + /** + * Get all notifications for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to array of notifications + */ + async getNotifications(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + // Filter out dismissed notifications and sort by date (newest first) + return file.notifications + .filter((n) => !n.dismissed) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + } + + /** + * Get unread notification count for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to unread count + */ + async getUnreadCount(projectPath: string): Promise { + const notifications = await this.getNotifications(projectPath); + return notifications.filter((n) => !n.read).length; + } + + /** + * Create a new notification + * + * @param input - Notification creation input + * @returns Promise resolving to the created notification + */ + async createNotification(input: CreateNotificationInput): Promise { + const { projectPath, type, title, message, featureId } = input; + + // Ensure automaker directory exists + await ensureAutomakerDir(projectPath); + + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification: Notification = { + id: randomUUID(), + type, + title, + message, + createdAt: new Date().toISOString(), + read: false, + dismissed: false, + featureId, + projectPath, + }; + + file.notifications.push(notification); + await atomicWriteJson(notificationsPath, file); + + logger.info(`Created notification: ${title} for project ${projectPath}`); + + // Emit event for real-time updates + if (this.events) { + this.events.emit('notification:created', notification); + } + + return notification; + } + + /** + * Mark a notification as read + * + * @param projectPath - Absolute path to project directory + * @param notificationId - ID of the notification to mark as read + * @returns Promise resolving to the updated notification or null if not found + */ + async markAsRead(projectPath: string, notificationId: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification = file.notifications.find((n) => n.id === notificationId); + if (!notification) { + return null; + } + + notification.read = true; + await atomicWriteJson(notificationsPath, file); + + logger.info(`Marked notification ${notificationId} as read`); + return notification; + } + + /** + * Mark all notifications as read for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of notifications marked as read + */ + async markAllAsRead(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + let count = 0; + for (const notification of file.notifications) { + if (!notification.read && !notification.dismissed) { + notification.read = true; + count++; + } + } + + if (count > 0) { + await atomicWriteJson(notificationsPath, file); + logger.info(`Marked ${count} notifications as read`); + } + + return count; + } + + /** + * Dismiss a notification + * + * @param projectPath - Absolute path to project directory + * @param notificationId - ID of the notification to dismiss + * @returns Promise resolving to true if notification was dismissed + */ + async dismissNotification(projectPath: string, notificationId: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + const notification = file.notifications.find((n) => n.id === notificationId); + if (!notification) { + return false; + } + + notification.dismissed = true; + await atomicWriteJson(notificationsPath, file); + + logger.info(`Dismissed notification ${notificationId}`); + return true; + } + + /** + * Dismiss all notifications for a project + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to number of notifications dismissed + */ + async dismissAll(projectPath: string): Promise { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + notificationsPath, + DEFAULT_NOTIFICATIONS_FILE + ); + + let count = 0; + for (const notification of file.notifications) { + if (!notification.dismissed) { + notification.dismissed = true; + count++; + } + } + + if (count > 0) { + await atomicWriteJson(notificationsPath, file); + logger.info(`Dismissed ${count} notifications`); + } + + return count; + } +} + +// Singleton instance +let notificationServiceInstance: NotificationService | null = null; + +/** + * Get the singleton notification service instance + */ +export function getNotificationService(): NotificationService { + if (!notificationServiceInstance) { + notificationServiceInstance = new NotificationService(); + } + return notificationServiceInstance; +} diff --git a/jules_branch/apps/server/src/services/ntfy-service.ts b/jules_branch/apps/server/src/services/ntfy-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..c63ac6e207d6fac7b910e61465e79e7292cdcd61 --- /dev/null +++ b/jules_branch/apps/server/src/services/ntfy-service.ts @@ -0,0 +1,282 @@ +/** + * Ntfy Service - Sends push notifications via ntfy.sh + * + * Provides integration with ntfy.sh for push notifications. + * Supports custom servers, authentication, tags, emojis, and click actions. + * + * @see https://docs.ntfy.sh/publish/ + */ + +import { createLogger } from '@automaker/utils'; +import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types'; + +const logger = createLogger('Ntfy'); + +/** Default timeout for ntfy HTTP requests (10 seconds) */ +const DEFAULT_NTFY_TIMEOUT = 10000; + +// Re-export EventHookContext as NtfyContext for backward compatibility +export type NtfyContext = EventHookContext; + +/** + * Ntfy Service + * + * Handles sending notifications to ntfy.sh endpoints. + */ +export class NtfyService { + /** + * Send a notification to a ntfy.sh endpoint + * + * @param endpoint The ntfy.sh endpoint configuration + * @param options Notification options (title, body, tags, etc.) + * @param context Context for variable substitution + */ + async sendNotification( + endpoint: NtfyEndpointConfig, + options: { + title?: string; + body?: string; + tags?: string; + emoji?: string; + clickUrl?: string; + priority?: 1 | 2 | 3 | 4 | 5; + }, + context: NtfyContext + ): Promise<{ success: boolean; error?: string }> { + if (!endpoint.enabled) { + logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`); + return { success: false, error: 'Endpoint is disabled' }; + } + + // Validate endpoint configuration + const validationError = this.validateEndpoint(endpoint); + if (validationError) { + logger.error(`Invalid ntfy endpoint configuration: ${validationError}`); + return { success: false, error: validationError }; + } + + // Build URL + const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash + const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`; + + // Build headers + const headers: Record = { + 'Content-Type': 'text/plain; charset=utf-8', + }; + + // Title (with variable substitution) + const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context); + if (title) { + headers['Title'] = title; + } + + // Priority + const priority = options.priority || 3; + headers['Priority'] = String(priority); + + // Tags and emoji + const tags = this.buildTags( + options.tags || endpoint.defaultTags, + options.emoji || endpoint.defaultEmoji + ); + if (tags) { + headers['Tags'] = tags; + } + + // Click action URL + const clickUrl = this.substituteVariables( + options.clickUrl || endpoint.defaultClickUrl || '', + context + ); + if (clickUrl) { + headers['Click'] = clickUrl; + } + + // Authentication + this.addAuthHeaders(headers, endpoint); + + // Message body (with variable substitution) + const body = this.substituteVariables(options.body || this.getDefaultBody(context), context); + + logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT); + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`); + return { + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + logger.info(`Ntfy notification sent successfully to ${endpoint.name}`); + return { success: true }; + } catch (error) { + if ((error as Error).name === 'AbortError') { + logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`); + return { success: false, error: 'Request timed out' }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Ntfy notification failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Validate an ntfy endpoint configuration + */ + validateEndpoint(endpoint: NtfyEndpointConfig): string | null { + // Validate server URL + if (!endpoint.serverUrl) { + return 'Server URL is required'; + } + + try { + new URL(endpoint.serverUrl); + } catch { + return 'Invalid server URL format'; + } + + // Validate topic + if (!endpoint.topic) { + return 'Topic is required'; + } + + if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) { + return 'Topic cannot contain spaces'; + } + + // Validate authentication + if (endpoint.authType === 'basic') { + if (!endpoint.username || !endpoint.password) { + return 'Username and password are required for basic authentication'; + } + } else if (endpoint.authType === 'token') { + if (!endpoint.token) { + return 'Access token is required for token authentication'; + } + } + + return null; + } + + /** + * Build tags string from tags and emoji + */ + private buildTags(tags?: string, emoji?: string): string { + const tagList: string[] = []; + + if (tags) { + // Split by comma and trim whitespace + const parsedTags = tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + tagList.push(...parsedTags); + } + + if (emoji) { + // Add emoji as first tag if it looks like a shortcode + if (emoji.startsWith(':') && emoji.endsWith(':')) { + tagList.unshift(emoji.slice(1, -1)); + } else if (!emoji.includes(' ')) { + // If it's a single emoji or shortcode without colons, add as-is + tagList.unshift(emoji); + } + } + + return tagList.join(','); + } + + /** + * Add authentication headers based on auth type + */ + private addAuthHeaders(headers: Record, endpoint: NtfyEndpointConfig): void { + if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) { + const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString( + 'base64' + ); + headers['Authorization'] = `Basic ${credentials}`; + } else if (endpoint.authType === 'token' && endpoint.token) { + headers['Authorization'] = `Bearer ${endpoint.token}`; + } + } + + /** + * Get default title based on event context + */ + private getDefaultTitle(context: NtfyContext): string { + const eventName = this.formatEventName(context.eventType); + if (context.featureName) { + return `${eventName}: ${context.featureName}`; + } + return eventName; + } + + /** + * Get default body based on event context + */ + private getDefaultBody(context: NtfyContext): string { + const lines: string[] = []; + + if (context.featureName) { + lines.push(`Feature: ${context.featureName}`); + } + if (context.featureId) { + lines.push(`ID: ${context.featureId}`); + } + if (context.projectName) { + lines.push(`Project: ${context.projectName}`); + } + if (context.error) { + lines.push(`Error: ${context.error}`); + } + lines.push(`Time: ${context.timestamp}`); + + return lines.join('\n'); + } + + /** + * Format event type to human-readable name + */ + private formatEventName(eventType: string): string { + const eventNames: Record = { + feature_created: 'Feature Created', + feature_success: 'Feature Completed', + feature_error: 'Feature Failed', + auto_mode_complete: 'Auto Mode Complete', + auto_mode_error: 'Auto Mode Error', + }; + return eventNames[eventType] || eventType; + } + + /** + * Substitute {{variable}} placeholders in a string + */ + private substituteVariables(template: string, context: NtfyContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof NtfyContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + } +} + +// Singleton instance +export const ntfyService = new NtfyService(); diff --git a/jules_branch/apps/server/src/services/pipeline-orchestrator.ts b/jules_branch/apps/server/src/services/pipeline-orchestrator.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3b1e53a1403fa2f4505442e5c431a8041280e79 --- /dev/null +++ b/jules_branch/apps/server/src/services/pipeline-orchestrator.ts @@ -0,0 +1,691 @@ +/** + * PipelineOrchestrator - Pipeline step execution and coordination + */ + +import path from 'path'; +import type { + Feature, + PipelineStep, + PipelineConfig, + FeatureStatusWithPipeline, +} from '@automaker/types'; +import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { resolveModelString, DEFAULT_MODELS } from '@automaker/model-resolver'; +import * as secureFs from '../lib/secure-fs.js'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../lib/settings-helpers.js'; +import { validateWorkingDirectory } from '../lib/sdk-options.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { FeatureStateManager } from './feature-state-manager.js'; +import type { AgentExecutor } from './agent-executor.js'; +import type { WorktreeResolver } from './worktree-resolver.js'; +import type { SettingsService } from './settings-service.js'; +import type { ConcurrencyManager } from './concurrency-manager.js'; +import { pipelineService } from './pipeline-service.js'; +import type { TestRunnerService, TestRunStatus } from './test-runner-service.js'; +import { performMerge } from './merge-service.js'; +import type { + PipelineContext, + PipelineStatusInfo, + StepResult, + MergeResult, + UpdateFeatureStatusFn, + BuildFeaturePromptFn, + ExecuteFeatureFn, + RunAgentFn, +} from './pipeline-types.js'; + +// Re-export types for backward compatibility +export type { + PipelineContext, + PipelineStatusInfo, + StepResult, + MergeResult, + UpdateFeatureStatusFn, + BuildFeaturePromptFn, + ExecuteFeatureFn, + RunAgentFn, +} from './pipeline-types.js'; + +const logger = createLogger('PipelineOrchestrator'); + +export class PipelineOrchestrator { + constructor( + private eventBus: TypedEventBus, + private featureStateManager: FeatureStateManager, + private agentExecutor: AgentExecutor, + private testRunnerService: TestRunnerService, + private worktreeResolver: WorktreeResolver, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private updateFeatureStatusFn: UpdateFeatureStatusFn, + private loadContextFilesFn: typeof loadContextFiles, + private buildFeaturePromptFn: BuildFeaturePromptFn, + private executeFeatureFn: ExecuteFeatureFn, + private runAgentFn: RunAgentFn + ) {} + + async executePipeline(ctx: PipelineContext): Promise { + const { + projectPath, + featureId, + feature, + steps, + workDir, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + } = ctx; + const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]'); + const contextResult = await this.loadContextFilesFn({ + projectPath, + fsModule: secureFs as Parameters[0]['fsModule'], + taskContext: { title: feature.title ?? '', description: feature.description ?? '' }, + }); + const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd); + const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let previousContext = ''; + try { + previousContext = (await secureFs.readFile(contextPath, 'utf-8')) as string; + } catch { + /* */ + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (abortController.signal.aborted) throw new Error('Pipeline execution aborted'); + await this.updateFeatureStatusFn(projectPath, featureId, `pipeline_${step.id}`); + this.eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId, + branchName: feature.branchName ?? null, + content: `Starting pipeline step ${i + 1}/${steps.length}: ${step.name}`, + projectPath, + }); + this.eventBus.emitAutoModeEvent('pipeline_step_started', { + featureId, + stepId: step.id, + stepName: step.name, + stepIndex: i, + totalSteps: steps.length, + projectPath, + }); + const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + const currentStatus = `pipeline_${step.id}`; + await this.runAgentFn( + workDir, + featureId, + this.buildPipelineStepPrompt(step, feature, previousContext, prompts.taskExecution), + abortController, + projectPath, + undefined, + model, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + previousContent: previousContext, + systemPrompt: contextFilesPrompt || undefined, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + thinkingLevel: feature.thinkingLevel, + reasoningEffort: feature.reasoningEffort, + status: currentStatus, + providerId: feature.providerId, + } + ); + 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.\n\n` + + `**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` + + `\n` + + `## Summary: ${step.name}\n\n` + + `### Changes Implemented\n` + + `- [List all changes made in this step]\n\n` + + `### Files Modified\n` + + `- [List all files modified in this step]\n\n` + + `### Outcome\n` + + `- [Describe the result of this step]\n` + + `\n\n` + + `The and tags MUST be on their own lines. This is REQUIRED.` + ); + } + + async detectPipelineStatus( + projectPath: string, + featureId: string, + currentStatus: FeatureStatusWithPipeline + ): Promise { + const isPipeline = pipelineService.isPipelineStatus(currentStatus); + if (!isPipeline) + return { + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + const stepId = pipelineService.getStepIdFromStatus(currentStatus); + if (!stepId) + return { + isPipeline: true, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + const config = await pipelineService.getPipelineConfig(projectPath); + if (!config || config.steps.length === 0) + return { isPipeline: true, stepId, stepIndex: -1, totalSteps: 0, step: null, config: null }; + const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + return { + isPipeline: true, + stepId, + stepIndex, + totalSteps: sortedSteps.length, + step: stepIndex === -1 ? null : sortedSteps[stepIndex], + config, + }; + } + + async resumePipeline( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo + ): Promise { + const featureId = feature.id; + const contextPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'); + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + /* No context */ + } + + if (!hasContext) { + logger.warn(`No context for feature ${featureId}, restarting pipeline`); + await this.updateFeatureStatusFn(projectPath, featureId, 'in_progress'); + return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); + } + + if (pipelineInfo.stepIndex === -1) { + logger.warn(`Step ${pipelineInfo.stepId} no longer exists, completing feature`); + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + const runningEntryForStep = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForStep?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline step no longer exists', + projectPath, + }); + } + return; + } + + if (!pipelineInfo.config) throw new Error('Pipeline config is null but stepIndex is valid'); + return this.resumeFromStep( + projectPath, + feature, + useWorktrees, + pipelineInfo.stepIndex, + pipelineInfo.config + ); + } + + /** Resume from a specific step index */ + async resumeFromStep( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + startFromStepIndex: number, + pipelineConfig: PipelineConfig + ): Promise { + const featureId = feature.id; + const allSortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + if (startFromStepIndex < 0 || startFromStepIndex >= allSortedSteps.length) + throw new Error(`Invalid step index: ${startFromStepIndex}`); + + const excludedStepIds = new Set(feature.excludedPipelineSteps || []); + let currentStep = allSortedSteps[startFromStepIndex]; + + if (excludedStepIds.has(currentStep.id)) { + const nextStatus = pipelineService.getNextStatus( + `pipeline_${currentStep.id}`, + pipelineConfig, + feature.skipTests ?? false, + feature.excludedPipelineSteps + ); + if (!pipelineService.isPipelineStatus(nextStatus)) { + await this.updateFeatureStatusFn(projectPath, featureId, nextStatus); + const runningEntryForExcluded = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForExcluded?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed (remaining steps excluded)', + projectPath, + }); + } + return; + } + const nextStepId = pipelineService.getStepIdFromStatus(nextStatus); + const nextStepIndex = allSortedSteps.findIndex((s) => s.id === nextStepId); + if (nextStepIndex === -1) throw new Error(`Next step ${nextStepId} not found`); + startFromStepIndex = nextStepIndex; + } + + const stepsToExecute = allSortedSteps + .slice(startFromStepIndex) + .filter((step) => !excludedStepIds.has(step.id)); + if (stepsToExecute.length === 0) { + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + const runningEntryForAllExcluded = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForAllExcluded?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed (all steps excluded)', + projectPath, + }); + } + return; + } + + const runningEntry = this.concurrencyManager.acquire({ + featureId, + projectPath, + isAutoMode: false, + allowReuse: true, + }); + const abortController = runningEntry.abortController; + runningEntry.branchName = feature.branchName ?? null; + let pipelineCompleted = false; + + try { + validateWorkingDirectory(projectPath); + let worktreePath: string | null = null; + const branchName = feature.branchName; + + if (useWorktrees && branchName) { + worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); + if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + runningEntry.worktreePath = worktreePath; + runningEntry.branchName = branchName ?? null; + + this.eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + branchName: branchName ?? null, + feature: { + id: featureId, + title: feature.title || 'Resuming Pipeline', + description: feature.description, + }, + }); + + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + const context: PipelineContext = { + projectPath, + featureId, + feature, + steps: stepsToExecute, + workDir, + worktreePath, + branchName: branchName ?? null, + abortController, + autoLoadClaudeMd, + useClaudeCodeSystemPrompt, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await this.executePipeline(context); + pipelineCompleted = true; + + // Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict) + const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId); + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + + // Only update status if not already in a terminal state + if (reloadedFeature && reloadedFeature.status !== 'merge_conflict') { + await this.updateFeatureStatusFn(projectPath, featureId, finalStatus); + } + logger.info(`Pipeline resume completed for feature ${featureId}`); + if (runningEntry.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: true, + message: 'Pipeline resumed successfully', + projectPath, + }); + } + } catch (error) { + const errorInfo = classifyError(error); + if (errorInfo.isAbort) { + if (runningEntry.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + executionMode: 'auto', + passes: false, + message: 'Pipeline stopped by user', + projectPath, + }); + } + } else { + // If pipeline steps completed successfully, don't send the feature back to backlog. + // The pipeline work is done — set to waiting_approval so the user can review. + const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog'; + if (pipelineCompleted) { + logger.info( + `[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` + + `Setting status to waiting_approval instead of backlog to preserve pipeline work.` + ); + } + logger.error(`Pipeline resume failed for ${featureId}:`, error); + // Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution + const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId); + if (currentFeature?.status !== 'merge_conflict') { + await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus); + } + this.eventBus.emitAutoModeEvent('auto_mode_error', { + featureId, + featureName: feature.title, + branchName: feature.branchName ?? null, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } finally { + this.concurrencyManager.release(featureId); + } + } + + /** Execute test step with agent fix loop (REQ-F07) */ + async executeTestStep(context: PipelineContext, testCommand: string): Promise { + const { featureId, projectPath, workDir, abortController, maxTestAttempts } = context; + + for (let attempt = 1; attempt <= maxTestAttempts; attempt++) { + if (abortController.signal.aborted) + return { success: false, message: 'Test execution aborted' }; + logger.info(`Running tests for ${featureId} (attempt ${attempt}/${maxTestAttempts})`); + + const testResult = await this.testRunnerService.startTests(workDir, { command: testCommand }); + if (!testResult.success || !testResult.result?.sessionId) + return { + success: false, + testsPassed: false, + message: testResult.error || 'Failed to start tests', + }; + + const completionResult = await this.waitForTestCompletion( + testResult.result.sessionId, + abortController.signal + ); + if (completionResult.status === 'passed') return { success: true, testsPassed: true }; + + const sessionOutput = this.testRunnerService.getSessionOutput(testResult.result.sessionId); + const scrollback = sessionOutput.result?.output || ''; + this.eventBus.emitAutoModeEvent('pipeline_test_failed', { + featureId, + attempt, + maxAttempts: maxTestAttempts, + failedTests: this.extractFailedTestNames(scrollback), + projectPath, + }); + + if (attempt < maxTestAttempts) { + const fixPrompt = `## Test Failures - Please Fix\n\n${this.buildTestFailureSummary(scrollback)}\n\nFix the failing tests without modifying test code unless clearly wrong.`; + await this.runAgentFn( + workDir, + featureId, + fixPrompt, + abortController, + projectPath, + undefined, + undefined, + { + projectPath, + planningMode: 'skip', + requirePlanApproval: false, + useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt, + autoLoadClaudeMd: context.autoLoadClaudeMd, + thinkingLevel: context.feature.thinkingLevel, + reasoningEffort: context.feature.reasoningEffort, + status: context.feature.status, + providerId: context.feature.providerId, + } + ); + } + } + return { + success: false, + testsPassed: false, + message: `Tests failed after ${maxTestAttempts} attempts`, + }; + } + + /** Wait for test completion */ + private async waitForTestCompletion( + sessionId: string, + signal: AbortSignal + ): Promise<{ status: TestRunStatus; exitCode: number | null; duration: number }> { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + // Check for abort + if (signal.aborted) { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } + + const session = this.testRunnerService.getSession(sessionId); + if (session && session.status !== 'running' && session.status !== 'pending') { + clearInterval(checkInterval); + clearTimeout(timeoutId); + resolve({ + status: session.status, + exitCode: session.exitCode, + duration: session.finishedAt + ? session.finishedAt.getTime() - session.startedAt.getTime() + : 0, + }); + } + }, 1000); + const timeoutId = setTimeout(() => { + // Check for abort before timeout resolution + if (signal.aborted) { + clearInterval(checkInterval); + resolve({ status: 'failed', exitCode: null, duration: 0 }); + return; + } + clearInterval(checkInterval); + resolve({ status: 'failed', exitCode: null, duration: 600000 }); + }, 600000); + }); + } + + /** Attempt to merge feature branch (REQ-F05) */ + async attemptMerge(context: PipelineContext): Promise { + const { projectPath, featureId, branchName, worktreePath, feature } = context; + if (!branchName) return { success: false, error: 'No branch name for merge' }; + + logger.info(`Attempting auto-merge for feature ${featureId} (branch: ${branchName})`); + try { + // Get the primary branch dynamically instead of hardcoding 'main' + const targetBranch = await this.worktreeResolver.getCurrentBranch(projectPath); + + // Call merge service directly instead of HTTP fetch + const result = await performMerge( + projectPath, + branchName, + worktreePath || projectPath, + targetBranch || 'main', + { + deleteWorktreeAndBranch: false, + }, + this.eventBus.getUnderlyingEmitter() + ); + + if (!result.success) { + if (result.hasConflicts) { + await this.updateFeatureStatusFn(projectPath, featureId, 'merge_conflict'); + this.eventBus.emitAutoModeEvent('pipeline_merge_conflict', { + featureId, + branchName, + projectPath, + }); + return { success: false, hasConflicts: true, needsAgentResolution: true }; + } + return { success: false, error: result.error }; + } + + logger.info(`Auto-merge successful for feature ${featureId}`); + const runningEntryForMerge = this.concurrencyManager.getRunningFeature(featureId); + if (runningEntryForMerge?.isAutoMode) { + this.eventBus.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + featureName: feature.title, + branchName, + executionMode: 'auto', + passes: true, + message: 'Pipeline completed and merged', + projectPath, + }); + } + return { success: true }; + } catch (error) { + logger.error(`Merge failed for ${featureId}:`, error); + return { success: false, error: (error as Error).message }; + } + } + + /** Shared helper to parse test output lines and extract failure information */ + private parseTestLines(scrollback: string): { + failedTests: string[]; + passCount: number; + failCount: number; + } { + const lines = scrollback.split('\n'); + const failedTests: string[] = []; + let passCount = 0; + let failCount = 0; + + let inFailureContext = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes('FAIL') || trimmed.includes('FAILED')) { + const match = trimmed.match(/(?:FAIL|FAILED)\s+(.+)/); + if (match) failedTests.push(match[1].trim()); + failCount++; + inFailureContext = true; + } else if (trimmed.includes('PASS') || trimmed.includes('PASSED')) { + passCount++; + inFailureContext = false; + } + if (trimmed.match(/^>\s+.*\.(test|spec)\./)) { + failedTests.push(trimmed.replace(/^>\s+/, '')); + } + // Only capture assertion details when they appear in failure context + // or match explicit assertion error / expect patterns + if (trimmed.includes('AssertionError')) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + /expect\(.+\)\.(toBe|toEqual|toMatch|toThrow|toContain)\s*\(/.test(trimmed) + ) { + failedTests.push(trimmed); + } else if ( + inFailureContext && + (trimmed.startsWith('Expected') || trimmed.startsWith('Received')) + ) { + failedTests.push(trimmed); + } + } + + return { failedTests, passCount, failCount }; + } + + /** Build a concise test failure summary for the agent */ + buildTestFailureSummary(scrollback: string): string { + const { failedTests, passCount, failCount } = this.parseTestLines(scrollback); + const unique = [...new Set(failedTests)].slice(0, 10); + return `Test Results: ${passCount} passed, ${failCount} failed.\n\nFailed tests:\n${unique.map((t) => `- ${t}`).join('\n')}\n\nOutput (last 2000 chars):\n${scrollback.slice(-2000)}`; + } + + /** Extract failed test names from scrollback */ + private extractFailedTestNames(scrollback: string): string[] { + const { failedTests } = this.parseTestLines(scrollback); + return [...new Set(failedTests)].slice(0, 20); + } +} diff --git a/jules_branch/apps/server/src/services/pipeline-service.ts b/jules_branch/apps/server/src/services/pipeline-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb885d807dfddc95f4354f0952b3d1f18918b221 --- /dev/null +++ b/jules_branch/apps/server/src/services/pipeline-service.ts @@ -0,0 +1,344 @@ +/** + * Pipeline Service - Handles reading/writing pipeline configuration + * + * Provides persistent storage for: + * - Pipeline configuration ({projectPath}/.automaker/pipeline.json) + */ + +import path from 'path'; +import { createLogger } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import { ensureAutomakerDir } from '@automaker/platform'; +import type { PipelineConfig, PipelineStep, FeatureStatusWithPipeline } from '@automaker/types'; + +const logger = createLogger('PipelineService'); + +// Default empty pipeline config +const DEFAULT_PIPELINE_CONFIG: PipelineConfig = { + version: 1, + steps: [], +}; + +/** + * Atomic file write - write to temp file then rename + */ +async function atomicWriteJson(filePath: string, data: unknown): Promise { + const tempPath = `${filePath}.tmp.${Date.now()}`; + const content = JSON.stringify(data, null, 2); + + try { + await secureFs.writeFile(tempPath, content, 'utf-8'); + await secureFs.rename(tempPath, filePath); + } catch (error) { + // Clean up temp file if it exists + try { + await secureFs.unlink(tempPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +/** + * Safely read JSON file with fallback to default + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Generate a unique ID for pipeline steps + */ +function generateStepId(): string { + return `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`; +} + +/** + * Get the pipeline config file path for a project + */ +function getPipelineConfigPath(projectPath: string): string { + return path.join(projectPath, '.automaker', 'pipeline.json'); +} + +/** + * PipelineService - Manages pipeline configuration for workflow automation + * + * Handles reading and writing pipeline config to JSON files with atomic operations. + * Pipeline steps define custom columns that appear between "in_progress" and + * "waiting_approval/verified" columns in the kanban board. + */ +export class PipelineService { + /** + * Get pipeline configuration for a project + * + * @param projectPath - Absolute path to the project + * @returns Promise resolving to PipelineConfig (empty steps array if no config exists) + */ + async getPipelineConfig(projectPath: string): Promise { + const configPath = getPipelineConfigPath(projectPath); + const config = await readJsonFile(configPath, DEFAULT_PIPELINE_CONFIG); + + // Ensure version is set + return { + ...DEFAULT_PIPELINE_CONFIG, + ...config, + }; + } + + /** + * Save entire pipeline configuration + * + * @param projectPath - Absolute path to the project + * @param config - Complete PipelineConfig to save + */ + async savePipelineConfig(projectPath: string, config: PipelineConfig): Promise { + await ensureAutomakerDir(projectPath); + const configPath = getPipelineConfigPath(projectPath); + await atomicWriteJson(configPath, config); + logger.info(`Pipeline config saved for project: ${projectPath}`); + } + + /** + * Add a new pipeline step + * + * @param projectPath - Absolute path to the project + * @param step - Step data (without id, createdAt, updatedAt) + * @returns Promise resolving to the created PipelineStep + */ + async addStep( + projectPath: string, + step: Omit + ): Promise { + const config = await this.getPipelineConfig(projectPath); + const now = new Date().toISOString(); + + const newStep: PipelineStep = { + ...step, + id: generateStepId(), + createdAt: now, + updatedAt: now, + }; + + config.steps.push(newStep); + + // Normalize order values + config.steps.sort((a, b) => a.order - b.order); + config.steps.forEach((s, index) => { + s.order = index; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step added: ${newStep.name} (${newStep.id})`); + + return newStep; + } + + /** + * Update an existing pipeline step + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to update + * @param updates - Partial step data to merge + */ + async updateStep( + projectPath: string, + stepId: string, + updates: Partial> + ): Promise { + const config = await this.getPipelineConfig(projectPath); + const stepIndex = config.steps.findIndex((s) => s.id === stepId); + + if (stepIndex === -1) { + throw new Error(`Pipeline step not found: ${stepId}`); + } + + config.steps[stepIndex] = { + ...config.steps[stepIndex], + ...updates, + updatedAt: new Date().toISOString(), + }; + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step updated: ${stepId}`); + + return config.steps[stepIndex]; + } + + /** + * Delete a pipeline step + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to delete + */ + async deleteStep(projectPath: string, stepId: string): Promise { + const config = await this.getPipelineConfig(projectPath); + const stepIndex = config.steps.findIndex((s) => s.id === stepId); + + if (stepIndex === -1) { + throw new Error(`Pipeline step not found: ${stepId}`); + } + + config.steps.splice(stepIndex, 1); + + // Normalize order values after deletion + config.steps.forEach((s, index) => { + s.order = index; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline step deleted: ${stepId}`); + } + + /** + * Reorder pipeline steps + * + * @param projectPath - Absolute path to the project + * @param stepIds - Array of step IDs in the desired order + */ + async reorderSteps(projectPath: string, stepIds: string[]): Promise { + const config = await this.getPipelineConfig(projectPath); + + // Validate all step IDs exist + const existingIds = new Set(config.steps.map((s) => s.id)); + for (const id of stepIds) { + if (!existingIds.has(id)) { + throw new Error(`Pipeline step not found: ${id}`); + } + } + + // Create a map for quick lookup + const stepMap = new Map(config.steps.map((s) => [s.id, s])); + + // Reorder steps based on stepIds array + config.steps = stepIds.map((id, index) => { + const step = stepMap.get(id)!; + return { ...step, order: index, updatedAt: new Date().toISOString() }; + }); + + await this.savePipelineConfig(projectPath, config); + logger.info(`Pipeline steps reordered`); + } + + /** + * Get the next status in the pipeline flow + * + * Determines what status a feature should transition to based on current status. + * Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status + * Steps in the excludedStepIds array will be skipped. + * + * @param currentStatus - Current feature status + * @param config - Pipeline configuration (or null if no pipeline) + * @param skipTests - Whether to skip tests (affects final status) + * @param excludedStepIds - Optional array of step IDs to skip + * @returns The next status in the pipeline flow + */ + getNextStatus( + currentStatus: FeatureStatusWithPipeline, + config: PipelineConfig | null, + skipTests: boolean, + excludedStepIds?: string[] + ): FeatureStatusWithPipeline { + const steps = config?.steps || []; + const exclusions = new Set(excludedStepIds || []); + + // Sort steps by order and filter out excluded steps + const sortedSteps = [...steps] + .sort((a, b) => a.order - b.order) + .filter((step) => !exclusions.has(step.id)); + + // If no pipeline steps (or all excluded), use original logic + if (sortedSteps.length === 0) { + // If coming from in_progress or already in a pipeline step, go to final status + if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) { + return skipTests ? 'waiting_approval' : 'verified'; + } + return currentStatus; + } + + // Coming from in_progress -> go to first non-excluded pipeline step + if (currentStatus === 'in_progress') { + return `pipeline_${sortedSteps[0].id}`; + } + + // Coming from a pipeline step -> go to next non-excluded step or final status + if (currentStatus.startsWith('pipeline_')) { + const currentStepId = currentStatus.replace('pipeline_', ''); + const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId); + + if (currentIndex === -1) { + // Current step not found in filtered list (might be excluded or invalid) + // Find next valid step after this one from the original sorted list + const allSortedSteps = [...steps].sort((a, b) => a.order - b.order); + const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId); + + if (originalIndex === -1) { + // Step truly doesn't exist, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + // Find the next non-excluded step after the current one + for (let i = originalIndex + 1; i < allSortedSteps.length; i++) { + if (!exclusions.has(allSortedSteps[i].id)) { + return `pipeline_${allSortedSteps[i].id}`; + } + } + + // No more non-excluded steps, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + if (currentIndex < sortedSteps.length - 1) { + // Go to next non-excluded step + return `pipeline_${sortedSteps[currentIndex + 1].id}`; + } + + // Last non-excluded step completed, go to final status + return skipTests ? 'waiting_approval' : 'verified'; + } + + // For other statuses, don't change + return currentStatus; + } + + /** + * Get a specific pipeline step by ID + * + * @param projectPath - Absolute path to the project + * @param stepId - ID of the step to retrieve + * @returns The pipeline step or null if not found + */ + async getStep(projectPath: string, stepId: string): Promise { + const config = await this.getPipelineConfig(projectPath); + return config.steps.find((s) => s.id === stepId) || null; + } + + /** + * Check if a status is a pipeline status + */ + isPipelineStatus(status: FeatureStatusWithPipeline): boolean { + return status.startsWith('pipeline_'); + } + + /** + * Extract step ID from a pipeline status + */ + getStepIdFromStatus(status: FeatureStatusWithPipeline): string | null { + if (!this.isPipelineStatus(status)) { + return null; + } + return status.replace('pipeline_', ''); + } +} + +// Export singleton instance +export const pipelineService = new PipelineService(); diff --git a/jules_branch/apps/server/src/services/pipeline-types.ts b/jules_branch/apps/server/src/services/pipeline-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..67957b6a79ad56a30d327a3fd0e550bbbf9e5b60 --- /dev/null +++ b/jules_branch/apps/server/src/services/pipeline-types.ts @@ -0,0 +1,73 @@ +/** + * Pipeline Types - Type definitions for PipelineOrchestrator + */ + +import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types'; + +export interface PipelineContext { + projectPath: string; + featureId: string; + feature: Feature; + steps: PipelineStep[]; + workDir: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + autoLoadClaudeMd: boolean; + useClaudeCodeSystemPrompt?: boolean; + testAttempts: number; + maxTestAttempts: number; +} + +export interface PipelineStatusInfo { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; +} + +export interface StepResult { + success: boolean; + testsPassed?: boolean; + message?: string; +} + +export interface MergeResult { + success: boolean; + hasConflicts?: boolean; + needsAgentResolution?: boolean; + error?: string; +} + +export type UpdateFeatureStatusFn = ( + projectPath: string, + featureId: string, + status: string +) => Promise; + +export type BuildFeaturePromptFn = ( + feature: Feature, + prompts: { implementationInstructions: string; playwrightVerificationInstructions: string } +) => string; + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + useScreenshots: boolean, + model?: string, + options?: { _calledInternally?: boolean } +) => Promise; + +export type RunAgentFn = ( + workDir: string, + featureId: string, + prompt: string, + abortController: AbortController, + projectPath: string, + imagePaths?: string[], + model?: string, + options?: Record +) => Promise; diff --git a/jules_branch/apps/server/src/services/plan-approval-service.ts b/jules_branch/apps/server/src/services/plan-approval-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebd377679531ea6db0cf7f0e0ea54195356d7bbe --- /dev/null +++ b/jules_branch/apps/server/src/services/plan-approval-service.ts @@ -0,0 +1,332 @@ +/** + * PlanApprovalService - Manages plan approval workflow with timeout and recovery + * + * Key behaviors: + * - Timeout stored in closure, wrapped resolve/reject ensures cleanup + * - Recovery returns needsRecovery flag (caller handles execution) + * - Auto-reject on timeout (safety feature, not auto-approve) + */ + +import { createLogger } from '@automaker/utils'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { FeatureStateManager } from './feature-state-manager.js'; +import type { SettingsService } from './settings-service.js'; + +const logger = createLogger('PlanApprovalService'); + +/** Result returned when approval is resolved */ +export interface PlanApprovalResult { + approved: boolean; + editedPlan?: string; + feedback?: string; +} + +/** Result returned from resolveApproval method */ +export interface ResolveApprovalResult { + success: boolean; + error?: string; + needsRecovery?: boolean; +} + +/** Represents an orphaned approval that needs recovery after server restart */ +export interface OrphanedApproval { + featureId: string; + projectPath: string; + generatedAt?: string; + planContent?: string; +} + +/** Internal: timeoutId stored in closure, NOT in this object */ +interface PendingApproval { + resolve: (result: PlanApprovalResult) => void; + reject: (error: Error) => void; + featureId: string; + projectPath: string; +} + +/** Default timeout: 30 minutes */ +const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60 * 1000; + +/** + * PlanApprovalService handles the plan approval workflow with lifecycle management. + */ +export class PlanApprovalService { + private pendingApprovals = new Map(); + private eventBus: TypedEventBus; + private featureStateManager: FeatureStateManager; + private settingsService: SettingsService | null; + + constructor( + eventBus: TypedEventBus, + featureStateManager: FeatureStateManager, + settingsService: SettingsService | null + ) { + this.eventBus = eventBus; + this.featureStateManager = featureStateManager; + this.settingsService = settingsService; + } + + /** Generate project-scoped key to prevent collisions across projects */ + private approvalKey(projectPath: string, featureId: string): string { + return `${projectPath}::${featureId}`; + } + + /** Wait for plan approval with timeout (default 30 min). Rejects on timeout/cancellation. */ + async waitForApproval(featureId: string, projectPath: string): Promise { + const timeoutMs = await this.getTimeoutMs(projectPath); + const timeoutMinutes = Math.round(timeoutMs / 60000); + const key = this.approvalKey(projectPath, featureId); + + logger.info(`Registering pending approval for feature ${featureId} in project ${projectPath}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + return new Promise((resolve, reject) => { + // Prevent duplicate registrations for the same key — reject and clean up existing entry + const existing = this.pendingApprovals.get(key); + if (existing) { + existing.reject(new Error('Superseded by a new waitForApproval call')); + this.pendingApprovals.delete(key); + } + + // Wrap resolve/reject to clear timeout when approval is resolved + // This ensures timeout is ALWAYS cleared on any resolution path + // Define wrappers BEFORE setTimeout so they can be used in timeout callback + let timeoutId: NodeJS.Timeout; + const wrappedResolve = (result: PlanApprovalResult) => { + clearTimeout(timeoutId); + resolve(result); + }; + + const wrappedReject = (error: Error) => { + clearTimeout(timeoutId); + reject(error); + }; + + // Set up timeout to prevent indefinite waiting and memory leaks + // Now timeoutId assignment happens after wrappers are defined + timeoutId = setTimeout(() => { + const pending = this.pendingApprovals.get(key); + if (pending) { + logger.warn( + `Plan approval for feature ${featureId} timed out after ${timeoutMinutes} minutes` + ); + this.pendingApprovals.delete(key); + wrappedReject( + new Error( + `Plan approval timed out after ${timeoutMinutes} minutes - feature execution cancelled` + ) + ); + } + }, timeoutMs); + + this.pendingApprovals.set(key, { + resolve: wrappedResolve, + reject: wrappedReject, + featureId, + projectPath, + }); + + logger.info( + `Pending approval registered for feature ${featureId} (timeout: ${timeoutMinutes} minutes)` + ); + }); + } + + /** Resolve approval. Recovery path: returns needsRecovery=true if planSpec.status='generated'. */ + async resolveApproval( + featureId: string, + approved: boolean, + options?: { editedPlan?: string; feedback?: string; projectPath?: string } + ): Promise { + const { editedPlan, feedback, projectPath: projectPathFromClient } = options ?? {}; + + logger.info(`resolveApproval called for feature ${featureId}, approved=${approved}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + // Try to find pending approval using project-scoped key if projectPath is available + let foundKey: string | undefined; + let pending: PendingApproval | undefined; + + if (projectPathFromClient) { + foundKey = this.approvalKey(projectPathFromClient, featureId); + pending = this.pendingApprovals.get(foundKey); + } else { + // Fallback: search by featureId (backward compatibility) + for (const [key, approval] of this.pendingApprovals) { + if (approval.featureId === featureId) { + foundKey = key; + pending = approval; + break; + } + } + } + + if (!pending) { + logger.info(`No pending approval in Map for feature ${featureId}`); + + // RECOVERY: If no pending approval but we have projectPath from client, + // check if feature's planSpec.status is 'generated' and handle recovery + if (projectPathFromClient) { + logger.info(`Attempting recovery with projectPath: ${projectPathFromClient}`); + const feature = await this.featureStateManager.loadFeature( + projectPathFromClient, + featureId + ); + + if (feature?.planSpec?.status === 'generated') { + logger.info(`Feature ${featureId} has planSpec.status='generated', performing recovery`); + + if (approved) { + // Update planSpec to approved + await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'approved', + approvedAt: new Date().toISOString(), + reviewedByUser: true, + content: editedPlan || feature.planSpec.content, + }); + + logger.info(`Recovery approval complete for feature ${featureId}`); + + // Return needsRecovery flag - caller (AutoModeService) handles execution + return { success: true, needsRecovery: true }; + } else { + // Rejection recovery + await this.featureStateManager.updateFeaturePlanSpec(projectPathFromClient, featureId, { + status: 'rejected', + reviewedByUser: true, + }); + + await this.featureStateManager.updateFeatureStatus( + projectPathFromClient, + featureId, + 'backlog' + ); + + this.eventBus.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath: projectPathFromClient, + feedback, + }); + + return { success: true }; + } + } + } + + logger.info( + `ERROR: No pending approval found for feature ${featureId} and recovery not possible` + ); + return { + success: false, + error: `No pending approval for feature ${featureId}`, + }; + } + + logger.info(`Found pending approval for feature ${featureId}, proceeding...`); + + const { projectPath } = pending; + + // Update feature's planSpec status + await this.featureStateManager.updateFeaturePlanSpec(projectPath, featureId, { + status: approved ? 'approved' : 'rejected', + approvedAt: approved ? new Date().toISOString() : undefined, + reviewedByUser: true, + ...(editedPlan !== undefined && { content: editedPlan }), // Only update content if user provided an edited version + }); + + // If rejected, emit event so client knows the rejection reason (even without feedback) + if (!approved) { + this.eventBus.emitAutoModeEvent('plan_rejected', { + featureId, + projectPath, + feedback, + }); + } + + // Resolve the promise with all data including feedback + // This triggers the wrapped resolve which clears the timeout + pending.resolve({ approved, editedPlan, feedback }); + if (foundKey) { + this.pendingApprovals.delete(foundKey); + } + + return { success: true }; + } + + /** Cancel approval (e.g., when feature stopped). Timeout cleared via wrapped reject. */ + cancelApproval(featureId: string, projectPath?: string): void { + logger.info(`cancelApproval called for feature ${featureId}`); + logger.info( + `Current pending approvals: ${Array.from(this.pendingApprovals.keys()).join(', ') || 'none'}` + ); + + // If projectPath provided, use project-scoped key; otherwise search by featureId + let foundKey: string | undefined; + let pending: PendingApproval | undefined; + + if (projectPath) { + foundKey = this.approvalKey(projectPath, featureId); + pending = this.pendingApprovals.get(foundKey); + } else { + // Fallback: search for any approval with this featureId (backward compatibility) + for (const [key, approval] of this.pendingApprovals) { + if (approval.featureId === featureId) { + foundKey = key; + pending = approval; + break; + } + } + } + + if (pending && foundKey) { + logger.info(`Found and cancelling pending approval for feature ${featureId}`); + // Wrapped reject clears timeout automatically + pending.reject(new Error('Plan approval cancelled - feature was stopped')); + this.pendingApprovals.delete(foundKey); + } else { + logger.info(`No pending approval to cancel for feature ${featureId}`); + } + } + + /** Check if a feature has a pending plan approval. */ + hasPendingApproval(featureId: string, projectPath?: string): boolean { + if (projectPath) { + return this.pendingApprovals.has(this.approvalKey(projectPath, featureId)); + } + // Fallback: search by featureId (backward compatibility) + for (const approval of this.pendingApprovals.values()) { + if (approval.featureId === featureId) { + return true; + } + } + return false; + } + + /** Get timeout from project settings or default (30 min). */ + private async getTimeoutMs(projectPath: string): Promise { + if (!this.settingsService) { + return DEFAULT_APPROVAL_TIMEOUT_MS; + } + + try { + const projectSettings = await this.settingsService.getProjectSettings(projectPath); + // Check for planApprovalTimeoutMs in project settings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const timeoutMs = (projectSettings as any).planApprovalTimeoutMs; + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + return timeoutMs; + } + } catch (error) { + logger.warn( + `Failed to get project settings for ${projectPath}, using default timeout`, + error + ); + } + + return DEFAULT_APPROVAL_TIMEOUT_MS; + } +} diff --git a/jules_branch/apps/server/src/services/pr-review-comments.service.ts b/jules_branch/apps/server/src/services/pr-review-comments.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4bc1388a14bb41e66567dbaf515eb02f0d8a571 --- /dev/null +++ b/jules_branch/apps/server/src/services/pr-review-comments.service.ts @@ -0,0 +1,431 @@ +/** + * PR Review Comments Service + * + * Domain logic for fetching PR review comments, enriching them with + * resolved-thread status, and sorting. Extracted from the route handler + * so the route only deals with request/response plumbing. + */ + +import { spawn, execFile } from 'child_process'; +import { promisify } from 'util'; +import { createLogger } from '@automaker/utils'; +import { execEnv, logError } from '../lib/exec-utils.js'; + +const execFileAsync = promisify(execFile); + +// ── Public types (re-exported for callers) ── + +export interface PRReviewComment { + id: string; + author: string; + avatarUrl?: string; + body: string; + path?: string; + line?: number; + createdAt: string; + updatedAt?: string; + isReviewComment: boolean; + /** Whether this is an outdated review comment (code has changed since) */ + isOutdated?: boolean; + /** Whether the review thread containing this comment has been resolved */ + isResolved?: boolean; + /** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */ + threadId?: string; + /** The diff hunk context for the comment */ + diffHunk?: string; + /** The side of the diff (LEFT or RIGHT) */ + side?: string; + /** The commit ID the comment was made on */ + commitId?: string; + /** Whether the comment author is a bot/app account */ + isBot?: boolean; +} + +export interface ListPRReviewCommentsResult { + success: boolean; + comments?: PRReviewComment[]; + totalCount?: number; + error?: string; +} + +// ── Internal types ── + +/** Timeout for GitHub GraphQL API requests in milliseconds */ +const GITHUB_API_TIMEOUT_MS = 30000; + +/** Maximum number of pagination pages to prevent infinite loops */ +const MAX_PAGINATION_PAGES = 20; + +interface GraphQLReviewThreadComment { + databaseId: number; +} + +interface GraphQLReviewThread { + id: string; + isResolved: boolean; + comments: { + pageInfo?: { + hasNextPage: boolean; + endCursor?: string | null; + }; + nodes: GraphQLReviewThreadComment[]; + }; +} + +interface GraphQLResponse { + data?: { + repository?: { + pullRequest?: { + reviewThreads?: { + nodes: GraphQLReviewThread[]; + pageInfo?: { + hasNextPage: boolean; + endCursor?: string | null; + }; + }; + } | null; + }; + }; + errors?: Array<{ message: string }>; +} + +interface ReviewThreadInfo { + isResolved: boolean; + threadId: string; +} + +// ── Logger ── + +const logger = createLogger('PRReviewCommentsService'); + +// ── Service functions ── + +/** + * Execute a GraphQL query via the `gh` CLI and return the parsed response. + */ +async function executeGraphQL(projectPath: string, requestBody: string): Promise { + let timeoutId: NodeJS.Timeout | undefined; + + const response = await new Promise((resolve, reject) => { + const gh = spawn('gh', ['api', 'graphql', '--input', '-'], { + cwd: projectPath, + env: execEnv, + }); + + gh.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + + timeoutId = setTimeout(() => { + gh.kill(); + reject(new Error('GitHub GraphQL API request timed out')); + }, GITHUB_API_TIMEOUT_MS); + + let stdout = ''; + let stderr = ''; + gh.stdout.on('data', (data: Buffer) => (stdout += data.toString())); + gh.stderr.on('data', (data: Buffer) => (stderr += data.toString())); + + gh.on('close', (code) => { + clearTimeout(timeoutId); + if (code !== 0) { + return reject(new Error(`gh process exited with code ${code}: ${stderr}`)); + } + try { + resolve(JSON.parse(stdout)); + } catch (e) { + reject(e); + } + }); + + gh.stdin.on('error', () => { + // Ignore stdin errors (e.g. when the child process is killed) + }); + gh.stdin.write(requestBody); + gh.stdin.end(); + }); + + if (response.errors && response.errors.length > 0) { + throw new Error(response.errors[0].message); + } + + return response; +} + +/** + * Fetch review thread resolved status and thread IDs using GitHub GraphQL API. + * Uses cursor-based pagination to handle PRs with more than 100 review threads. + * Returns a map of comment ID (string) -> { isResolved, threadId }. + */ +export async function fetchReviewThreadResolvedStatus( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise> { + const resolvedMap = new Map(); + + const query = ` + query GetPRReviewThreads( + $owner: String! + $repo: String! + $prNumber: Int! + $cursor: String + ) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + comments(first: 100) { + pageInfo { + hasNextPage + endCursor + } + nodes { + databaseId + } + } + } + } + } + } + }`; + + try { + let cursor: string | null = null; + let pageCount = 0; + + do { + const variables = { owner, repo, prNumber, cursor }; + const requestBody = JSON.stringify({ query, variables }); + const response = await executeGraphQL(projectPath, requestBody); + + const reviewThreads = response.data?.repository?.pullRequest?.reviewThreads; + const threads = reviewThreads?.nodes ?? []; + + for (const thread of threads) { + if (thread.comments.pageInfo?.hasNextPage) { + logger.debug( + `Review thread ${thread.id} in PR #${prNumber} has >100 comments — ` + + 'some comments may be missing resolved status' + ); + } + const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id }; + for (const comment of thread.comments.nodes) { + resolvedMap.set(String(comment.databaseId), info); + } + } + + const pageInfo = reviewThreads?.pageInfo; + if (pageInfo?.hasNextPage && pageInfo.endCursor) { + cursor = pageInfo.endCursor; + pageCount++; + logger.debug( + `Fetching next page of review threads for PR #${prNumber} (page ${pageCount + 1})` + ); + } else { + cursor = null; + } + } while (cursor && pageCount < MAX_PAGINATION_PAGES); + + if (pageCount >= MAX_PAGINATION_PAGES) { + logger.warn( + `PR #${prNumber} in ${owner}/${repo} has more than ${MAX_PAGINATION_PAGES * 100} review threads — ` + + 'pagination limit reached. Some comments may be missing resolved status.' + ); + } + } catch (error) { + // Log but don't fail — resolved status is best-effort + logError(error, 'Failed to fetch PR review thread resolved status'); + } + + return resolvedMap; +} + +/** + * Fetch all comments for a PR (regular, inline review, and review body comments) + */ +export async function fetchPRReviewComments( + projectPath: string, + owner: string, + repo: string, + prNumber: number +): Promise { + const allComments: PRReviewComment[] = []; + + // Fetch review thread resolved status in parallel with comment fetching + const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber); + + // 1. Fetch regular PR comments (issue-level comments) + // Uses the REST API issues endpoint instead of `gh pr view --json comments` + // because the latter uses GraphQL internally where bot/app authors can return + // null, causing bot comments to be silently dropped or display as "unknown". + try { + const issueCommentsEndpoint = `repos/${owner}/${repo}/issues/${prNumber}/comments`; + const { stdout: commentsOutput } = await execFileAsync( + 'gh', + ['api', issueCommentsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const commentsData = JSON.parse(commentsOutput); + const regularComments = (Array.isArray(commentsData) ? commentsData : []).map( + (c: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + created_at: string; + updated_at?: string; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: String(c.id), + author: c.user?.login || c.performed_via_github_app?.slug || 'unknown', + avatarUrl: c.user?.avatar_url, + body: c.body, + createdAt: c.created_at, + updatedAt: c.updated_at, + isReviewComment: false, + isOutdated: false, + isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app, + // Regular PR comments are not part of review threads, so not resolvable + isResolved: false, + }) + ); + + allComments.push(...regularComments); + } catch (error) { + logError(error, 'Failed to fetch regular PR comments'); + } + + // 2. Fetch inline review comments (code-level comments with file/line info) + try { + const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`; + const { stdout: reviewsOutput } = await execFileAsync( + 'gh', + ['api', reviewsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const reviewsData = JSON.parse(reviewsOutput); + const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map( + (c: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + path: string; + line?: number; + original_line?: number; + created_at: string; + updated_at?: string; + diff_hunk?: string; + side?: string; + commit_id?: string; + position?: number | null; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: String(c.id), + author: c.user?.login || c.performed_via_github_app?.slug || 'unknown', + avatarUrl: c.user?.avatar_url, + body: c.body, + path: c.path, + line: c.line ?? c.original_line, + createdAt: c.created_at, + updatedAt: c.updated_at, + isReviewComment: true, + // A review comment is "outdated" if position is null (code has changed) + isOutdated: c.position === null, + // isResolved will be filled in below from GraphQL data + isResolved: false, + isBot: c.user?.type === 'Bot' || !!c.performed_via_github_app, + diffHunk: c.diff_hunk, + side: c.side, + commitId: c.commit_id, + }) + ); + + allComments.push(...reviewComments); + } catch (error) { + logError(error, 'Failed to fetch inline review comments'); + } + + // 3. Fetch review body comments (summary text submitted with each review) + // These are the top-level comments written when submitting a review + // (Approve, Request Changes, Comment). They are separate from inline code comments + // and issue-level comments. Only include reviews that have a non-empty body. + try { + const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/reviews`; + const { stdout: reviewBodiesOutput } = await execFileAsync( + 'gh', + ['api', reviewsEndpoint, '--paginate'], + { + cwd: projectPath, + env: execEnv, + maxBuffer: 1024 * 1024 * 10, // 10MB buffer for large PRs + timeout: GITHUB_API_TIMEOUT_MS, + } + ); + + const reviewBodiesData = JSON.parse(reviewBodiesOutput); + const reviewBodyComments = (Array.isArray(reviewBodiesData) ? reviewBodiesData : []) + .filter( + (r: { body?: string; state?: string }) => + r.body && r.body.trim().length > 0 && r.state !== 'PENDING' + ) + .map( + (r: { + id: number; + user: { login: string; avatar_url?: string; type?: string } | null; + body: string; + state: string; + submitted_at: string; + performed_via_github_app?: { slug: string } | null; + }) => ({ + id: `review-${r.id}`, + author: r.user?.login || r.performed_via_github_app?.slug || 'unknown', + avatarUrl: r.user?.avatar_url, + body: r.body, + createdAt: r.submitted_at, + isReviewComment: false, + isOutdated: false, + isResolved: false, + isBot: r.user?.type === 'Bot' || !!r.performed_via_github_app, + }) + ); + + allComments.push(...reviewBodyComments); + } catch (error) { + logError(error, 'Failed to fetch review body comments'); + } + + // Wait for resolved status and apply to inline review comments + const resolvedMap = await resolvedStatusPromise; + for (const comment of allComments) { + if (comment.isReviewComment && resolvedMap.has(comment.id)) { + const info = resolvedMap.get(comment.id)!; + comment.isResolved = info.isResolved; + comment.threadId = info.threadId; + } + } + + // Sort by createdAt descending (newest first) + allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + return allComments; +} diff --git a/jules_branch/apps/server/src/services/pr-service.ts b/jules_branch/apps/server/src/services/pr-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b26f35b8690f47cdf0f01723e9765057a064573 --- /dev/null +++ b/jules_branch/apps/server/src/services/pr-service.ts @@ -0,0 +1,225 @@ +/** + * Service for resolving PR target information from git remotes. + * + * Extracts remote-parsing and target-resolution logic that was previously + * inline in the create-pr route handler. + */ + +// TODO: Move execAsync/execEnv to a shared lib (lib/exec.ts or @automaker/utils) so that +// services no longer depend on route internals. Tracking issue: route-to-service dependency +// inversion. For now, a local thin wrapper is used within the service boundary. +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { createLogger, isValidRemoteName } from '@automaker/utils'; + +// Thin local wrapper — duplicates the route-level execAsync/execEnv until a +// shared lib/exec.ts (or @automaker/utils export) is created. +const execAsync = promisify(exec); + +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const _additionalPaths: string[] = []; +if (process.platform === 'win32') { + if (process.env.LOCALAPPDATA) + _additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + if (process.env.PROGRAMFILES) _additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + if (process.env['ProgramFiles(x86)']) + _additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); +} else { + _additionalPaths.push( + '/opt/homebrew/bin', + '/usr/local/bin', + '/home/linuxbrew/.linuxbrew/bin', + `${process.env.HOME}/.local/bin` + ); +} +const execEnv = { + ...process.env, + PATH: [process.env.PATH, ..._additionalPaths.filter(Boolean)].filter(Boolean).join(pathSeparator), +}; + +const logger = createLogger('PRService'); + +export interface ParsedRemote { + owner: string; + repo: string; +} + +export interface PrTargetResult { + repoUrl: string | null; + targetRepo: string | null; + pushOwner: string | null; + upstreamRepo: string | null; + originOwner: string | null; + parsedRemotes: Map; +} + +/** + * Parse all git remotes for the given repo path and resolve the PR target. + * + * @param worktreePath - Working directory of the repository / worktree + * @param pushRemote - Remote used for pushing (e.g. "origin") + * @param targetRemote - Explicit remote to target the PR against (optional) + * + * @throws {Error} When targetRemote is specified but not found among repository remotes + * @throws {Error} When pushRemote is not found among parsed remotes (when targetRemote is specified) + */ +export async function resolvePrTarget({ + worktreePath, + pushRemote, + targetRemote, +}: { + worktreePath: string; + pushRemote: string; + targetRemote?: string; +}): Promise { + // Validate remote names — pushRemote is a required string so the undefined + // guard is unnecessary, but targetRemote is optional. + if (!isValidRemoteName(pushRemote)) { + throw new Error(`Invalid push remote name: "${pushRemote}"`); + } + if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) { + throw new Error(`Invalid target remote name: "${targetRemote}"`); + } + + let repoUrl: string | null = null; + let upstreamRepo: string | null = null; + let originOwner: string | null = null; + const parsedRemotes: Map = new Map(); + + try { + const { stdout: remotes } = await execAsync('git remote -v', { + cwd: worktreePath, + env: execEnv, + }); + + // Parse remotes to detect fork workflow and get repo URL + const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings + for (const line of lines) { + // Try multiple patterns to match different remote URL formats + // Pattern 1: git@github.com:owner/repo.git (fetch) + // Pattern 2: https://github.com/owner/repo.git (fetch) + // Pattern 3: https://github.com/owner/repo (fetch) + let match = line.match( + /^([a-zA-Z0-9._-]+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + if (!match) { + // Try SSH format: git@github.com:owner/repo.git + match = line.match( + /^([a-zA-Z0-9._-]+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + } + if (!match) { + // Try HTTPS format: https://github.com/owner/repo.git + match = line.match( + /^([a-zA-Z0-9._-]+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/ + ); + } + + if (match) { + const [, remoteName, owner, repo] = match; + parsedRemotes.set(remoteName, { owner, repo }); + if (remoteName === 'upstream') { + upstreamRepo = `${owner}/${repo}`; + repoUrl = `https://github.com/${owner}/${repo}`; + } else if (remoteName === 'origin') { + originOwner = owner; + if (!repoUrl) { + repoUrl = `https://github.com/${owner}/${repo}`; + } + } + } + } + } catch (err) { + // Log the failure for debugging — control flow falls through to auto-detection + logger.debug('Failed to parse git remotes', { worktreePath, error: err }); + } + + // When targetRemote is explicitly provided but remote parsing failed entirely + // (parsedRemotes is empty), we cannot validate or resolve the requested remote. + // Silently proceeding to auto-detection would ignore the caller's explicit intent, + // so we fail fast with a clear error instead. + if (targetRemote && parsedRemotes.size === 0) { + throw new Error( + `targetRemote "${targetRemote}" was specified but no remotes could be parsed from the repository. ` + + `Ensure the repository has at least one configured remote (parsedRemotes is empty).` + ); + } + + // When a targetRemote is explicitly specified, validate that it is known + // before using it. Silently falling back to auto-detection when the caller + // explicitly requested a remote that doesn't exist is misleading, so we + // fail fast here instead. + if (targetRemote && parsedRemotes.size > 0 && !parsedRemotes.has(targetRemote)) { + throw new Error(`targetRemote "${targetRemote}" not found in repository remotes`); + } + + // When a targetRemote is explicitly specified, override fork detection + // to use the specified remote as the PR target + let targetRepo: string | null = null; + let pushOwner: string | null = null; + if (targetRemote && parsedRemotes.size > 0) { + const targetInfo = parsedRemotes.get(targetRemote); + const pushInfo = parsedRemotes.get(pushRemote); + + // If the push remote is not found in the parsed remotes, we cannot + // determine the push owner and would build incorrect URLs. Fail fast + // instead of silently proceeding with null values. + if (!pushInfo) { + logger.warn('Push remote not found in parsed remotes', { + pushRemote, + targetRemote, + availableRemotes: [...parsedRemotes.keys()], + }); + throw new Error(`Push remote "${pushRemote}" not found in repository remotes`); + } + + if (targetInfo) { + targetRepo = `${targetInfo.owner}/${targetInfo.repo}`; + repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`; + } + pushOwner = pushInfo.owner; + + // Override the auto-detected upstream/origin with explicit targetRemote + // Only treat as cross-remote if target differs from push remote + if (targetRemote !== pushRemote && targetInfo) { + upstreamRepo = targetRepo; + originOwner = pushOwner; + } else if (targetInfo) { + // Same remote for push and target - regular (non-fork) workflow + upstreamRepo = null; + originOwner = targetInfo.owner; + repoUrl = `https://github.com/${targetInfo.owner}/${targetInfo.repo}`; + } + } + + // Fallback: Try to get repo URL from git config if remote parsing failed + if (!repoUrl) { + try { + const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', { + cwd: worktreePath, + env: execEnv, + }); + const url = originUrl.trim(); + + // Parse URL to extract owner/repo + // Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) + const match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/); + if (match) { + const [, owner, repo] = match; + originOwner = owner; + repoUrl = `https://github.com/${owner}/${repo}`; + } + } catch { + // Failed to get repo URL from config + } + } + + return { + repoUrl, + targetRepo, + pushOwner, + upstreamRepo, + originOwner, + parsedRemotes, + }; +} diff --git a/jules_branch/apps/server/src/services/pull-service.ts b/jules_branch/apps/server/src/services/pull-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6f8c36a88e3ded8403a2569eba1f41e24a6aec6 --- /dev/null +++ b/jules_branch/apps/server/src/services/pull-service.ts @@ -0,0 +1,546 @@ +/** + * PullService - Pull git operations without HTTP + * + * Encapsulates the full git pull workflow including: + * - Branch name and detached HEAD detection + * - Fetching from remote + * - Status parsing and local change detection + * - Stash push/pop logic + * - Upstream verification (rev-parse / --verify) + * - Pull execution and conflict detection + * - Conflict file list collection + * + * Extracted from the worktree pull route to improve organization + * and testability. Follows the same pattern as rebase-service.ts + * and cherry-pick-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand, getConflictFiles } from '@automaker/git-utils'; +import { execGitCommandWithLockRetry, getCurrentBranch } from '../lib/git.js'; + +const logger = createLogger('PullService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface PullOptions { + /** Remote name to pull from (defaults to 'origin') */ + remote?: string; + /** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */ + remoteBranch?: string; + /** When true, automatically stash local changes before pulling and reapply after */ + stashIfNeeded?: boolean; +} + +export interface PullResult { + success: boolean; + error?: string; + branch?: string; + pulled?: boolean; + hasLocalChanges?: boolean; + localChangedFiles?: string[]; + stashed?: boolean; + stashRestored?: boolean; + stashRecoveryFailed?: boolean; + hasConflicts?: boolean; + conflictSource?: 'pull' | 'stash'; + conflictFiles?: string[]; + message?: string; + /** Whether the pull resulted in a merge commit (not fast-forward) */ + isMerge?: boolean; + /** Whether the pull was a fast-forward (no merge commit needed) */ + isFastForward?: boolean; + /** Files affected by the merge (only present when isMerge is true) */ + mergeAffectedFiles?: string[]; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Fetch the latest refs from a remote. + * + * @param worktreePath - Path to the git worktree + * @param remote - Remote name (e.g. 'origin') + */ +export async function fetchRemote(worktreePath: string, remote: string): Promise { + await execGitCommand(['fetch', remote], worktreePath); +} + +/** + * Parse `git status --porcelain` output into a list of changed file paths. + * + * @param worktreePath - Path to the git worktree + * @returns Object with hasLocalChanges flag and list of changed file paths + */ +export async function getLocalChanges( + worktreePath: string +): Promise<{ hasLocalChanges: boolean; localChangedFiles: string[] }> { + const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath); + const hasLocalChanges = statusOutput.trim().length > 0; + + let localChangedFiles: string[] = []; + if (hasLocalChanges) { + localChangedFiles = statusOutput + .trim() + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => { + const entry = line.substring(3).trim(); + const arrowIndex = entry.indexOf(' -> '); + return arrowIndex !== -1 ? entry.substring(arrowIndex + 4).trim() : entry; + }); + } + + return { hasLocalChanges, localChangedFiles }; +} + +/** + * Stash local changes with a descriptive message. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name (used in stash message) + * @returns Promise — resolves on success, throws on failure + */ +export async function stashChanges(worktreePath: string, branchName: string): Promise { + const stashMessage = `automaker-pull-stash: Pre-pull stash on ${branchName}`; + await execGitCommandWithLockRetry( + ['stash', 'push', '--include-untracked', '-m', stashMessage], + worktreePath + ); +} + +/** + * Pop the top stash entry. + * + * @param worktreePath - Path to the git worktree + * @returns The stdout from stash pop + */ +export async function popStash(worktreePath: string): Promise { + return await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); +} + +/** + * Try to pop the stash, returning whether the pop succeeded. + * + * @param worktreePath - Path to the git worktree + * @returns true if stash pop succeeded, false if it failed + */ +async function tryPopStash(worktreePath: string): Promise { + try { + await execGitCommandWithLockRetry(['stash', 'pop'], worktreePath); + return true; + } catch (stashPopError) { + // Stash pop failed - leave it in stash list for manual recovery + logger.error('Failed to reapply stash during error recovery', { + worktreePath, + error: getErrorMessage(stashPopError), + }); + return false; + } +} + +/** + * Result of the upstream/remote branch check. + * - 'tracking': the branch has a configured upstream tracking ref + * - 'remote': no tracking ref, but the remote branch exists + * - 'none': neither a tracking ref nor a remote branch was found + */ +export type UpstreamStatus = 'tracking' | 'remote' | 'none'; + +/** + * Check whether the branch has an upstream tracking ref, or whether + * the remote branch exists. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name + * @param remote - Remote name + * @returns UpstreamStatus indicating tracking ref, remote branch, or neither + */ +export async function hasUpstreamOrRemoteBranch( + worktreePath: string, + branchName: string, + remote: string +): Promise { + try { + await execGitCommand(['rev-parse', '--abbrev-ref', `${branchName}@{upstream}`], worktreePath); + return 'tracking'; + } catch { + // No upstream tracking - check if the remote branch exists + try { + await execGitCommand(['rev-parse', '--verify', `${remote}/${branchName}`], worktreePath); + return 'remote'; + } catch { + return 'none'; + } + } +} + +/** + * Check whether an error output string indicates a merge conflict. + */ +function isConflictError(errorOutput: string): boolean { + return errorOutput.includes('CONFLICT') || errorOutput.includes('Automatic merge failed'); +} + +/** + * Determine whether the current HEAD commit is a merge commit by checking + * whether it has two or more parent hashes. + * + * Runs `git show -s --pretty=%P HEAD` which prints the parent SHAs separated + * by spaces. A merge commit has at least two parents; a regular commit has one. + * + * @param worktreePath - Path to the git worktree + * @returns true if HEAD is a merge commit, false otherwise + */ +async function isMergeCommit(worktreePath: string): Promise { + try { + const output = await execGitCommand(['show', '-s', '--pretty=%P', 'HEAD'], worktreePath); + // Each parent SHA is separated by a space; two or more means it's a merge + const parents = output + .trim() + .split(/\s+/) + .filter((p) => p.length > 0); + return parents.length >= 2; + } catch { + // If the check fails for any reason, assume it is not a merge commit + return false; + } +} + +/** + * Check whether an output string indicates a stash conflict. + */ +function isStashConflict(output: string): boolean { + return output.includes('CONFLICT') || output.includes('Merge conflict'); +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a full git pull workflow on the given worktree. + * + * The workflow: + * 1. Get current branch name (detect detached HEAD) + * 2. Fetch from remote + * 3. Check for local changes + * 4. If local changes and stashIfNeeded, stash them + * 5. Verify upstream tracking or remote branch exists + * 6. Execute `git pull` + * 7. If stash was created and pull succeeded, reapply stash + * 8. Detect and report conflicts from pull or stash reapplication + * + * @param worktreePath - Path to the git worktree + * @param options - Pull options (remote, stashIfNeeded) + * @returns PullResult with detailed status information + */ +export async function performPull( + worktreePath: string, + options?: PullOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + const stashIfNeeded = options?.stashIfNeeded ?? false; + const targetRemoteBranch = options?.remoteBranch; + + // 1. Get current branch name + let branchName: string; + try { + branchName = await getCurrentBranch(worktreePath); + } catch (err) { + return { + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }; + } + + // 2. Check for detached HEAD state + if (branchName === 'HEAD') { + return { + success: false, + error: 'Cannot pull in detached HEAD state. Please checkout a branch first.', + }; + } + + // 3. Fetch latest from remote + try { + await fetchRemote(worktreePath, targetRemote); + } catch (fetchError) { + return { + success: false, + error: `Failed to fetch from remote '${targetRemote}': ${getErrorMessage(fetchError)}`, + }; + } + + // 4. Check for local changes + let hasLocalChanges: boolean; + let localChangedFiles: string[]; + try { + ({ hasLocalChanges, localChangedFiles } = await getLocalChanges(worktreePath)); + } catch (err) { + return { + success: false, + error: `Failed to get local changes: ${getErrorMessage(err)}`, + }; + } + + // 5. If there are local changes and stashIfNeeded is not requested, return info + if (hasLocalChanges && !stashIfNeeded) { + return { + success: true, + branch: branchName, + pulled: false, + hasLocalChanges: true, + localChangedFiles, + message: + 'Local changes detected. Use stashIfNeeded to automatically stash and reapply changes.', + }; + } + + // 6. Stash local changes if needed + let didStash = false; + if (hasLocalChanges && stashIfNeeded) { + try { + await stashChanges(worktreePath, branchName); + didStash = true; + } catch (stashError) { + return { + success: false, + error: `Failed to stash local changes: ${getErrorMessage(stashError)}`, + }; + } + } + + // 7. Verify upstream tracking or remote branch exists + // Skip this check when a specific remote branch is provided - we always use + // explicit 'git pull ' args in that case. + let upstreamStatus: UpstreamStatus = 'tracking'; + if (!targetRemoteBranch) { + upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); + if (upstreamStatus === 'none') { + let stashRecoveryFailed = false; + if (didStash) { + const stashPopped = await tryPopStash(worktreePath); + stashRecoveryFailed = !stashPopped; + } + return { + success: false, + error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + } + + // 8. Pull latest changes + // When a specific remote branch is requested, always use explicit remote + branch args. + // When the branch has a configured upstream tracking ref, let Git use it automatically. + // When only the remote branch exists (no tracking ref), explicitly specify remote and branch. + const pullArgs = targetRemoteBranch + ? ['pull', targetRemote, targetRemoteBranch] + : upstreamStatus === 'tracking' + ? ['pull'] + : ['pull', targetRemote, branchName]; + let pullConflict = false; + let pullConflictFiles: string[] = []; + + // Declare merge detection variables before the try block so they are accessible + // in the stash reapplication path even when didStash is true. + let isMerge = false; + let isFastForward = false; + let mergeAffectedFiles: string[] = []; + + try { + const pullOutput = await execGitCommand(pullArgs, worktreePath); + + const alreadyUpToDate = pullOutput.includes('Already up to date'); + // Detect fast-forward from git pull output + isFastForward = pullOutput.includes('Fast-forward') || pullOutput.includes('fast-forward'); + // Detect merge by checking whether the new HEAD has two parents (more reliable + // than string-matching localised pull output which may not contain 'Merge'). + isMerge = !alreadyUpToDate && !isFastForward ? await isMergeCommit(worktreePath) : false; + + // If it was a real merge (not fast-forward), get the affected files + if (isMerge) { + try { + // Get files changed in the merge commit + const diffOutput = await execGitCommand( + ['diff', '--name-only', 'HEAD~1', 'HEAD'], + worktreePath + ); + mergeAffectedFiles = diffOutput + .trim() + .split('\n') + .filter((f: string) => f.trim().length > 0); + } catch { + // Ignore errors - this is best-effort + } + } + + // If no stash to reapply, return success + if (!didStash) { + return { + success: true, + branch: branchName, + pulled: !alreadyUpToDate, + hasLocalChanges: false, + stashed: false, + stashRestored: false, + message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes', + ...(isMerge ? { isMerge: true, mergeAffectedFiles } : {}), + ...(isFastForward ? { isFastForward: true } : {}), + }; + } + } catch (pullError: unknown) { + const err = pullError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + if (isConflictError(errorOutput)) { + pullConflict = true; + try { + pullConflictFiles = await getConflictFiles(worktreePath); + } catch { + pullConflictFiles = []; + } + } else { + // Non-conflict pull error + let stashRecoveryFailed = false; + if (didStash) { + const stashPopped = await tryPopStash(worktreePath); + stashRecoveryFailed = !stashPopped; + } + + // Check for common errors + const errorMsg = err.stderr || err.message || 'Pull failed'; + if (errorMsg.includes('no tracking information')) { + return { + success: false, + error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + + return { + success: false, + error: `${errorMsg}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; + } + } + + // 9. If pull had conflicts, return conflict info (don't try stash pop) + if (pullConflict) { + return { + success: false, + branch: branchName, + pulled: true, + hasConflicts: true, + conflictSource: 'pull', + conflictFiles: pullConflictFiles, + stashed: didStash, + stashRestored: false, + message: + `Pull resulted in merge conflicts. ${didStash ? 'Your local changes are still stashed.' : ''}`.trim(), + }; + } + + // 10. Pull succeeded, now try to reapply stash + if (didStash) { + return await reapplyStash(worktreePath, branchName, { + isMerge, + isFastForward, + mergeAffectedFiles, + }); + } + + // Shouldn't reach here, but return a safe default + return { + success: true, + branch: branchName, + pulled: true, + message: 'Pulled latest changes', + }; +} + +/** + * Attempt to reapply stashed changes after a successful pull. + * Handles both clean reapplication and conflict scenarios. + * + * @param worktreePath - Path to the git worktree + * @param branchName - Current branch name + * @param mergeInfo - Merge/fast-forward detection info from the pull step + * @returns PullResult reflecting stash reapplication status + */ +async function reapplyStash( + worktreePath: string, + branchName: string, + mergeInfo: { isMerge: boolean; isFastForward: boolean; mergeAffectedFiles: string[] } +): Promise { + const mergeFields: Partial = { + ...(mergeInfo.isMerge + ? { isMerge: true, mergeAffectedFiles: mergeInfo.mergeAffectedFiles } + : {}), + ...(mergeInfo.isFastForward ? { isFastForward: true } : {}), + }; + + try { + await popStash(worktreePath); + + // Stash pop succeeded cleanly (popStash throws on non-zero exit) + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: false, + stashed: true, + stashRestored: true, + ...mergeFields, + message: 'Pulled latest changes and restored your stashed changes.', + }; + } catch (stashPopError: unknown) { + const err = stashPopError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + // Check if stash pop failed due to conflicts + // The stash remains in the stash list when conflicts occur, so stashRestored is false + if (isStashConflict(errorOutput)) { + let stashConflictFiles: string[] = []; + try { + stashConflictFiles = await getConflictFiles(worktreePath); + } catch { + stashConflictFiles = []; + } + + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: true, + conflictSource: 'stash', + conflictFiles: stashConflictFiles, + stashed: true, + stashRestored: false, + ...mergeFields, + message: 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.', + }; + } + + // Non-conflict stash pop error - stash is still in the stash list + logger.warn('Failed to reapply stash after pull', { worktreePath, error: errorOutput }); + + return { + success: true, + branch: branchName, + pulled: true, + hasConflicts: false, + stashed: true, + stashRestored: false, + ...mergeFields, + message: + 'Pull succeeded but failed to reapply stashed changes. Your changes are still in the stash list.', + }; + } +} diff --git a/jules_branch/apps/server/src/services/push-service.ts b/jules_branch/apps/server/src/services/push-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1619f5b3509edc162a29dc532b88167cd450bc5 --- /dev/null +++ b/jules_branch/apps/server/src/services/push-service.ts @@ -0,0 +1,258 @@ +/** + * PushService - Push git operations without HTTP + * + * Encapsulates the full git push workflow including: + * - Branch name and detached HEAD detection + * - Safe array-based command execution (no shell interpolation) + * - Divergent branch detection and auto-resolution via pull-then-retry + * - Structured result reporting + * + * Mirrors the pull-service.ts pattern for consistency. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '@automaker/git-utils'; +import { getCurrentBranch } from '../lib/git.js'; +import { performPull } from './pull-service.js'; + +const logger = createLogger('PushService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface PushOptions { + /** Remote name to push to (defaults to 'origin') */ + remote?: string; + /** Force push */ + force?: boolean; + /** When true and push is rejected due to divergence, pull then retry push */ + autoResolve?: boolean; +} + +export interface PushResult { + success: boolean; + error?: string; + branch?: string; + pushed?: boolean; + /** Whether the push was initially rejected because the branches diverged */ + diverged?: boolean; + /** Whether divergence was automatically resolved via pull-then-retry */ + autoResolved?: boolean; + /** Whether the auto-resolve pull resulted in merge conflicts */ + hasConflicts?: boolean; + /** Files with merge conflicts (only when hasConflicts is true) */ + conflictFiles?: string[]; + message?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Detect whether push error output indicates a diverged/non-fast-forward rejection. + */ +function isDivergenceError(errorOutput: string): boolean { + const lower = errorOutput.toLowerCase(); + // Require specific divergence indicators rather than just 'rejected' alone, + // which could match pre-receive hook rejections or protected branch errors. + const hasNonFastForward = lower.includes('non-fast-forward'); + const hasFetchFirst = lower.includes('fetch first'); + const hasFailedToPush = lower.includes('failed to push some refs'); + const hasRejected = lower.includes('rejected'); + return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush); +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a git push on the given worktree. + * + * The workflow: + * 1. Get current branch name (detect detached HEAD) + * 2. Attempt `git push ` with safe array args + * 3. If push fails with divergence and autoResolve is true: + * a. Pull from the same remote (with stash support) + * b. If pull succeeds without conflicts, retry push + * 4. If push fails with "no upstream" error, retry with --set-upstream + * 5. Return structured result + * + * @param worktreePath - Path to the git worktree + * @param options - Push options (remote, force, autoResolve) + * @returns PushResult with detailed status information + */ +export async function performPush( + worktreePath: string, + options?: PushOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + const force = options?.force ?? false; + const autoResolve = options?.autoResolve ?? false; + + // 1. Get current branch name + let branchName: string; + try { + branchName = await getCurrentBranch(worktreePath); + } catch (err) { + return { + success: false, + error: `Failed to get current branch: ${getErrorMessage(err)}`, + }; + } + + // 2. Check for detached HEAD state + if (branchName === 'HEAD') { + return { + success: false, + error: 'Cannot push in detached HEAD state. Please checkout a branch first.', + }; + } + + // 3. Build push args (no -u flag; upstream is set in the fallback path only when needed) + const pushArgs = ['push', targetRemote, branchName]; + if (force) { + pushArgs.push('--force'); + } + + // 4. Attempt push + try { + await execGitCommand(pushArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + message: `Successfully pushed ${branchName} to ${targetRemote}`, + }; + } catch (pushError: unknown) { + const err = pushError as { stderr?: string; stdout?: string; message?: string }; + const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`; + + // 5. Check if the error is a divergence rejection + if (isDivergenceError(errorOutput)) { + if (!autoResolve) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`, + message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`, + }; + } + + // 6. Auto-resolve: pull then retry push + logger.info('Push rejected due to divergence, attempting auto-resolve via pull', { + worktreePath, + remote: targetRemote, + branch: branchName, + }); + + try { + const pullResult = await performPull(worktreePath, { + remote: targetRemote, + stashIfNeeded: true, + }); + + if (!pullResult.success) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Auto-resolve failed during pull: ${pullResult.error}`, + }; + } + + if (pullResult.hasConflicts) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + hasConflicts: true, + conflictFiles: pullResult.conflictFiles, + error: + 'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.', + }; + } + + // 7. Retry push after successful pull + try { + await execGitCommand(pushArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + diverged: true, + autoResolved: true, + message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`, + }; + } catch (retryError: unknown) { + const retryErr = retryError as { stderr?: string; message?: string }; + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`, + }; + } + } catch (pullError) { + return { + success: false, + branch: branchName, + pushed: false, + diverged: true, + autoResolved: false, + error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`, + }; + } + } + + // 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream + const isNoUpstreamError = + errorOutput.toLowerCase().includes('no upstream') || + errorOutput.toLowerCase().includes('has no upstream branch') || + errorOutput.toLowerCase().includes('set-upstream'); + if (isNoUpstreamError) { + try { + const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName]; + if (force) { + setUpstreamArgs.push('--force'); + } + await execGitCommand(setUpstreamArgs, worktreePath); + + return { + success: true, + branch: branchName, + pushed: true, + message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`, + }; + } catch (upstreamError: unknown) { + const upstreamErr = upstreamError as { stderr?: string; message?: string }; + return { + success: false, + branch: branchName, + pushed: false, + error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError), + }; + } + } + + // 6c. Other push error - return as-is + return { + success: false, + branch: branchName, + pushed: false, + error: err.stderr || err.message || getErrorMessage(pushError), + }; + } +} diff --git a/jules_branch/apps/server/src/services/rebase-service.ts b/jules_branch/apps/server/src/services/rebase-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..758a667b5d4eb7a75fb2df3d43449f5b7d34384d --- /dev/null +++ b/jules_branch/apps/server/src/services/rebase-service.ts @@ -0,0 +1,260 @@ +/** + * RebaseService - Rebase git operations without HTTP + * + * Handles git rebase operations with conflict detection and reporting. + * Follows the same pattern as merge-service.ts and cherry-pick-service.ts. + */ + +import fs from 'fs/promises'; +import path from 'path'; +import { createLogger, getErrorMessage, isValidRemoteName } from '@automaker/utils'; +import { execGitCommand, getCurrentBranch, getConflictFiles } from '@automaker/git-utils'; + +const logger = createLogger('RebaseService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface RebaseOptions { + /** Remote name to fetch from before rebasing (defaults to 'origin') */ + remote?: string; +} + +export interface RebaseResult { + success: boolean; + error?: string; + hasConflicts?: boolean; + conflictFiles?: string[]; + aborted?: boolean; + branch?: string; + ontoBranch?: string; + message?: string; +} + +// ============================================================================ +// Service Functions +// ============================================================================ + +/** + * Run a git rebase operation on the given worktree. + * + * @param worktreePath - Path to the git worktree + * @param ontoBranch - The branch to rebase onto (e.g., 'origin/main') + * @param options - Optional rebase options (remote name for fetch) + * @returns RebaseResult with success/failure information + */ +export async function runRebase( + worktreePath: string, + ontoBranch: string, + options?: RebaseOptions +): Promise { + // Reject empty, whitespace-only, or dash-prefixed branch names. + const normalizedOntoBranch = ontoBranch?.trim() ?? ''; + if (normalizedOntoBranch === '' || normalizedOntoBranch.startsWith('-')) { + return { + success: false, + error: `Invalid branch name: "${ontoBranch}" must not be empty or start with a dash.`, + }; + } + + // Get current branch name before rebase + let currentBranch: string; + try { + currentBranch = await getCurrentBranch(worktreePath); + } catch (branchErr) { + return { + success: false, + error: `Failed to resolve current branch for worktree "${worktreePath}": ${getErrorMessage(branchErr)}`, + }; + } + + // Validate the remote name to prevent git option injection. + // Reject invalid remote names so the caller knows their input was wrong, + // consistent with how invalid branch names are handled above. + const remote = options?.remote || 'origin'; + if (!isValidRemoteName(remote)) { + logger.warn('Invalid remote name supplied to rebase-service', { + remote, + worktreePath, + }); + return { + success: false, + error: `Invalid remote name: "${remote}"`, + }; + } + + // Fetch latest from remote before rebasing to ensure we have up-to-date refs + try { + await execGitCommand(['fetch', remote], worktreePath); + } catch (fetchError) { + logger.warn('Failed to fetch from remote before rebase; proceeding with local refs', { + remote, + worktreePath, + error: getErrorMessage(fetchError), + }); + // Non-fatal: proceed with local refs if fetch fails (e.g. offline) + } + + try { + // Pass ontoBranch after '--' so git treats it as a ref, not an option. + // Set LC_ALL=C so git always emits English output regardless of the system + // locale, making text-based conflict detection reliable. + await execGitCommand(['rebase', '--', normalizedOntoBranch], worktreePath, { LC_ALL: 'C' }); + + return { + success: true, + branch: currentBranch, + ontoBranch: normalizedOntoBranch, + message: `Successfully rebased ${currentBranch} onto ${normalizedOntoBranch}`, + }; + } catch (rebaseError: unknown) { + // Check if this is a rebase conflict. We use a multi-layer strategy so + // that detection is reliable even when locale settings vary or git's text + // output changes across versions: + // + // 1. Primary (text-based): scan the error output for well-known English + // conflict markers. Because we pass LC_ALL=C above these strings are + // always in English, but we keep the check as one layer among several. + // + // 2. Repository-state check: run `git rev-parse --git-dir` to find the + // actual .git directory, then verify whether the in-progress rebase + // state directories (.git/rebase-merge or .git/rebase-apply) exist. + // These are created by git at the start of a rebase and are the most + // reliable indicator that a rebase is still in progress (i.e. stopped + // due to conflicts). + // + // 3. Unmerged-path check: run `git status --porcelain` (machine-readable, + // locale-independent) and look for lines whose first two characters + // indicate an unmerged state (UU, AA, DD, AU, UA, DU, UD). + // + // hasConflicts is true when ANY of the three layers returns positive. + const err = rebaseError as { stdout?: string; stderr?: string; message?: string }; + const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`; + + // Layer 1 – text matching (locale-safe because we set LC_ALL=C above). + const textIndicatesConflict = + output.includes('CONFLICT') || + output.includes('could not apply') || + output.includes('Resolve all conflicts') || + output.includes('fix conflicts'); + + // Layers 2 & 3 – repository state inspection (locale-independent). + let rebaseStateExists = false; + let hasUnmergedPaths = false; + try { + // Find the canonical .git directory for this worktree. + const gitDir = (await execGitCommand(['rev-parse', '--git-dir'], worktreePath)).trim(); + // git rev-parse --git-dir returns a path relative to cwd when the repo is + // a worktree, so we resolve it against worktreePath. + const resolvedGitDir = path.resolve(worktreePath, gitDir); + + // Layer 2: check for rebase state directories. + const rebaseMergeDir = path.join(resolvedGitDir, 'rebase-merge'); + const rebaseApplyDir = path.join(resolvedGitDir, 'rebase-apply'); + const [rebaseMergeExists, rebaseApplyExists] = await Promise.all([ + fs + .access(rebaseMergeDir) + .then(() => true) + .catch(() => false), + fs + .access(rebaseApplyDir) + .then(() => true) + .catch(() => false), + ]); + rebaseStateExists = rebaseMergeExists || rebaseApplyExists; + } catch { + // If rev-parse fails the repo may be in an unexpected state; fall back to + // text-based detection only. + } + + try { + // Layer 3: check for unmerged paths via machine-readable git status. + const statusOutput = await execGitCommand(['status', '--porcelain'], worktreePath, { + LC_ALL: 'C', + }); + // Unmerged status codes occupy the first two characters of each line. + // Standard unmerged codes: UU, AA, DD, AU, UA, DU, UD. + hasUnmergedPaths = statusOutput + .split('\n') + .some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line)); + } catch { + // git status failing is itself a sign something is wrong; leave + // hasUnmergedPaths as false and rely on the other layers. + } + + const hasConflicts = textIndicatesConflict || rebaseStateExists || hasUnmergedPaths; + + if (hasConflicts) { + // Attempt to fetch the list of conflicted files. We wrap this in its + // own try/catch so that a failure here does NOT prevent abortRebase from + // running – keeping the repository in a clean state is the priority. + let conflictFiles: string[] | undefined; + let conflictFilesError: unknown; + try { + conflictFiles = await getConflictFiles(worktreePath); + } catch (getConflictFilesError: unknown) { + conflictFilesError = getConflictFilesError; + logger.warn('Failed to retrieve conflict files after rebase conflict', { + worktreePath, + error: getErrorMessage(getConflictFilesError), + }); + } + + // Abort the rebase to leave the repo in a clean state. This must + // always run regardless of whether getConflictFiles succeeded. + const aborted = await abortRebase(worktreePath); + + if (!aborted) { + logger.error('Failed to abort rebase after conflict; repository may be in a dirty state', { + worktreePath, + }); + } + + // Re-throw a composed error so callers retain both the original rebase + // failure context and any conflict-file lookup failure. + if (conflictFilesError !== undefined) { + const composedMessage = [ + `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" failed due to conflicts.`, + `Original rebase error: ${getErrorMessage(rebaseError)}`, + `Additionally, fetching conflict files failed: ${getErrorMessage(conflictFilesError)}`, + aborted + ? 'The rebase was aborted; no changes were applied.' + : 'The rebase abort also failed; repository may be in a dirty state.', + ].join(' '); + throw new Error(composedMessage); + } + + return { + success: false, + error: aborted + ? `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" aborted due to conflicts; no changes were applied.` + : `Rebase of "${currentBranch}" onto "${normalizedOntoBranch}" failed due to conflicts and the abort also failed; repository may be in a dirty state.`, + hasConflicts: true, + conflictFiles, + aborted, + branch: currentBranch, + ontoBranch: normalizedOntoBranch, + }; + } + + // Non-conflict error - propagate + throw rebaseError; + } +} + +/** + * Abort an in-progress rebase operation. + * + * @param worktreePath - Path to the git worktree + * @returns true if abort succeeded, false if it failed (logged as warning) + */ +export async function abortRebase(worktreePath: string): Promise { + try { + await execGitCommand(['rebase', '--abort'], worktreePath); + return true; + } catch (err) { + logger.warn('Failed to abort rebase after conflict', err instanceof Error ? err.message : err); + return false; + } +} diff --git a/jules_branch/apps/server/src/services/recovery-service.ts b/jules_branch/apps/server/src/services/recovery-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d08f5a8e5e9fab7d666b1e2d7ad641af5a0bef41 --- /dev/null +++ b/jules_branch/apps/server/src/services/recovery-service.ts @@ -0,0 +1,333 @@ +/** + * RecoveryService - Crash recovery and feature resumption + */ + +import path from 'path'; +import type { Feature, FeatureStatusWithPipeline } from '@automaker/types'; +import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types'; +import { + createLogger, + readJsonWithRecovery, + logRecoveryWarning, + DEFAULT_BACKUP_COUNT, +} from '@automaker/utils'; +import { + getFeatureDir, + getFeaturesDir, + getExecutionStatePath, + ensureAutomakerDir, +} from '@automaker/platform'; +import * as secureFs from '../lib/secure-fs.js'; +import { getPromptCustomization } from '../lib/settings-helpers.js'; +import type { TypedEventBus } from './typed-event-bus.js'; +import type { ConcurrencyManager, RunningFeature } from './concurrency-manager.js'; +import type { SettingsService } from './settings-service.js'; +import type { PipelineStatusInfo } from './pipeline-orchestrator.js'; + +const logger = createLogger('RecoveryService'); + +export interface ExecutionState { + version: 1; + autoLoopWasRunning: boolean; + maxConcurrency: number; + projectPath: string; + branchName: string | null; + runningFeatureIds: string[]; + savedAt: string; +} + +export const DEFAULT_EXECUTION_STATE: ExecutionState = { + version: 1, + autoLoopWasRunning: false, + maxConcurrency: DEFAULT_MAX_CONCURRENCY, + projectPath: '', + branchName: null, + runningFeatureIds: [], + savedAt: '', +}; + +export type ExecuteFeatureFn = ( + projectPath: string, + featureId: string, + useWorktrees: boolean, + isAutoMode: boolean, + providedWorktreePath?: string, + options?: { continuationPrompt?: string; _calledInternally?: boolean } +) => Promise; +export type LoadFeatureFn = (projectPath: string, featureId: string) => Promise; +export type DetectPipelineStatusFn = ( + projectPath: string, + featureId: string, + status: FeatureStatusWithPipeline +) => Promise; +export type ResumePipelineFn = ( + projectPath: string, + feature: Feature, + useWorktrees: boolean, + pipelineInfo: PipelineStatusInfo +) => Promise; +export type IsFeatureRunningFn = (featureId: string) => boolean; +export type AcquireRunningFeatureFn = (options: { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; +}) => RunningFeature; +export type ReleaseRunningFeatureFn = (featureId: string) => void; + +export class RecoveryService { + constructor( + private eventBus: TypedEventBus, + private concurrencyManager: ConcurrencyManager, + private settingsService: SettingsService | null, + private executeFeatureFn: ExecuteFeatureFn, + private loadFeatureFn: LoadFeatureFn, + private detectPipelineStatusFn: DetectPipelineStatusFn, + private resumePipelineFn: ResumePipelineFn, + private isFeatureRunningFn: IsFeatureRunningFn, + private acquireRunningFeatureFn: AcquireRunningFeatureFn, + private releaseRunningFeatureFn: ReleaseRunningFeatureFn + ) {} + + async saveExecutionStateForProject( + projectPath: string, + branchName: string | null, + maxConcurrency: number + ): Promise { + try { + await ensureAutomakerDir(projectPath); + const runningFeatureIds = this.concurrencyManager + .getAllRunning() + .filter((f) => f.projectPath === projectPath) + .map((f) => f.featureId); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency, + projectPath, + branchName, + runningFeatureIds, + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile( + getExecutionStatePath(projectPath), + JSON.stringify(state, null, 2), + 'utf-8' + ); + } catch { + /* ignore */ + } + } + + async saveExecutionState( + projectPath: string, + autoLoopWasRunning = false, + maxConcurrency = DEFAULT_MAX_CONCURRENCY + ): Promise { + try { + await ensureAutomakerDir(projectPath); + const state: ExecutionState = { + version: 1, + autoLoopWasRunning, + maxConcurrency, + projectPath, + branchName: null, + runningFeatureIds: this.concurrencyManager.getAllRunning().map((rf) => rf.featureId), + savedAt: new Date().toISOString(), + }; + await secureFs.writeFile( + getExecutionStatePath(projectPath), + JSON.stringify(state, null, 2), + 'utf-8' + ); + } catch { + /* ignore */ + } + } + + async loadExecutionState(projectPath: string): Promise { + try { + const content = (await secureFs.readFile( + getExecutionStatePath(projectPath), + 'utf-8' + )) as string; + return JSON.parse(content) as ExecutionState; + } catch { + return DEFAULT_EXECUTION_STATE; + } + } + + async clearExecutionState(projectPath: string, _branchName: string | null = null): Promise { + try { + await secureFs.unlink(getExecutionStatePath(projectPath)); + } catch { + /* ignore */ + } + } + + async contextExists(projectPath: string, featureId: string): Promise { + try { + await secureFs.access(path.join(getFeatureDir(projectPath, featureId), 'agent-output.md')); + return true; + } catch { + return false; + } + } + + private async executeFeatureWithContext( + projectPath: string, + featureId: string, + context: string, + useWorktrees: boolean + ): Promise { + const feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const prompts = await getPromptCustomization(this.settingsService, '[RecoveryService]'); + const featurePrompt = `## Feature Implementation Task\n\n**Feature ID:** ${feature.id}\n**Title:** ${feature.title || 'Untitled Feature'}\n**Description:** ${feature.description}\n`; + let prompt = prompts.taskExecution.resumeFeatureTemplate; + prompt = prompt + .replace(/\{\{featurePrompt\}\}/g, featurePrompt) + .replace(/\{\{previousContext\}\}/g, context); + return this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + continuationPrompt: prompt, + _calledInternally: true, + }); + } + + async resumeFeature( + projectPath: string, + featureId: string, + useWorktrees = false, + _calledInternally = false + ): Promise { + if (!_calledInternally && this.isFeatureRunningFn(featureId)) return; + this.acquireRunningFeatureFn({ + featureId, + projectPath, + isAutoMode: false, + allowReuse: _calledInternally, + }); + try { + const feature = await this.loadFeatureFn(projectPath, featureId); + if (!feature) throw new Error(`Feature ${featureId} not found`); + const pipelineInfo = await this.detectPipelineStatusFn( + projectPath, + featureId, + (feature.status || '') as FeatureStatusWithPipeline + ); + if (pipelineInfo.isPipeline) + return await this.resumePipelineFn(projectPath, feature, useWorktrees, pipelineInfo); + const hasContext = await this.contextExists(projectPath, featureId); + if (hasContext) { + const context = (await secureFs.readFile( + path.join(getFeatureDir(projectPath, featureId), 'agent-output.md'), + 'utf-8' + )) as string; + this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', { + featureId, + featureName: feature.title, + projectPath, + hasContext: true, + message: `Resuming feature "${feature.title}" from saved context`, + }); + return await this.executeFeatureWithContext(projectPath, featureId, context, useWorktrees); + } + this.eventBus.emitAutoModeEvent('auto_mode_feature_resuming', { + featureId, + featureName: feature.title, + projectPath, + hasContext: false, + message: `Starting fresh execution for interrupted feature "${feature.title}"`, + }); + return await this.executeFeatureFn(projectPath, featureId, useWorktrees, false, undefined, { + _calledInternally: true, + }); + } finally { + this.releaseRunningFeatureFn(featureId); + } + } + + async resumeInterruptedFeatures(projectPath: string): Promise { + const featuresDir = getFeaturesDir(projectPath); + try { + // Load execution state to find features that were running before restart. + // This is critical because reconcileAllFeatureStates() runs at server startup + // and resets in_progress/interrupted/pipeline_* features to ready/backlog + // BEFORE the UI connects and calls this method. Without checking execution state, + // we would find no features to resume since their statuses have already been reset. + const executionState = await this.loadExecutionState(projectPath); + const previouslyRunningIds = new Set(executionState.runningFeatureIds ?? []); + + const entries = await secureFs.readdir(featuresDir, { withFileTypes: true }); + const featuresWithContext: Feature[] = []; + const featuresWithoutContext: Feature[] = []; + for (const entry of entries) { + if (entry.isDirectory()) { + const result = await readJsonWithRecovery( + path.join(featuresDir, entry.name, 'feature.json'), + null, + { maxBackups: DEFAULT_BACKUP_COUNT, autoRestore: true } + ); + logRecoveryWarning(result, `Feature ${entry.name}`, logger); + const feature = result.data; + if (!feature) continue; + + // Check if the feature should be resumed: + // 1. Features still in active states (in_progress, pipeline_*) - not yet reconciled + // 2. Features in interrupted state - explicitly marked for resume + // 3. Features that were previously running (from execution state) and are now + // in ready/backlog due to reconciliation resetting their status + const isActiveState = + feature.status === 'in_progress' || + feature.status === 'interrupted' || + (feature.status && feature.status.startsWith('pipeline_')); + const wasReconciledFromRunning = + previouslyRunningIds.has(feature.id) && + (feature.status === 'ready' || feature.status === 'backlog'); + + if (isActiveState || wasReconciledFromRunning) { + if (await this.contextExists(projectPath, feature.id)) { + featuresWithContext.push(feature); + } else { + featuresWithoutContext.push(feature); + } + } + } + } + const allInterruptedFeatures = [...featuresWithContext, ...featuresWithoutContext]; + if (allInterruptedFeatures.length === 0) return; + + logger.info( + `[resumeInterruptedFeatures] Found ${allInterruptedFeatures.length} feature(s) to resume ` + + `(${previouslyRunningIds.size} from execution state, statuses: ${allInterruptedFeatures.map((f) => `${f.id}=${f.status}`).join(', ')})` + ); + + this.eventBus.emitAutoModeEvent('auto_mode_resuming_features', { + message: `Resuming ${allInterruptedFeatures.length} interrupted feature(s)`, + projectPath, + featureIds: allInterruptedFeatures.map((f) => f.id), + features: allInterruptedFeatures.map((f) => ({ + id: f.id, + title: f.title, + status: f.status, + branchName: f.branchName ?? null, + hasContext: featuresWithContext.some((fc) => fc.id === f.id), + })), + }); + for (const feature of allInterruptedFeatures) { + try { + if (!this.isFeatureRunningFn(feature.id)) + await this.resumeFeature(projectPath, feature.id, true); + } catch { + /* continue */ + } + } + + // Clear execution state after successful resume to prevent + // re-resuming the same features on subsequent calls + await this.clearExecutionState(projectPath); + } catch { + /* ignore */ + } + } +} diff --git a/jules_branch/apps/server/src/services/settings-service.ts b/jules_branch/apps/server/src/services/settings-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..5986b877acded14ed9c736f38888dbee9584956d --- /dev/null +++ b/jules_branch/apps/server/src/services/settings-service.ts @@ -0,0 +1,1405 @@ +/** + * Settings Service - Handles reading/writing settings to JSON files + * + * Provides persistent storage for: + * - Global settings (DATA_DIR/settings.json) + * - Credentials (DATA_DIR/credentials.json) + * - Per-project settings ({projectPath}/.automaker/settings.json) + */ + +import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils'; +import * as secureFs from '../lib/secure-fs.js'; +import os from 'os'; +import path from 'path'; +import fs from 'fs/promises'; + +import { + getGlobalSettingsPath, + getCredentialsPath, + getProjectSettingsPath, + ensureDataDir, + ensureAutomakerDir, +} from '@automaker/platform'; +import type { + GlobalSettings, + Credentials, + ProjectSettings, + KeyboardShortcuts, + ProjectRef, + TrashedProjectRef, + BoardBackgroundSettings, + WorktreeInfo, + PhaseModelConfig, + PhaseModelEntry, + FeatureTemplate, + ClaudeApiProfile, + ClaudeCompatibleProvider, + ProviderModel, +} from '../types/settings.js'; +import { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_CREDENTIALS, + DEFAULT_PROJECT_SETTINGS, + DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, + SETTINGS_VERSION, + CREDENTIALS_VERSION, + PROJECT_SETTINGS_VERSION, +} from '../types/settings.js'; +import { + DEFAULT_MAX_CONCURRENCY, + migrateModelId, + migrateCursorModelIds, + migrateOpencodeModelIds, +} from '@automaker/types'; + +const logger = createLogger('SettingsService'); + +/** + * Wrapper for readJsonFile from utils that uses the local secureFs + * to maintain compatibility with the server's secure file system + */ +async function readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const content = (await secureFs.readFile(filePath, 'utf-8')) as string; + return JSON.parse(content) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.error(`Error reading ${filePath}:`, error); + return defaultValue; + } +} + +/** + * Check if a file exists + */ +async function fileExists(filePath: string): Promise { + try { + await secureFs.access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Write settings atomically with backup support + */ +async function writeSettingsJson(filePath: string, data: unknown): Promise { + await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT }); +} + +/** + * SettingsService - Manages persistent storage of user settings and credentials + * + * Handles reading and writing settings to JSON files with atomic operations + * for reliability. Provides three levels of settings: + * - Global settings: shared preferences in {dataDir}/settings.json + * - Credentials: sensitive API keys in {dataDir}/credentials.json + * - Project settings: per-project overrides in {projectPath}/.automaker/settings.json + * + * All operations are atomic (write to temp file, then rename) to prevent corruption. + * Missing files are treated as empty and return defaults on read. + * Updates use deep merge for nested objects like keyboardShortcuts and apiKeys. + */ +export class SettingsService { + private dataDir: string; + + /** + * Create a new SettingsService instance + * + * @param dataDir - Absolute path to global data directory (e.g., ~/.automaker) + */ + constructor(dataDir: string) { + this.dataDir = dataDir; + } + + // ============================================================================ + // Global Settings + // ============================================================================ + + /** + * Get global settings with defaults applied for any missing fields + * + * Reads from {dataDir}/settings.json. If file doesn't exist, returns defaults. + * Missing fields are filled in from DEFAULT_GLOBAL_SETTINGS for forward/backward + * compatibility during schema migrations. + * + * Also applies version-based migrations for breaking changes. + * + * @returns Promise resolving to complete GlobalSettings object + */ + async getGlobalSettings(): Promise { + const settingsPath = getGlobalSettingsPath(this.dataDir); + const settings = await readJsonFile(settingsPath, DEFAULT_GLOBAL_SETTINGS); + + // Migrate legacy enhancementModel/validationModel to phaseModels + const migratedPhaseModels = this.migratePhaseModels(settings); + + // Migrate model IDs to canonical format + const migratedModelSettings = this.migrateModelSettings(settings); + + // Merge built-in feature templates: ensure all built-in templates exist in user settings. + // User customizations (enabled/disabled state, order overrides) are preserved. + // New built-in templates added in code updates are injected for existing users. + const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates); + + // Apply any missing defaults (for backwards compatibility) + let result: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ...settings, + ...migratedModelSettings, + keyboardShortcuts: { + ...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, + ...settings.keyboardShortcuts, + }, + phaseModels: migratedPhaseModels, + featureTemplates: mergedFeatureTemplates, + }; + + // Version-based migrations + const storedVersion = settings.version || 1; + let needsSave = false; + + // Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects + // Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats + if (storedVersion < 3) { + logger.info( + `Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format` + ); + needsSave = true; + } + + // Migration v3 -> v4: Add onboarding/setup wizard state fields + // Older settings files never stored setup state in settings.json (it lived in localStorage), + // so default to "setup complete" for existing installs to avoid forcing re-onboarding. + if (storedVersion < 4) { + if (settings.setupComplete === undefined) result.setupComplete = true; + if (settings.isFirstRun === undefined) result.isFirstRun = false; + if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false; + needsSave = true; + } + + // Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users + // If user has an Anthropic API key in credentials but no profiles, create a + // "Direct Anthropic" profile that references the credentials and set it as active. + if (storedVersion < 5) { + try { + const credentials = await this.getCredentials(); + const hasAnthropicKey = !!credentials.apiKeys?.anthropic; + const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; + const hasNoActiveProfile = !result.activeClaudeApiProfileId; + + if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { + const directAnthropicProfile = { + id: `profile-${Date.now()}-direct-anthropic`, + name: 'Direct Anthropic', + baseUrl: 'https://api.anthropic.com', + apiKeySource: 'credentials' as const, + useAuthToken: false, + }; + + result.claudeApiProfiles = [directAnthropicProfile]; + result.activeClaudeApiProfileId = directAnthropicProfile.id; + + logger.info( + 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' + ); + } + } catch (error) { + logger.warn( + 'Migration v4->v5: Could not check credentials for auto-profile creation:', + error + ); + } + needsSave = true; + } + + // Migration v5 -> v6: Convert claudeApiProfiles to claudeCompatibleProviders + // The new system uses a models[] array instead of modelMappings, and removes + // the "active profile" concept - models are selected directly in phase model configs. + if (storedVersion < 6) { + const legacyProfiles = settings.claudeApiProfiles || []; + if ( + legacyProfiles.length > 0 && + (!result.claudeCompatibleProviders || result.claudeCompatibleProviders.length === 0) + ) { + logger.info( + `Migration v5->v6: Converting ${legacyProfiles.length} Claude API profile(s) to compatible providers` + ); + result.claudeCompatibleProviders = this.migrateProfilesToProviders(legacyProfiles); + } + // Remove the deprecated activeClaudeApiProfileId field + if (result.activeClaudeApiProfileId) { + logger.info('Migration v5->v6: Removing deprecated activeClaudeApiProfileId'); + delete result.activeClaudeApiProfileId; + } + needsSave = true; + } + + // Update version if any migration occurred + if (needsSave) { + result.version = SETTINGS_VERSION; + } + + // Save migrated settings if needed + if (needsSave) { + try { + await ensureDataDir(this.dataDir); + await writeSettingsJson(settingsPath, result); + logger.info('Settings migration complete'); + } catch (error) { + logger.error('Failed to save migrated settings:', error); + } + } + + return result; + } + + /** + * Merge built-in feature templates with user's stored templates. + * + * Ensures new built-in templates added in code updates are available to existing users + * without overwriting their customizations (e.g., enabled/disabled state, custom order). + * Built-in templates missing from stored settings are appended with their defaults. + * + * @param storedTemplates - Templates from user's settings file (may be undefined for new installs) + * @returns Merged template list with all built-in templates present + */ + private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] { + if (!storedTemplates) { + return DEFAULT_FEATURE_TEMPLATES; + } + + const storedIds = new Set(storedTemplates.map((t) => t.id)); + const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id)); + + if (missingBuiltIns.length === 0) { + return storedTemplates; + } + + // Append missing built-in templates after existing ones + return [...storedTemplates, ...missingBuiltIns]; + } + + /** + * Migrate legacy enhancementModel/validationModel fields to phaseModels structure + * + * Handles backwards compatibility for settings created before phaseModels existed. + * Also handles migration from string phase models (v2) to PhaseModelEntry objects (v3). + * Legacy fields take precedence over defaults but phaseModels takes precedence over legacy. + * + * @param settings - Raw settings from file + * @returns Complete PhaseModelConfig with all fields populated + */ + private migratePhaseModels(settings: Partial): PhaseModelConfig { + // Start with defaults + const result: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS }; + + // If phaseModels exists, use it (with defaults for any missing fields) + if (settings.phaseModels) { + // Merge with defaults and convert any string values to PhaseModelEntry + const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS }; + for (const key of Object.keys(settings.phaseModels) as Array) { + const value = settings.phaseModels[key]; + if (value !== undefined) { + // Convert string to PhaseModelEntry if needed (v2 -> v3 migration) + merged[key] = this.toPhaseModelEntry(value); + } + } + return merged; + } + + // Migrate legacy fields if phaseModels doesn't exist + // These were the only two legacy fields that existed + if (settings.enhancementModel) { + result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel); + logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`); + } + if (settings.validationModel) { + result.validationModel = this.toPhaseModelEntry(settings.validationModel); + logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`); + } + + return result; + } + + /** + * Convert a phase model value to PhaseModelEntry format + * + * Handles migration from string format (v2) to object format (v3). + * Also migrates legacy model IDs to canonical prefixed format. + * - String values like 'sonnet' become { model: 'claude-sonnet' } + * - Object values have their model ID migrated if needed + * + * @param value - Phase model value (string or PhaseModelEntry) + * @returns PhaseModelEntry object with canonical model ID + */ + private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry { + if (typeof value === 'string') { + // v2 format: just a model string - migrate to canonical ID + return { model: migrateModelId(value) as PhaseModelEntry['model'] }; + } + // v3 format: PhaseModelEntry object - migrate model ID if needed + return { + ...value, + model: migrateModelId(value.model) as PhaseModelEntry['model'], + }; + } + + /** + * Migrate ClaudeApiProfiles to ClaudeCompatibleProviders + * + * Converts the legacy profile format (with modelMappings) to the new + * provider format (with models[] array). Each model mapping entry becomes + * a ProviderModel with appropriate tier assignment. + * + * @param profiles - Legacy ClaudeApiProfile array + * @returns Array of ClaudeCompatibleProvider + */ + private migrateProfilesToProviders(profiles: ClaudeApiProfile[]): ClaudeCompatibleProvider[] { + return profiles.map((profile): ClaudeCompatibleProvider => { + // Convert modelMappings to models array + const models: ProviderModel[] = []; + + if (profile.modelMappings) { + // Haiku mapping + if (profile.modelMappings.haiku) { + models.push({ + id: profile.modelMappings.haiku, + displayName: this.inferModelDisplayName(profile.modelMappings.haiku, 'haiku'), + mapsToClaudeModel: 'haiku', + }); + } + // Sonnet mapping + if (profile.modelMappings.sonnet) { + models.push({ + id: profile.modelMappings.sonnet, + displayName: this.inferModelDisplayName(profile.modelMappings.sonnet, 'sonnet'), + mapsToClaudeModel: 'sonnet', + }); + } + // Opus mapping + if (profile.modelMappings.opus) { + models.push({ + id: profile.modelMappings.opus, + displayName: this.inferModelDisplayName(profile.modelMappings.opus, 'opus'), + mapsToClaudeModel: 'opus', + }); + } + } + + // Infer provider type from base URL or name + const providerType = this.inferProviderType(profile); + + return { + id: profile.id, + name: profile.name, + providerType, + enabled: true, + baseUrl: profile.baseUrl, + apiKeySource: profile.apiKeySource ?? 'inline', + apiKey: profile.apiKey, + useAuthToken: profile.useAuthToken, + timeoutMs: profile.timeoutMs, + disableNonessentialTraffic: profile.disableNonessentialTraffic, + models, + }; + }); + } + + /** + * Infer a display name for a model based on its ID and tier + * + * @param modelId - The raw model ID + * @param tier - The tier hint (haiku/sonnet/opus) + * @returns A user-friendly display name + */ + private inferModelDisplayName(modelId: string, tier: 'haiku' | 'sonnet' | 'opus'): string { + // Common patterns in model IDs + const lowerModelId = modelId.toLowerCase(); + + // GLM models + if (lowerModelId.includes('glm')) { + return modelId.replace(/-/g, ' ').replace(/glm/i, 'GLM'); + } + + // MiniMax models + if (lowerModelId.includes('minimax')) { + return modelId.replace(/-/g, ' ').replace(/minimax/i, 'MiniMax'); + } + + // Claude models via OpenRouter or similar + if (lowerModelId.includes('claude')) { + return modelId; + } + + // Default: use model ID as display name with tier in parentheses + return `${modelId} (${tier})`; + } + + /** + * Infer provider type from profile configuration + * + * @param profile - The legacy profile + * @returns The inferred provider type + */ + private inferProviderType(profile: ClaudeApiProfile): ClaudeCompatibleProvider['providerType'] { + const baseUrl = profile.baseUrl.toLowerCase(); + const name = profile.name.toLowerCase(); + + // Check URL patterns + if (baseUrl.includes('z.ai') || baseUrl.includes('zhipuai')) { + return 'glm'; + } + if (baseUrl.includes('minimax')) { + return 'minimax'; + } + if (baseUrl.includes('openrouter')) { + return 'openrouter'; + } + if (baseUrl.includes('anthropic.com')) { + return 'anthropic'; + } + + // Check name patterns + if (name.includes('glm') || name.includes('zhipu')) { + return 'glm'; + } + if (name.includes('minimax')) { + return 'minimax'; + } + if (name.includes('openrouter')) { + return 'openrouter'; + } + if (name.includes('anthropic') || name.includes('direct')) { + return 'anthropic'; + } + + // Default to custom + return 'custom'; + } + + /** + * Migrate model-related settings to canonical format + * + * Migrates: + * - enabledCursorModels: legacy IDs to cursor- prefixed + * - enabledOpencodeModels: legacy slash format to dash format + * - cursorDefaultModel: legacy ID to cursor- prefixed + * + * @param settings - Settings to migrate + * @returns Settings with migrated model IDs + */ + private migrateModelSettings(settings: Partial): Partial { + const migrated: Partial = { ...settings }; + + // Migrate Cursor models + if (settings.enabledCursorModels) { + migrated.enabledCursorModels = migrateCursorModelIds( + settings.enabledCursorModels as string[] + ); + } + + // Migrate Cursor default model + if (settings.cursorDefaultModel) { + const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]); + if (migratedDefault.length > 0) { + migrated.cursorDefaultModel = migratedDefault[0]; + } + } + + // Migrate OpenCode models + if (settings.enabledOpencodeModels) { + migrated.enabledOpencodeModels = migrateOpencodeModelIds( + settings.enabledOpencodeModels as string[] + ); + } + + // Migrate OpenCode default model + if (settings.opencodeDefaultModel) { + const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]); + if (migratedDefault.length > 0) { + migrated.opencodeDefaultModel = migratedDefault[0]; + } + } + + return migrated; + } + + /** + * Update global settings with partial changes + * + * Performs a deep merge: nested objects like keyboardShortcuts are merged, + * not replaced. Updates are written atomically. Creates dataDir if needed. + * + * @param updates - Partial GlobalSettings to merge (only provided fields are updated) + * @returns Promise resolving to complete updated GlobalSettings + */ + async updateGlobalSettings(updates: Partial): Promise { + await ensureDataDir(this.dataDir); + const settingsPath = getGlobalSettingsPath(this.dataDir); + + const current = await this.getGlobalSettings(); + + // Guard against destructive "empty array/object" overwrites. + // During auth transitions, the UI can briefly have default/empty state and accidentally + // sync it, wiping persisted settings (especially `projects`). + const sanitizedUpdates: Partial = { ...updates }; + let attemptedProjectWipe = false; + + const ignoreEmptyArrayOverwrite = (key: K): void => { + const nextVal = sanitizedUpdates[key] as unknown; + const curVal = current[key] as unknown; + if ( + Array.isArray(nextVal) && + nextVal.length === 0 && + Array.isArray(curVal) && + curVal.length > 0 + ) { + delete sanitizedUpdates[key]; + } + }; + + const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0; + // Check if this is a legitimate project removal (moved to trash) vs accidental wipe + const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects) + ? sanitizedUpdates.trashedProjects.length + : Array.isArray(current.trashedProjects) + ? current.trashedProjects.length + : 0; + + if ( + Array.isArray(sanitizedUpdates.projects) && + sanitizedUpdates.projects.length === 0 && + currentProjectsLen > 0 + ) { + // Only treat as accidental wipe if trashedProjects is also empty + // (If projects are moved to trash, they appear in trashedProjects) + if (newTrashedProjectsLen === 0) { + logger.warn( + '[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.', + { + currentProjectsLen, + newProjectsLen: 0, + newTrashedProjectsLen, + currentProjects: current.projects?.map((p) => p.name), + } + ); + attemptedProjectWipe = true; + delete sanitizedUpdates.projects; + } else { + logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', { + currentProjectsLen, + newProjectsLen: 0, + movedToTrash: newTrashedProjectsLen, + }); + } + } + + ignoreEmptyArrayOverwrite('trashedProjects'); + ignoreEmptyArrayOverwrite('projectHistory'); + ignoreEmptyArrayOverwrite('recentFolders'); + ignoreEmptyArrayOverwrite('mcpServers'); + ignoreEmptyArrayOverwrite('enabledCursorModels'); + ignoreEmptyArrayOverwrite('claudeApiProfiles'); + // Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers + + // Check for explicit permission to clear eventHooks (escape hatch for intentional clearing) + const allowEmptyEventHooks = + (sanitizedUpdates as Record).__allowEmptyEventHooks === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyEventHooks; + + // Only guard eventHooks if explicit permission wasn't granted + if (!allowEmptyEventHooks) { + ignoreEmptyArrayOverwrite('eventHooks'); + } + + // Guard ntfyEndpoints against accidental wipe + // (similar to eventHooks, these are user-configured and shouldn't be lost) + // Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing) + const allowEmptyNtfyEndpoints = + (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints; + + if (!allowEmptyNtfyEndpoints) { + const currentNtfyLen = Array.isArray(current.ntfyEndpoints) + ? current.ntfyEndpoints.length + : 0; + const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints) + ? sanitizedUpdates.ntfyEndpoints.length + : currentNtfyLen; + + if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) { + logger.warn( + '[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.', + { + currentNtfyLen, + newNtfyLen, + } + ); + delete sanitizedUpdates.ntfyEndpoints; + } + } else { + logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch'); + } + + // Empty object overwrite guard + const ignoreEmptyObjectOverwrite = (key: K): void => { + const nextVal = sanitizedUpdates[key] as unknown; + const curVal = current[key] as unknown; + if ( + nextVal && + typeof nextVal === 'object' && + !Array.isArray(nextVal) && + Object.keys(nextVal).length === 0 && + curVal && + typeof curVal === 'object' && + !Array.isArray(curVal) && + Object.keys(curVal).length > 0 + ) { + delete sanitizedUpdates[key]; + } + }; + + ignoreEmptyObjectOverwrite('lastSelectedSessionByProject'); + ignoreEmptyObjectOverwrite('autoModeByWorktree'); + + // If a request attempted to wipe projects, also ignore theme changes in that same request. + if (attemptedProjectWipe) { + delete sanitizedUpdates.theme; + } + + const updated: GlobalSettings = { + ...current, + ...sanitizedUpdates, + version: SETTINGS_VERSION, + }; + + // Deep merge keyboard shortcuts if provided + if (sanitizedUpdates.keyboardShortcuts) { + updated.keyboardShortcuts = { + ...current.keyboardShortcuts, + ...sanitizedUpdates.keyboardShortcuts, + }; + } + + // Deep merge phaseModels if provided + if (sanitizedUpdates.phaseModels) { + updated.phaseModels = { + ...current.phaseModels, + ...sanitizedUpdates.phaseModels, + }; + } + + // Deep merge autoModeByWorktree if provided (preserves other worktree entries) + if (sanitizedUpdates.autoModeByWorktree) { + type WorktreeEntry = { maxConcurrency: number; branchName: string | null }; + const mergedAutoModeByWorktree: Record = { + ...current.autoModeByWorktree, + }; + for (const [key, value] of Object.entries(sanitizedUpdates.autoModeByWorktree)) { + mergedAutoModeByWorktree[key] = { + ...mergedAutoModeByWorktree[key], + ...value, + }; + } + updated.autoModeByWorktree = mergedAutoModeByWorktree; + } + + await writeSettingsJson(settingsPath, updated); + logger.info('Global settings updated'); + + return updated; + } + + /** + * Check if global settings file exists + * + * Used to determine if user has previously configured settings. + * + * @returns Promise resolving to true if {dataDir}/settings.json exists + */ + async hasGlobalSettings(): Promise { + const settingsPath = getGlobalSettingsPath(this.dataDir); + return fileExists(settingsPath); + } + + // ============================================================================ + // Credentials + // ============================================================================ + + /** + * Get credentials with defaults applied + * + * Reads from {dataDir}/credentials.json. If file doesn't exist, returns + * defaults (empty API keys). Used primarily by backend for API authentication. + * UI should use getMaskedCredentials() instead. + * + * @returns Promise resolving to complete Credentials object + */ + async getCredentials(): Promise { + const credentialsPath = getCredentialsPath(this.dataDir); + const credentials = await readJsonFile(credentialsPath, DEFAULT_CREDENTIALS); + + return { + ...DEFAULT_CREDENTIALS, + ...credentials, + apiKeys: { + ...DEFAULT_CREDENTIALS.apiKeys, + ...credentials.apiKeys, + }, + }; + } + + /** + * Update credentials with partial changes + * + * Updates individual API keys. Uses deep merge for apiKeys object. + * Creates dataDir if needed. Credentials are written atomically. + * WARNING: Use only in secure contexts - keys are unencrypted. + * + * @param updates - Partial Credentials (usually just apiKeys) + * @returns Promise resolving to complete updated Credentials object + */ + async updateCredentials(updates: Partial): Promise { + await ensureDataDir(this.dataDir); + const credentialsPath = getCredentialsPath(this.dataDir); + + const current = await this.getCredentials(); + const updated: Credentials = { + ...current, + ...updates, + version: CREDENTIALS_VERSION, + }; + + // Deep merge api keys if provided + if (updates.apiKeys) { + updated.apiKeys = { + ...current.apiKeys, + ...updates.apiKeys, + }; + } + + await writeSettingsJson(credentialsPath, updated); + logger.info('Credentials updated'); + + return updated; + } + + /** + * Get masked credentials safe for UI display + * + * Returns API keys masked for security (first 4 and last 4 chars visible). + * Use this for showing credential status in UI without exposing full keys. + * Each key includes a 'configured' boolean and masked string representation. + * + * @returns Promise resolving to masked credentials object with each provider's status + */ + async getMaskedCredentials(): Promise<{ + anthropic: { configured: boolean; masked: string }; + google: { configured: boolean; masked: string }; + openai: { configured: boolean; masked: string }; + zai: { configured: boolean; masked: string }; + }> { + const credentials = await this.getCredentials(); + + const maskKey = (key: string): string => { + if (!key || key.length < 8) return ''; + return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; + }; + + return { + anthropic: { + configured: !!credentials.apiKeys.anthropic, + masked: maskKey(credentials.apiKeys.anthropic), + }, + google: { + configured: !!credentials.apiKeys.google, + masked: maskKey(credentials.apiKeys.google), + }, + openai: { + configured: !!credentials.apiKeys.openai, + masked: maskKey(credentials.apiKeys.openai), + }, + zai: { + configured: !!credentials.apiKeys.zai, + masked: maskKey(credentials.apiKeys.zai), + }, + }; + } + + /** + * Check if credentials file exists + * + * Used to determine if user has configured any API keys. + * + * @returns Promise resolving to true if {dataDir}/credentials.json exists + */ + async hasCredentials(): Promise { + const credentialsPath = getCredentialsPath(this.dataDir); + return fileExists(credentialsPath); + } + + // ============================================================================ + // Project Settings + // ============================================================================ + + /** + * Get project-specific settings with defaults applied + * + * Reads from {projectPath}/.automaker/settings.json. If file doesn't exist, + * returns defaults. Project settings are optional - missing values fall back + * to global settings on the UI side. + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to complete ProjectSettings object + */ + async getProjectSettings(projectPath: string): Promise { + const settingsPath = getProjectSettingsPath(projectPath); + const settings = await readJsonFile(settingsPath, DEFAULT_PROJECT_SETTINGS); + + return { + ...DEFAULT_PROJECT_SETTINGS, + ...settings, + }; + } + + /** + * Update project-specific settings with partial changes + * + * Performs a deep merge on boardBackground. Creates .automaker directory + * in project if needed. Updates are written atomically. + * + * @param projectPath - Absolute path to project directory + * @param updates - Partial ProjectSettings to merge + * @returns Promise resolving to complete updated ProjectSettings + */ + async updateProjectSettings( + projectPath: string, + updates: Partial + ): Promise { + await ensureAutomakerDir(projectPath); + const settingsPath = getProjectSettingsPath(projectPath); + + const current = await this.getProjectSettings(projectPath); + const updated: ProjectSettings = { + ...current, + ...updates, + version: PROJECT_SETTINGS_VERSION, + }; + + // Deep merge board background if provided + if (updates.boardBackground) { + updated.boardBackground = { + ...current.boardBackground, + ...updates.boardBackground, + }; + } + + // Handle activeClaudeApiProfileId special cases: + // - "__USE_GLOBAL__" marker means delete the key (use global setting) + // - null means explicit "Direct Anthropic API" + // - string means specific profile ID + if ( + 'activeClaudeApiProfileId' in updates && + updates.activeClaudeApiProfileId === '__USE_GLOBAL__' + ) { + delete updated.activeClaudeApiProfileId; + } + + // Handle phaseModelOverrides special cases: + // - "__CLEAR__" marker means delete the key (use global settings for all phases) + // - object means partial overrides for specific phases + if ( + 'phaseModelOverrides' in updates && + (updates as Record).phaseModelOverrides === '__CLEAR__' + ) { + delete updated.phaseModelOverrides; + } + + // Handle defaultFeatureModel special cases: + // - "__CLEAR__" marker means delete the key (use global setting) + // - object means project-specific override + if ( + 'defaultFeatureModel' in updates && + (updates as Record).defaultFeatureModel === '__CLEAR__' + ) { + delete updated.defaultFeatureModel; + } + + // Handle devCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('devCommand' in updates && updates.devCommand === null) { + delete updated.devCommand; + } + + // Handle testCommand special cases: + // - null means delete the key (use auto-detection) + // - string means custom command + if ('testCommand' in updates && updates.testCommand === null) { + delete updated.testCommand; + } + + await writeSettingsJson(settingsPath, updated); + logger.info(`Project settings updated for ${projectPath}`); + + return updated; + } + + /** + * Check if project settings file exists + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to true if {projectPath}/.automaker/settings.json exists + */ + async hasProjectSettings(projectPath: string): Promise { + const settingsPath = getProjectSettingsPath(projectPath); + return fileExists(settingsPath); + } + + // ============================================================================ + // Migration + // ============================================================================ + + /** + * Migrate settings from localStorage to file-based storage + * + * Called during onboarding when UI detects localStorage data but no settings files. + * Extracts global settings, credentials, and per-project settings from various + * localStorage keys and writes them to the new file-based storage. + * Collects errors but continues on partial failures. + * + * @param localStorageData - Object containing localStorage key/value pairs to migrate + * @returns Promise resolving to migration result with success status and error list + */ + async migrateFromLocalStorage(localStorageData: { + 'automaker-storage'?: string; + 'automaker-setup'?: string; + 'worktree-panel-collapsed'?: string; + 'file-browser-recent-folders'?: string; + 'automaker:lastProjectDir'?: string; + }): Promise<{ + success: boolean; + migratedGlobalSettings: boolean; + migratedCredentials: boolean; + migratedProjectCount: number; + errors: string[]; + }> { + const errors: string[] = []; + let migratedGlobalSettings = false; + let migratedCredentials = false; + let migratedProjectCount = 0; + + try { + // Parse the main automaker-storage + let appState: Record = {}; + if (localStorageData['automaker-storage']) { + try { + const parsed = JSON.parse(localStorageData['automaker-storage']); + appState = parsed.state || parsed; + } catch (e) { + errors.push(`Failed to parse automaker-storage: ${e}`); + } + } + + // Parse setup wizard state (previously stored in localStorage) + let setupState: Record = {}; + if (localStorageData['automaker-setup']) { + try { + const parsed = JSON.parse(localStorageData['automaker-setup']); + setupState = parsed.state || parsed; + } catch (e) { + errors.push(`Failed to parse automaker-setup: ${e}`); + } + } + + // Extract global settings + const globalSettings: Partial = { + setupComplete: + setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false, + isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true, + skipClaudeSetup: + setupState.skipClaudeSetup !== undefined + ? (setupState.skipClaudeSetup as boolean) + : false, + theme: (appState.theme as GlobalSettings['theme']) || 'dark', + sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true, + chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false, + maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY, + defaultSkipTests: + appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, + enableDependencyBlocking: + appState.enableDependencyBlocking !== undefined + ? (appState.enableDependencyBlocking as boolean) + : true, + skipVerificationInAutoMode: + appState.skipVerificationInAutoMode !== undefined + ? (appState.skipVerificationInAutoMode as boolean) + : false, + useWorktrees: + appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true, + defaultPlanningMode: + (appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip', + defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false, + muteDoneSound: (appState.muteDoneSound as boolean) || false, + enhancementModel: + (appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet', + keyboardShortcuts: + (appState.keyboardShortcuts as KeyboardShortcuts) || + DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, + eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [], + ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [], + projects: (appState.projects as ProjectRef[]) || [], + trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], + projectHistory: (appState.projectHistory as string[]) || [], + projectHistoryIndex: (appState.projectHistoryIndex as number) || -1, + lastSelectedSessionByProject: + (appState.lastSelectedSessionByProject as Record) || {}, + }; + + // Add direct localStorage values + if (localStorageData['automaker:lastProjectDir']) { + globalSettings.lastProjectDir = localStorageData['automaker:lastProjectDir']; + } + + if (localStorageData['file-browser-recent-folders']) { + try { + globalSettings.recentFolders = JSON.parse( + localStorageData['file-browser-recent-folders'] + ); + } catch { + globalSettings.recentFolders = []; + } + } + + if (localStorageData['worktree-panel-collapsed']) { + globalSettings.worktreePanelCollapsed = + localStorageData['worktree-panel-collapsed'] === 'true'; + } + + // Save global settings + await this.updateGlobalSettings(globalSettings); + migratedGlobalSettings = true; + logger.info('Migrated global settings from localStorage'); + + // Extract and save credentials + if (appState.apiKeys) { + const apiKeys = appState.apiKeys as { + anthropic?: string; + google?: string; + openai?: string; + }; + await this.updateCredentials({ + apiKeys: { + anthropic: apiKeys.anthropic || '', + google: apiKeys.google || '', + openai: apiKeys.openai || '', + zai: '', + }, + }); + migratedCredentials = true; + logger.info('Migrated credentials from localStorage'); + } + + // Migrate per-project settings + const boardBackgroundByProject = appState.boardBackgroundByProject as + | Record + | undefined; + const currentWorktreeByProject = appState.currentWorktreeByProject as + | Record + | undefined; + const worktreesByProject = appState.worktreesByProject as + | Record + | undefined; + + // Get unique project paths that have per-project settings + const projectPaths = new Set(); + if (boardBackgroundByProject) { + Object.keys(boardBackgroundByProject).forEach((p) => projectPaths.add(p)); + } + if (currentWorktreeByProject) { + Object.keys(currentWorktreeByProject).forEach((p) => projectPaths.add(p)); + } + if (worktreesByProject) { + Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p)); + } + + // Also check projects list for theme settings + const projects = (appState.projects as ProjectRef[]) || []; + for (const project of projects) { + if (project.theme) { + projectPaths.add(project.path); + } + } + + // Migrate each project's settings + for (const projectPath of projectPaths) { + try { + const projectSettings: Partial = {}; + + // Get theme from project object + const project = projects.find((p) => p.path === projectPath); + if (project?.theme) { + projectSettings.theme = project.theme as ProjectSettings['theme']; + } + + if (boardBackgroundByProject?.[projectPath]) { + projectSettings.boardBackground = boardBackgroundByProject[projectPath]; + } + + if (currentWorktreeByProject?.[projectPath]) { + projectSettings.currentWorktree = currentWorktreeByProject[projectPath]; + } + + if (worktreesByProject?.[projectPath]) { + projectSettings.worktrees = worktreesByProject[projectPath]; + } + + if (Object.keys(projectSettings).length > 0) { + await this.updateProjectSettings(projectPath, projectSettings); + migratedProjectCount++; + } + } catch (e) { + errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`); + } + } + + logger.info(`Migration complete: ${migratedProjectCount} projects migrated`); + + return { + success: errors.length === 0, + migratedGlobalSettings, + migratedCredentials, + migratedProjectCount, + errors, + }; + } catch (error) { + logger.error('Migration failed:', error); + errors.push(`Migration failed: ${error}`); + return { + success: false, + migratedGlobalSettings, + migratedCredentials, + migratedProjectCount, + errors, + }; + } + } + + /** + * Get the data directory path + * + * Returns the absolute path to the directory where global settings and + * credentials are stored. Useful for logging, debugging, and validation. + * + * @returns Absolute path to data directory + */ + getDataDir(): string { + return this.dataDir; + } + + /** + * Get the legacy Electron userData directory path + * + * Returns the platform-specific path where Electron previously stored settings + * before the migration to shared data directories. + * + * @returns Absolute path to legacy userData directory + */ + private getLegacyElectronUserDataPath(): string { + const homeDir = os.homedir(); + + switch (process.platform) { + case 'darwin': + // macOS: ~/Library/Application Support/Automaker + return path.join(homeDir, 'Library', 'Application Support', 'Automaker'); + case 'win32': + // Windows: %APPDATA%\Automaker + return path.join( + process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), + 'Automaker' + ); + default: + // Linux and others: ~/.config/Automaker + return path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), 'Automaker'); + } + } + + /** + * Migrate entire data directory from legacy Electron userData location to new shared data directory + * + * This handles the migration from when Electron stored data in the platform-specific + * userData directory (e.g., ~/.config/Automaker) to the new shared ./data directory. + * + * Migration only occurs if: + * 1. The new location does NOT have settings.json + * 2. The legacy location DOES have settings.json + * + * Migrates all files and directories including: + * - settings.json (global settings) + * - credentials.json (API keys) + * - sessions-metadata.json (chat session metadata) + * - agent-sessions/ (conversation histories) + * - Any other files in the data directory + * + * @returns Promise resolving to migration result + */ + async migrateFromLegacyElectronPath(): Promise<{ + migrated: boolean; + migratedFiles: string[]; + legacyPath: string; + errors: string[]; + }> { + const legacyPath = this.getLegacyElectronUserDataPath(); + const migratedFiles: string[] = []; + const errors: string[] = []; + + // Skip if legacy path is the same as current data dir (no migration needed) + if (path.resolve(legacyPath) === path.resolve(this.dataDir)) { + logger.debug('Legacy path same as current data dir, skipping migration'); + return { migrated: false, migratedFiles, legacyPath, errors }; + } + + logger.info(`Checking for legacy data migration from: ${legacyPath}`); + logger.info(`Current data directory: ${this.dataDir}`); + + // Check if new settings already exist + const newSettingsPath = getGlobalSettingsPath(this.dataDir); + let newSettingsExist = false; + try { + await fs.access(newSettingsPath); + newSettingsExist = true; + } catch { + // New settings don't exist, migration may be needed + } + + if (newSettingsExist) { + logger.debug('Settings already exist in new location, skipping migration'); + return { migrated: false, migratedFiles, legacyPath, errors }; + } + + // Check if legacy directory exists and has settings + const legacySettingsPath = path.join(legacyPath, 'settings.json'); + let legacySettingsExist = false; + try { + await fs.access(legacySettingsPath); + legacySettingsExist = true; + } catch { + // Legacy settings don't exist + } + + if (!legacySettingsExist) { + logger.debug('No legacy settings found, skipping migration'); + return { migrated: false, migratedFiles, legacyPath, errors }; + } + + // Perform migration of specific application data files only + // (not Electron internal caches like Code Cache, GPU Cache, etc.) + logger.info('Found legacy data directory, migrating application data to new location...'); + + // Ensure new data directory exists + try { + await ensureDataDir(this.dataDir); + } catch (error) { + const msg = `Failed to create data directory: ${error}`; + logger.error(msg); + errors.push(msg); + return { migrated: false, migratedFiles, legacyPath, errors }; + } + + // Only migrate specific application data files/directories + const itemsToMigrate = [ + 'settings.json', + 'credentials.json', + 'sessions-metadata.json', + 'agent-sessions', + '.api-key', + '.sessions', + ]; + + for (const item of itemsToMigrate) { + const srcPath = path.join(legacyPath, item); + const destPath = path.join(this.dataDir, item); + + // Check if source exists + try { + await fs.access(srcPath); + } catch { + // Source doesn't exist, skip + continue; + } + + // Check if destination already exists + try { + await fs.access(destPath); + logger.debug(`Skipping ${item} - already exists in destination`); + continue; + } catch { + // Destination doesn't exist, proceed with copy + } + + // Copy file or directory + try { + const stat = await fs.stat(srcPath); + if (stat.isDirectory()) { + await this.copyDirectory(srcPath, destPath); + migratedFiles.push(item + '/'); + logger.info(`Migrated directory: ${item}/`); + } else { + const content = await fs.readFile(srcPath); + await fs.writeFile(destPath, content); + migratedFiles.push(item); + logger.info(`Migrated file: ${item}`); + } + } catch (error) { + const msg = `Failed to migrate ${item}: ${error}`; + logger.error(msg); + errors.push(msg); + } + } + + if (migratedFiles.length > 0) { + logger.info( + `Migration complete. Migrated ${migratedFiles.length} item(s): ${migratedFiles.join(', ')}` + ); + logger.info(`Legacy path: ${legacyPath}`); + logger.info(`New path: ${this.dataDir}`); + } + + return { + migrated: migratedFiles.length > 0, + migratedFiles, + legacyPath, + errors, + }; + } + + /** + * Recursively copy a directory from source to destination + * + * @param srcDir - Source directory path + * @param destDir - Destination directory path + */ + private async copyDirectory(srcDir: string, destDir: string): Promise { + await fs.mkdir(destDir, { recursive: true }); + const entries = await fs.readdir(srcDir, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + const destPath = path.join(destDir, entry.name); + + if (entry.isDirectory()) { + await this.copyDirectory(srcPath, destPath); + } else if (entry.isFile()) { + const content = await fs.readFile(srcPath); + await fs.writeFile(destPath, content); + } + } + } +} diff --git a/jules_branch/apps/server/src/services/spec-parser.ts b/jules_branch/apps/server/src/services/spec-parser.ts new file mode 100644 index 0000000000000000000000000000000000000000..534c17e2ff3a203c457751deca5d1a3cac5bb90d --- /dev/null +++ b/jules_branch/apps/server/src/services/spec-parser.ts @@ -0,0 +1,251 @@ +/** + * 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 and summary + * Format: [TASK_COMPLETE] T###: Brief summary + */ +export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null { + // Use a regex that captures the summary until newline or next task marker + // Allow brackets in summary content (e.g., "supports array[index] access") + // Pattern breakdown: + // - \[TASK_COMPLETE\]\s* - Match the marker + // - (T\d{3}) - Capture task ID + // - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket) + // - But we want to allow brackets in summary, so we use a different approach: + // - Match summary until newline, then trim any trailing markers in post-processing + const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i); + if (!match) return null; + + // Post-process: remove trailing task markers from summary if present + let summary = match[2]?.trim(); + if (summary) { + // Remove trailing content that looks like another marker + summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim(); + } + + return { + id: match[1], + summary: summary || undefined, + }; +} + +/** + * Detect [PHASE_COMPLETE] marker in text and extract phase number + * Format: [PHASE_COMPLETE] Phase N complete + */ +export function detectPhaseCompleteMarker(text: string): number | null { + const match = text.match(/\[PHASE_COMPLETE\]\s*Phase\s*(\d+)/i); + return match ? parseInt(match[1], 10) : null; +} + +/** + * Fallback spec detection when [SPEC_GENERATED] marker is missing + * Looks for structural elements that indicate a spec was generated. + * This is especially important for non-Claude models that may not output + * the explicit [SPEC_GENERATED] marker. + * + * @param text - The text content to check for spec structure + * @returns true if the text appears to be a generated spec + */ +export function detectSpecFallback(text: string): boolean { + // Check for key structural elements of a spec + const hasTasksBlock = /```tasks[\s\S]*```/.test(text); + const hasTaskLines = /- \[ \] T\d{3}:/.test(text); + + // Check for common spec sections (case-insensitive) + const hasAcceptanceCriteria = /acceptance criteria/i.test(text); + const hasTechnicalContext = /technical context/i.test(text); + const hasProblemStatement = /problem statement/i.test(text); + const hasUserStory = /user story/i.test(text); + // Additional patterns for different model outputs + const hasGoal = /\*\*Goal\*\*:/i.test(text); + const hasSolution = /\*\*Solution\*\*:/i.test(text); + const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text); + const hasOverview = /##\s*(overview|summary)/i.test(text); + + // Spec is detected if we have task structure AND at least some spec content + const hasTaskStructure = hasTasksBlock || hasTaskLines; + const hasSpecContent = + hasAcceptanceCriteria || + hasTechnicalContext || + hasProblemStatement || + hasUserStory || + hasGoal || + hasSolution || + hasImplementation || + hasOverview; + + return hasTaskStructure && hasSpecContent; +} + +/** + * Extract summary from text content + * Checks for multiple formats in order of priority: + * 1. Explicit tags + * 2. ## Summary section (markdown) + * 3. **Goal**: section (lite planning mode) + * 4. **Problem**: or **Problem Statement**: section (spec/full modes) + * 5. **Solution**: section as fallback + * + * Note: Uses last match for each pattern to avoid stale summaries + * when agent output accumulates across multiple runs. + * + * @param text - The text content to extract summary from + * @returns The extracted summary string, or null if no summary found + */ +export function extractSummary(text: string): string | null { + // Helper to truncate content to first paragraph with max length + const truncate = (content: string, maxLength: number): string => { + const firstPara = content.split(/\n\n/)[0]; + return firstPara.length > maxLength ? `${firstPara.substring(0, maxLength)}...` : firstPara; + }; + + // Helper to get last match from matchAll results + const getLastMatch = (matches: IterableIterator): RegExpMatchArray | null => { + const arr = [...matches]; + return arr.length > 0 ? arr[arr.length - 1] : null; + }; + + // Check for explicit tags first (use last match to avoid stale summaries) + const summaryMatches = text.matchAll(/([\s\S]*?)<\/summary>/g); + const summaryMatch = getLastMatch(summaryMatches); + if (summaryMatch) { + return summaryMatch[1].trim(); + } + + // Check for ## Summary section (use last match) + // Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections + // (like "### Root Cause", "### Fix Applied") that belong to the summary content. + const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi); + const sectionMatch = getLastMatch(sectionMatches); + if (sectionMatch) { + const content = sectionMatch[1].trim(); + // Keep full content (including ### subsections) up to max length + return content.length > 500 ? `${content.substring(0, 500)}...` : content; + } + + // Check for **Goal**: section (lite mode, use last match) + const goalMatches = text.matchAll(/\*\*Goal\*\*:\s*(.+?)(?:\n|$)/gi); + const goalMatch = getLastMatch(goalMatches); + if (goalMatch) { + return goalMatch[1].trim(); + } + + // Check for **Problem**: or **Problem Statement**: section (spec/full modes, use last match) + const problemMatches = text.matchAll( + /\*\*Problem(?:\s*Statement)?\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi + ); + const problemMatch = getLastMatch(problemMatches); + if (problemMatch) { + return truncate(problemMatch[1].trim(), 500); + } + + // Check for **Solution**: section as fallback (use last match) + const solutionMatches = text.matchAll(/\*\*Solution\*\*:\s*([\s\S]*?)(?=\n\d+\.|\n\*\*|$)/gi); + const solutionMatch = getLastMatch(solutionMatches); + if (solutionMatch) { + return truncate(solutionMatch[1].trim(), 300); + } + + return null; +} diff --git a/jules_branch/apps/server/src/services/stage-files-service.ts b/jules_branch/apps/server/src/services/stage-files-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e155b3ee15645492ac75e78b313e0a7a5d309482 --- /dev/null +++ b/jules_branch/apps/server/src/services/stage-files-service.ts @@ -0,0 +1,117 @@ +/** + * stageFilesService - Path validation and git staging/unstaging operations + * + * Extracted from createStageFilesHandler to centralise path canonicalization, + * path-traversal validation, and git invocation so they can be tested and + * reused independently of the HTTP layer. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { execGitCommand } from '../lib/git.js'; + +/** + * Result returned by `stageFiles` on success. + */ +export interface StageFilesResult { + operation: string; + filesCount: number; +} + +/** + * Error thrown when one or more file paths fail validation (e.g. absolute + * paths, path-traversal attempts, or paths that resolve outside the worktree + * root, or when the worktree path itself does not exist). + * + * Handlers can catch this to return an HTTP 400 response instead of 500. + */ +export class StageFilesValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'StageFilesValidationError'; + } +} + +/** + * Resolve the canonical path of the worktree root, validate every file path + * against it to prevent path-traversal attacks, and then invoke the + * appropriate git command (`add` or `reset`) to stage or unstage the files. + * + * @param worktreePath - Absolute path to the git worktree root directory. + * @param files - Relative file paths to stage or unstage. + * @param operation - `'stage'` runs `git add`, `'unstage'` runs `git reset HEAD`. + * + * @returns An object containing the operation name and the number of files + * that were staged/unstaged. + * + * @throws {StageFilesValidationError} When `worktreePath` is inaccessible or + * any entry in `files` fails the path-traversal checks. + * @throws {Error} When the underlying git command fails. + */ +export async function stageFiles( + worktreePath: string, + files: string[], + operation: 'stage' | 'unstage' +): Promise { + // Canonicalize the worktree root by resolving symlinks so that + // path-traversal checks are reliable even when symlinks are involved. + let canonicalRoot: string; + try { + canonicalRoot = await fs.realpath(worktreePath); + } catch { + throw new StageFilesValidationError('worktreePath does not exist or is not accessible'); + } + + // Validate and sanitize each file path to prevent path traversal attacks. + // Each file entry is resolved against the canonicalized worktree root and + // must remain within that root directory. + const base = canonicalRoot + path.sep; + const sanitizedFiles: string[] = []; + for (const file of files) { + // Reject empty or whitespace-only paths — path.resolve(canonicalRoot, '') + // returns canonicalRoot itself, so without this guard an empty string would + // pass all subsequent checks and be forwarded to git unchanged. + if (file.trim() === '') { + throw new StageFilesValidationError( + 'Invalid file path (empty or whitespace-only paths not allowed)' + ); + } + // Reject absolute paths + if (path.isAbsolute(file)) { + throw new StageFilesValidationError( + `Invalid file path (absolute paths not allowed): ${file}` + ); + } + // Reject entries containing '..' + if (file.includes('..')) { + throw new StageFilesValidationError( + `Invalid file path (path traversal not allowed): ${file}` + ); + } + // Resolve the file path against the canonicalized worktree root and + // ensure the result stays within the worktree directory. + const resolved = path.resolve(canonicalRoot, file); + if (resolved !== canonicalRoot && !resolved.startsWith(base)) { + throw new StageFilesValidationError( + `Invalid file path (outside worktree directory): ${file}` + ); + } + // Forward only the original relative path to git — git interprets + // paths relative to its working directory (canonicalRoot / worktreePath), + // so we do not need to pass the resolved absolute path. + sanitizedFiles.push(file); + } + + if (operation === 'stage') { + // Stage the specified files + await execGitCommand(['add', '--', ...sanitizedFiles], worktreePath); + } else { + // Unstage the specified files + await execGitCommand(['reset', 'HEAD', '--', ...sanitizedFiles], worktreePath); + } + + return { + operation, + filesCount: sanitizedFiles.length, + }; +} diff --git a/jules_branch/apps/server/src/services/stash-service.ts b/jules_branch/apps/server/src/services/stash-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd0a8737c7fc6ead711d70d12deff3b2fd78a929 --- /dev/null +++ b/jules_branch/apps/server/src/services/stash-service.ts @@ -0,0 +1,461 @@ +/** + * StashService - Stash operations without HTTP + * + * Encapsulates stash workflows including: + * - Push (create) stashes with optional message and file selection + * - List all stash entries with metadata and changed files + * - Apply or pop a stash entry with conflict detection + * - Drop (delete) a stash entry + * - Conflict detection from command output and git diff + * - Lifecycle event emission (start, progress, conflicts, success, failure) + * + * Extracted from the worktree stash route handlers to improve organisation + * and testability. Follows the same pattern as pull-service.ts and + * merge-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; +import { execGitCommand, execGitCommandWithLockRetry } from '../lib/git.js'; + +const logger = createLogger('StashService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface StashApplyOptions { + /** When true, remove the stash entry after applying (git stash pop) */ + pop?: boolean; +} + +export interface StashApplyResult { + success: boolean; + error?: string; + applied?: boolean; + hasConflicts?: boolean; + conflictFiles?: string[]; + operation?: 'apply' | 'pop'; + stashIndex?: number; + message?: string; +} + +export interface StashPushResult { + success: boolean; + error?: string; + stashed: boolean; + branch?: string; + message?: string; +} + +export interface StashEntry { + index: number; + message: string; + branch: string; + date: string; + files: string[]; +} + +export interface StashListResult { + success: boolean; + error?: string; + stashes: StashEntry[]; + total: number; +} + +export interface StashDropResult { + success: boolean; + error?: string; + dropped: boolean; + stashIndex?: number; + message?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Retrieve the list of files with unmerged (conflicted) entries using git diff. + * + * @param worktreePath - Path to the git worktree + * @returns Array of file paths that have unresolved conflicts + */ +export async function getConflictedFiles(worktreePath: string): Promise { + try { + const diffOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + worktreePath + ); + return diffOutput + .trim() + .split('\n') + .filter((f) => f.trim().length > 0); + } catch { + // If we cannot get the file list, return an empty array + return []; + } +} + +/** + * Determine whether command output indicates a merge conflict. + */ +function isConflictOutput(output: string): boolean { + return output.includes('CONFLICT') || output.includes('Merge conflict'); +} + +/** + * Build a conflict result from stash apply/pop, emit events, and return. + * Extracted to avoid duplicating conflict handling in the try and catch paths. + */ +async function handleStashConflicts( + worktreePath: string, + stashIndex: number, + operation: 'apply' | 'pop', + events?: EventEmitter +): Promise { + const conflictFiles = await getConflictedFiles(worktreePath); + + events?.emit('stash:conflicts', { + worktreePath, + stashIndex, + operation, + conflictFiles, + }); + + const result: StashApplyResult = { + success: true, + applied: true, + hasConflicts: true, + conflictFiles, + operation, + stashIndex, + message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} with conflicts. Please resolve the conflicts.`, + }; + + events?.emit('stash:success', { + worktreePath, + stashIndex, + operation, + hasConflicts: true, + conflictFiles, + }); + + return result; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Apply or pop a stash entry in the given worktree. + * + * The workflow: + * 1. Validate inputs + * 2. Emit stash:start event + * 3. Run `git stash apply` or `git stash pop` + * 4. Emit stash:progress event with raw command output + * 5. Check output for conflict markers; if conflicts found, collect files and + * emit stash:conflicts event + * 6. Emit stash:success or stash:failure depending on outcome + * 7. Return a structured StashApplyResult + * + * @param worktreePath - Absolute path to the git worktree + * @param stashIndex - Zero-based stash index (stash@{N}) + * @param options - Optional flags (pop) + * @returns StashApplyResult with detailed status information + */ +export async function applyOrPop( + worktreePath: string, + stashIndex: number, + options?: StashApplyOptions, + events?: EventEmitter +): Promise { + const operation: 'apply' | 'pop' = options?.pop ? 'pop' : 'apply'; + const stashRef = `stash@{${stashIndex}}`; + + logger.info(`[StashService] ${operation} ${stashRef} in ${worktreePath}`); + + // 1. Emit start event + events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation }); + + try { + // 2. Run git stash apply / pop + let stdout = ''; + + try { + stdout = await execGitCommand(['stash', operation, stashRef], worktreePath); + } catch (gitError: unknown) { + const err = gitError as { stdout?: string; stderr?: string; message?: string }; + const errStdout = err.stdout || ''; + const errStderr = err.stderr || err.message || ''; + + const combinedOutput = `${errStdout}\n${errStderr}`; + + // 3. Emit progress with raw output + events?.emit('stash:progress', { + worktreePath, + stashIndex, + operation, + output: combinedOutput, + }); + + // 4. Check if the error is a conflict + if (isConflictOutput(combinedOutput)) { + return handleStashConflicts(worktreePath, stashIndex, operation, events); + } + + // 5. Non-conflict git error – re-throw so the outer catch logs and handles it + throw gitError; + } + + // 6. Command succeeded – check stdout for conflict markers (some git versions + // exit 0 even when conflicts occur during apply) + const combinedOutput = stdout; + + events?.emit('stash:progress', { worktreePath, stashIndex, operation, output: combinedOutput }); + + if (isConflictOutput(combinedOutput)) { + return handleStashConflicts(worktreePath, stashIndex, operation, events); + } + + // 7. Clean success + const result: StashApplyResult = { + success: true, + applied: true, + hasConflicts: false, + operation, + stashIndex, + message: `Stash ${operation === 'pop' ? 'popped' : 'applied'} successfully`, + }; + + events?.emit('stash:success', { + worktreePath, + stashIndex, + operation, + hasConflicts: false, + }); + + return result; + } catch (error) { + const errorMessage = getErrorMessage(error); + + logger.error(`Stash ${operation} failed`, { error: getErrorMessage(error) }); + + events?.emit('stash:failure', { + worktreePath, + stashIndex, + operation, + error: errorMessage, + }); + + return { + success: false, + error: errorMessage, + applied: false, + operation, + stashIndex, + }; + } +} + +// ============================================================================ +// Push Stash +// ============================================================================ + +/** + * Stash uncommitted changes (including untracked files) with an optional + * message and optional file selection. + * + * Workflow: + * 1. Check for uncommitted changes via `git status --porcelain` + * 2. If no changes, return early with stashed: false + * 3. Build and run `git stash push --include-untracked [-m message] [-- files]` + * 4. Retrieve the current branch name + * 5. Return a structured StashPushResult + * + * @param worktreePath - Absolute path to the git worktree + * @param options - Optional message and files to selectively stash + * @returns StashPushResult with stash status and branch info + */ +export async function pushStash( + worktreePath: string, + options?: { message?: string; files?: string[] }, + events?: EventEmitter +): Promise { + const message = options?.message; + const files = options?.files; + + logger.info(`[StashService] push stash in ${worktreePath}`); + events?.emit('stash:start', { worktreePath, operation: 'push' }); + + // 1. Check for any changes to stash + const status = await execGitCommand(['status', '--porcelain'], worktreePath); + + if (!status.trim()) { + events?.emit('stash:success', { worktreePath, operation: 'push', stashed: false }); + return { + success: true, + stashed: false, + message: 'No changes to stash', + }; + } + + // 2. Build stash push command args + const args = ['stash', 'push', '--include-untracked']; + if (message && message.trim()) { + args.push('-m', message.trim()); + } + + // If specific files are provided, add them as pathspecs after '--' + if (files && files.length > 0) { + args.push('--'); + args.push(...files); + } + + // 3. Execute stash push (with automatic index.lock cleanup and retry) + await execGitCommandWithLockRetry(args, worktreePath); + + // 4. Get current branch name + const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath); + const branchName = branchOutput.trim(); + + events?.emit('stash:success', { + worktreePath, + operation: 'push', + stashed: true, + branch: branchName, + }); + + return { + success: true, + stashed: true, + branch: branchName, + message: message?.trim() || `WIP on ${branchName}`, + }; +} + +// ============================================================================ +// List Stashes +// ============================================================================ + +/** + * List all stash entries for a worktree with metadata and changed files. + * + * Workflow: + * 1. Run `git stash list` with a custom format to get index, message, and date + * 2. Parse each stash line into a structured StashEntry + * 3. For each entry, fetch the list of files changed via `git stash show` + * 4. Return the full list as a StashListResult + * + * @param worktreePath - Absolute path to the git worktree + * @returns StashListResult with all stash entries and their metadata + */ +export async function listStash(worktreePath: string): Promise { + logger.info(`[StashService] list stashes in ${worktreePath}`); + + // 1. Get stash list with format: index, message, date + // Use %aI (strict ISO 8601) instead of %ai to ensure cross-browser compatibility + const stashOutput = await execGitCommand( + ['stash', 'list', '--format=%gd|||%s|||%aI'], + worktreePath + ); + + if (!stashOutput.trim()) { + return { + success: true, + stashes: [], + total: 0, + }; + } + + const stashLines = stashOutput + .trim() + .split('\n') + .filter((l) => l.trim()); + const stashes: StashEntry[] = []; + + for (const line of stashLines) { + const parts = line.split('|||'); + if (parts.length < 3) continue; + + const refSpec = parts[0].trim(); // e.g., "stash@{0}" + const stashMessage = parts[1].trim(); + const date = parts[2].trim(); + + // Extract index from stash@{N}; skip entries that don't match the expected format + const indexMatch = refSpec.match(/stash@\{(\d+)\}/); + if (!indexMatch) continue; + const index = parseInt(indexMatch[1], 10); + + // Extract branch name from message (format: "WIP on branch: hash message" or "On branch: hash message") + let branch = ''; + const branchMatch = stashMessage.match(/^(?:WIP on|On) ([^:]+):/); + if (branchMatch) { + branch = branchMatch[1]; + } + + // Get list of files in this stash + let files: string[] = []; + try { + const filesOutput = await execGitCommand( + ['stash', 'show', refSpec, '--name-only'], + worktreePath + ); + files = filesOutput + .trim() + .split('\n') + .filter((f) => f.trim()); + } catch { + // Ignore errors getting file list + } + + stashes.push({ + index, + message: stashMessage, + branch, + date, + files, + }); + } + + return { + success: true, + stashes, + total: stashes.length, + }; +} + +// ============================================================================ +// Drop Stash +// ============================================================================ + +/** + * Drop (delete) a stash entry by index. + * + * @param worktreePath - Absolute path to the git worktree + * @param stashIndex - Zero-based stash index (stash@{N}) + * @returns StashDropResult with drop status + */ +export async function dropStash( + worktreePath: string, + stashIndex: number, + events?: EventEmitter +): Promise { + const stashRef = `stash@{${stashIndex}}`; + + logger.info(`[StashService] drop ${stashRef} in ${worktreePath}`); + events?.emit('stash:start', { worktreePath, stashIndex, stashRef, operation: 'drop' }); + + await execGitCommand(['stash', 'drop', stashRef], worktreePath); + + events?.emit('stash:success', { worktreePath, stashIndex, stashRef, operation: 'drop' }); + + return { + success: true, + dropped: true, + stashIndex, + message: `Stash ${stashRef} dropped successfully`, + }; +} diff --git a/jules_branch/apps/server/src/services/sync-service.ts b/jules_branch/apps/server/src/services/sync-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f47055c9250e5fb98e7f0fa86a00d5ad7f739ba6 --- /dev/null +++ b/jules_branch/apps/server/src/services/sync-service.ts @@ -0,0 +1,209 @@ +/** + * SyncService - Pull then push in a single operation + * + * Composes performPull() and performPush() to synchronize a branch + * with its remote. Always uses stashIfNeeded for the pull step. + * If push fails with divergence after pull, retries once. + * + * Follows the same pattern as pull-service.ts and push-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { performPull } from './pull-service.js'; +import { performPush } from './push-service.js'; +import type { PullResult } from './pull-service.js'; +import type { PushResult } from './push-service.js'; + +const logger = createLogger('SyncService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface SyncOptions { + /** Remote name (defaults to 'origin') */ + remote?: string; +} + +export interface SyncResult { + success: boolean; + error?: string; + branch?: string; + /** Whether the pull step was performed */ + pulled?: boolean; + /** Whether the push step was performed */ + pushed?: boolean; + /** Pull resulted in conflicts */ + hasConflicts?: boolean; + /** Files with merge conflicts */ + conflictFiles?: string[]; + /** Source of conflicts ('pull' | 'stash') */ + conflictSource?: 'pull' | 'stash'; + /** Whether the pull was a fast-forward */ + isFastForward?: boolean; + /** Whether the pull resulted in a merge commit */ + isMerge?: boolean; + /** Whether push divergence was auto-resolved */ + autoResolved?: boolean; + message?: string; +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a sync operation (pull then push) on the given worktree. + * + * The workflow: + * 1. Pull from remote with stashIfNeeded: true + * 2. If pull has conflicts, stop and return conflict info + * 3. Push to remote + * 4. If push fails with divergence after pull, retry once + * + * @param worktreePath - Path to the git worktree + * @param options - Sync options (remote) + * @returns SyncResult with detailed status information + */ +export async function performSync( + worktreePath: string, + options?: SyncOptions +): Promise { + const targetRemote = options?.remote || 'origin'; + + // 1. Pull from remote + logger.info('Sync: starting pull', { worktreePath, remote: targetRemote }); + + let pullResult: PullResult; + try { + pullResult = await performPull(worktreePath, { + remote: targetRemote, + stashIfNeeded: true, + }); + } catch (pullError) { + return { + success: false, + error: `Sync pull failed: ${getErrorMessage(pullError)}`, + }; + } + + if (!pullResult.success) { + return { + success: false, + branch: pullResult.branch, + pulled: false, + pushed: false, + error: `Sync pull failed: ${pullResult.error}`, + hasConflicts: pullResult.hasConflicts, + conflictFiles: pullResult.conflictFiles, + conflictSource: pullResult.conflictSource, + }; + } + + // 2. If pull had conflicts, stop and return conflict info + if (pullResult.hasConflicts) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + hasConflicts: true, + conflictFiles: pullResult.conflictFiles, + conflictSource: pullResult.conflictSource, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.', + message: pullResult.message, + }; + } + + // 3. Push to remote + logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote }); + + let pushResult: PushResult; + try { + pushResult = await performPush(worktreePath, { + remote: targetRemote, + }); + } catch (pushError) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: `Sync push failed: ${getErrorMessage(pushError)}`, + }; + } + + if (!pushResult.success) { + // 4. If push diverged after pull, retry once with autoResolve + if (pushResult.diverged) { + logger.info('Sync: push diverged after pull, retrying with autoResolve', { + worktreePath, + remote: targetRemote, + }); + + try { + const retryResult = await performPush(worktreePath, { + remote: targetRemote, + autoResolve: true, + }); + + if (retryResult.success) { + return { + success: true, + branch: retryResult.branch, + pulled: true, + pushed: true, + autoResolved: true, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + message: 'Sync completed (push required auto-resolve).', + }; + } + + return { + success: false, + branch: retryResult.branch, + pulled: true, + pushed: false, + hasConflicts: retryResult.hasConflicts, + conflictFiles: retryResult.conflictFiles, + error: retryResult.error, + }; + } catch (retryError) { + return { + success: false, + branch: pullResult.branch, + pulled: true, + pushed: false, + error: `Sync push retry failed: ${getErrorMessage(retryError)}`, + }; + } + } + + return { + success: false, + branch: pushResult.branch, + pulled: true, + pushed: false, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + error: `Sync push failed: ${pushResult.error}`, + }; + } + + return { + success: true, + branch: pushResult.branch, + pulled: pullResult.pulled ?? true, + pushed: true, + isFastForward: pullResult.isFastForward, + isMerge: pullResult.isMerge, + message: pullResult.pulled + ? 'Sync completed: pulled latest changes and pushed.' + : 'Sync completed: already up to date, pushed local commits.', + }; +} diff --git a/jules_branch/apps/server/src/services/terminal-service.ts b/jules_branch/apps/server/src/services/terminal-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..167ab3480e9f2430e3f87ed4431dc8f6eb2209ac --- /dev/null +++ b/jules_branch/apps/server/src/services/terminal-service.ts @@ -0,0 +1,935 @@ +/** + * Terminal Service + * + * Manages PTY (pseudo-terminal) sessions using node-pty. + * Supports cross-platform shell detection including WSL. + */ + +import * as pty from 'node-pty'; +import { EventEmitter } from 'events'; +import * as os from 'os'; +import * as path from 'path'; +// secureFs is used for user-controllable paths (working directory validation) +// 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 +// These are system paths outside ALLOWED_ROOT_DIRECTORY, centralized for security auditing +import { + systemPathExists, + systemPathReadFileSync, + getWslVersionPath, + 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 = {}; +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 + +// Session limit constants - shared with routes/settings.ts +export const MIN_MAX_SESSIONS = 1; +export const MAX_MAX_SESSIONS = 1000; + +// Maximum number of concurrent terminal sessions +// Can be overridden via TERMINAL_MAX_SESSIONS environment variable +// Default set to 1000 - effectively unlimited for most use cases +let maxSessions = parseInt(process.env.TERMINAL_MAX_SESSIONS || '1000', 10); + +// Throttle output to prevent overwhelming WebSocket under heavy load +// Using 4ms for responsive input feedback while still preventing flood +// Note: 16ms caused perceived input lag, especially with backspace +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 | 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; + cwd: string; + createdAt: Date; + shell: string; + scrollbackBuffer: string; // Store recent output for replay on reconnect + outputBuffer: string; // Pending output to be flushed + flushTimeout: NodeJS.Timeout | null; // Throttle timer + resizeInProgress: boolean; // Flag to suppress scrollback during resize + resizeDebounceTimeout: NodeJS.Timeout | null; // Resize settle timer +} + +export interface TerminalOptions { + cwd?: string; + shell?: string; + cols?: number; + rows?: number; + env?: Record; +} + +type DataCallback = (sessionId: string, data: string) => void; +type ExitCallback = (sessionId: string, exitCode: number) => void; + +export class TerminalService extends EventEmitter { + private sessions: Map = new Map(); + private dataCallbacks: Set = new Set(); + private exitCallbacks: Set = new Set(); + private isWindows = os.platform() === 'win32'; + // On Windows, ConPTY requires AttachConsole which fails in Electron/service mode + // Detect Electron by checking for electron-specific env vars or process properties + private isElectron = + !!(process.versions && (process.versions as Record).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. + * Windows doesn't support Unix signals like SIGTERM/SIGKILL, so we call kill() without arguments. + * On Unix-like systems (macOS, Linux), we can specify the signal. + * + * @param ptyProcess - The PTY process to kill + * @param signal - The signal to send on Unix-like systems (default: 'SIGTERM') + */ + private killPtyProcess(ptyProcess: pty.IPty, signal: string = 'SIGTERM'): void { + if (this.isWindows) { + ptyProcess.kill(); + } else { + ptyProcess.kill(signal); + } + } + + /** + * Detect the best shell for the current platform + * Uses getShellPaths() to iterate through allowed shell paths + */ + detectShell(): { shell: string; args: string[] } { + const platform = os.platform(); + const shellPaths = getShellPaths(); + + // 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 || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } + } + } + } + // Fall back to first available POSIX shell + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgsForPath(shell) }; + } + } catch { + // Path not allowed, continue + } + } + return { shell: '/bin/bash', args: ['--login'] }; + } + + // For all platforms: first try user's shell if set + const userShell = process.env.SHELL; + if (userShell && platform !== 'win32') { + // Try to find userShell in allowed paths + for (const allowedShell of shellPaths) { + if ( + allowedShell === userShell || + getShellBasename(allowedShell) === getShellBasename(userShell) + ) { + try { + if (systemPathExists(allowedShell)) { + return { shell: allowedShell, args: getShellArgsForPath(allowedShell) }; + } + } catch { + // Path not allowed, continue searching + } + } + } + } + + // Iterate through allowed shell paths and return first existing one + for (const shell of shellPaths) { + try { + if (systemPathExists(shell)) { + return { shell, args: getShellArgsForPath(shell) }; + } + } catch { + // Path not allowed or doesn't exist, continue to next + } + } + + // Ultimate fallbacks based on platform + if (platform === 'win32') { + return { shell: 'cmd.exe', args: [] }; + } + return { shell: '/bin/sh', args: [] }; + } + + /** + * Detect if running inside WSL (Windows Subsystem for Linux) + */ + isWSL(): boolean { + try { + // Check /proc/version for Microsoft/WSL indicators + const wslVersionPath = getWslVersionPath(); + if (systemPathExists(wslVersionPath)) { + const version = systemPathReadFileSync(wslVersionPath, 'utf-8').toLowerCase(); + return version.includes('microsoft') || version.includes('wsl'); + } + // Check for WSL environment variable + if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + return true; + } + } catch { + // Ignore errors + } + return false; + } + + /** + * Get platform info for the client + */ + getPlatformInfo(): { + platform: string; + isWSL: boolean; + defaultShell: string; + arch: string; + } { + const { shell } = this.detectShell(); + return { + platform: os.platform(), + isWSL: this.isWSL(), + defaultShell: shell, + arch: os.arch(), + }; + } + + /** + * Validate and resolve a working directory path + * Includes basic sanitization against null bytes and path normalization + * Uses secureFs to enforce ALLOWED_ROOT_DIRECTORY for user-provided paths + */ + private async resolveWorkingDirectory(requestedCwd?: string): Promise { + const homeDir = os.homedir(); + + // If no cwd requested, use home + if (!requestedCwd) { + return homeDir; + } + + // Clean up the path + let cwd = requestedCwd.trim(); + + // Reject paths with null bytes (could bypass path checks) + if (cwd.includes('\0')) { + logger.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, '\\0')}`); + return homeDir; + } + + // Fix double slashes at start (but not for Windows UNC paths) + if (cwd.startsWith('//') && !cwd.startsWith('//wsl')) { + cwd = cwd.slice(1); + } + + // Normalize the path to resolve . and .. segments + // Skip normalization for WSL UNC paths as path.resolve would break them + if (!cwd.startsWith('//wsl')) { + cwd = path.resolve(cwd); + } + + // Check if path exists and is a directory + // Using secureFs.stat to enforce ALLOWED_ROOT_DIRECTORY security boundary + // This prevents terminals from being opened in directories outside the allowed workspace + try { + const statResult = await secureFs.stat(cwd); + if (statResult.isDirectory()) { + return cwd; + } + logger.warn(`Path exists but is not a directory: ${cwd}, falling back to home`); + return homeDir; + } catch { + logger.warn(`Working directory does not exist or not allowed: ${cwd}, falling back to home`); + return homeDir; + } + } + + /** + * Get current session count + */ + getSessionCount(): number { + return this.sessions.size; + } + + /** + * Get maximum allowed sessions + */ + getMaxSessions(): number { + return maxSessions; + } + + /** + * Set maximum allowed sessions (can be called dynamically) + */ + setMaxSessions(limit: number): void { + if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) { + maxSessions = limit; + logger.info(`Max sessions limit updated to ${limit}`); + } + } + + /** + * Create a new terminal session + * Returns null if the maximum session limit has been reached + */ + async createSession(options: TerminalOptions = {}): Promise { + // Check session limit + if (this.sessions.size >= maxSessions) { + logger.error(`Max sessions (${maxSessions}) reached, refusing new session`); + return null; + } + + const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + 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 + const cwd = await this.resolveWorkingDirectory(options.cwd); + + // Build environment with some useful defaults + // These settings ensure consistent terminal behavior across platforms + // First, create a clean copy of process.env excluding Automaker-specific variables + // that could pollute user shells (e.g., PORT would affect Next.js/other dev servers) + const automakerEnvVars = ['PORT', 'DATA_DIR', 'AUTOMAKER_API_KEY', 'NODE_PATH']; + const cleanEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && !automakerEnvVars.includes(key)) { + cleanEnv[key] = value; + } + } + + // Terminal config injection (custom prompts, themes) + const terminalConfigEnv: Record = {}; + 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 = { + ...cleanEnv, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'automaker-terminal', + // Ensure proper locale for character handling + 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}`); + + // Build PTY spawn options + const ptyOptions: pty.IPtyForkOptions = { + name: 'xterm-256color', + cols: options.cols || 80, + rows: options.rows || 24, + cwd, + env, + }; + + // On Windows, always use winpty instead of ConPTY + // ConPTY requires AttachConsole which fails in many contexts: + // - Electron apps without a console + // - VS Code integrated terminal + // - Spawned from other applications + // The error happens in a subprocess so we can't catch it - must proactively disable + if (this.isWindows) { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + logger.info( + `[createSession] Using winpty for session ${id} (ConPTY disabled for compatibility)` + ); + } + + let ptyProcess: pty.IPty; + try { + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + } catch (spawnError) { + const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); + + // Check for Windows ConPTY-specific errors + if (this.isWindows && errorMessage.includes('AttachConsole failed')) { + // ConPTY failed - try winpty fallback + if (!this.useConptyFallback) { + logger.warn(`[createSession] ConPTY AttachConsole failed, retrying with winpty fallback`); + this.useConptyFallback = true; + + try { + (ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false; + ptyProcess = pty.spawn(shell, shellArgs, ptyOptions); + logger.info(`[createSession] Successfully spawned session ${id} with winpty fallback`); + } catch (fallbackError) { + const fallbackMessage = + fallbackError instanceof Error ? fallbackError.message : String(fallbackError); + logger.error(`[createSession] Winpty fallback also failed:`, fallbackMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed (winpty):`, errorMessage); + return null; + } + } else { + logger.error(`[createSession] PTY spawn failed:`, errorMessage); + return null; + } + } + + const session: TerminalSession = { + id, + pty: ptyProcess, + cwd, + createdAt: new Date(), + shell, + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + resizeInProgress: false, + resizeDebounceTimeout: null, + }; + + this.sessions.set(id, session); + + // Flush buffered output to clients (throttled) + const flushOutput = () => { + if (session.outputBuffer.length === 0) return; + + // Send in batches if buffer is large + let dataToSend = session.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } else { + session.outputBuffer = ''; + session.flushTimeout = null; + } + + this.dataCallbacks.forEach((cb) => cb(id, dataToSend)); + this.emit('data', id, dataToSend); + }; + + // Forward data events with throttling + ptyProcess.onData((data) => { + // Skip ALL output during resize/reconnect to prevent prompt redraw duplication + // This drops both scrollback AND live output during the suppression window + // Without this, prompt redraws from SIGWINCH go to live clients causing duplicates + if (session.resizeInProgress) { + return; + } + + // Append to scrollback buffer + session.scrollbackBuffer += data; + // Trim if too large (keep the most recent data) + if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + + // Buffer output for throttled live delivery + session.outputBuffer += data; + + // Schedule flush if not already scheduled + if (!session.flushTimeout) { + session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS); + } + }); + + // Handle exit + ptyProcess.onExit(({ exitCode }) => { + const exitMessage = + exitCode === undefined || exitCode === null + ? 'Session terminated' + : `Session exited with code ${exitCode}`; + logger.info(`${exitMessage} (${id})`); + this.sessions.delete(id); + this.exitCallbacks.forEach((cb) => cb(id, exitCode)); + this.emit('exit', id, exitCode); + }); + + logger.info(`Session ${id} created successfully`); + return session; + } + + /** + * Write data to a terminal session + */ + write(sessionId: string, data: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + logger.warn(`Session ${sessionId} not found`); + return false; + } + session.pty.write(data); + return true; + } + + /** + * Resize a terminal session + * @param suppressOutput - If true, suppress output during resize to prevent duplicate prompts. + * Should be false for the initial resize so the first prompt isn't dropped. + */ + resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + logger.warn(`Session ${sessionId} not found for resize`); + return false; + } + try { + // Only suppress output on subsequent resizes, not the initial one + // This prevents the shell's first prompt from being dropped + if (suppressOutput) { + session.resizeInProgress = true; + if (session.resizeDebounceTimeout) { + clearTimeout(session.resizeDebounceTimeout); + } + } + + session.pty.resize(cols, rows); + + // Clear resize flag after a delay (allow prompt to settle) + // 150ms is enough for most prompts - longer causes sluggish feel + if (suppressOutput) { + session.resizeDebounceTimeout = setTimeout(() => { + session.resizeInProgress = false; + session.resizeDebounceTimeout = null; + }, 150); + } + + return true; + } catch (error) { + logger.error(`Error resizing session ${sessionId}:`, error); + session.resizeInProgress = false; // Clear flag on error + return false; + } + } + + /** + * Kill a terminal session + * Attempts graceful SIGTERM first, then SIGKILL after 1 second if still alive + */ + killSession(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + try { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + // Clean up resize debounce timeout + if (session.resizeDebounceTimeout) { + clearTimeout(session.resizeDebounceTimeout); + session.resizeDebounceTimeout = null; + } + + // First try graceful SIGTERM to allow process cleanup + // On Windows, killPtyProcess calls kill() without signal since Windows doesn't support Unix signals + logger.info(`Session ${sessionId} sending SIGTERM`); + this.killPtyProcess(session.pty, 'SIGTERM'); + + // Schedule SIGKILL fallback if process doesn't exit gracefully + // The onExit handler will remove session from map when it actually exits + setTimeout(() => { + if (this.sessions.has(sessionId)) { + logger.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`); + try { + this.killPtyProcess(session.pty, 'SIGKILL'); + } catch { + // Process may have already exited + } + // Force remove from map if still present + this.sessions.delete(sessionId); + } + }, 1000); + + logger.info(`Session ${sessionId} kill initiated`); + return true; + } catch (error) { + logger.error(`Error killing session ${sessionId}:`, error); + // Still try to remove from map even if kill fails + this.sessions.delete(sessionId); + return false; + } + } + + /** + * Get a session by ID + */ + getSession(sessionId: string): TerminalSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * Get scrollback buffer for a session (for replay on reconnect) + */ + getScrollback(sessionId: string): string | null { + const session = this.sessions.get(sessionId); + return session?.scrollbackBuffer || null; + } + + /** + * Get scrollback buffer and clear pending output buffer to prevent duplicates + * Call this when establishing a new WebSocket connection + * This prevents data that's already in scrollback from being sent again via data callback + */ + getScrollbackAndClearPending(sessionId: string): string | null { + const session = this.sessions.get(sessionId); + if (!session) return null; + + // Clear any pending output that hasn't been flushed yet + // This data is already in scrollbackBuffer + session.outputBuffer = ''; + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + + // NOTE: Don't set resizeInProgress here - it causes blank terminals + // if the shell hasn't output its prompt yet when WebSocket connects. + // The resize() method handles suppression during actual resize events. + + return session.scrollbackBuffer || null; + } + + /** + * Get all active sessions + */ + getAllSessions(): Array<{ + id: string; + cwd: string; + createdAt: Date; + shell: string; + }> { + return Array.from(this.sessions.values()).map((s) => ({ + id: s.id, + cwd: s.cwd, + createdAt: s.createdAt, + shell: s.shell, + })); + } + + /** + * Subscribe to data events + */ + onData(callback: DataCallback): () => void { + this.dataCallbacks.add(callback); + return () => this.dataCallbacks.delete(callback); + } + + /** + * Subscribe to exit events + */ + onExit(callback: ExitCallback): () => void { + this.exitCallbacks.add(callback); + return () => this.exitCallbacks.delete(callback); + } + + /** + * Handle theme change - regenerate RC files with new theme colors + */ + async onThemeChange(projectPath: string, newTheme: string): Promise { + 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 + */ + cleanup(): void { + logger.info(`Cleaning up ${this.sessions.size} sessions`); + this.sessions.forEach((session, id) => { + try { + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + } + // Use platform-specific kill to ensure proper termination on Windows + this.killPtyProcess(session.pty); + } catch { + // Ignore errors during cleanup + } + this.sessions.delete(id); + }); + } +} + +// Singleton instance +let terminalService: TerminalService | null = null; + +export function getTerminalService(settingsService?: SettingsService): TerminalService { + if (!terminalService) { + terminalService = new TerminalService(settingsService); + } + return terminalService; +} diff --git a/jules_branch/apps/server/src/services/test-runner-service.ts b/jules_branch/apps/server/src/services/test-runner-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d55d7be61db3f69f315b86cfc1f0aba1704cbe5f --- /dev/null +++ b/jules_branch/apps/server/src/services/test-runner-service.ts @@ -0,0 +1,682 @@ +/** + * Test Runner Service + * + * Manages test execution processes for git worktrees. + * Runs user-configured test commands with output streaming. + * + * Features: + * - Process management with graceful shutdown + * - Output buffering and throttling for WebSocket streaming + * - Support for running all tests or specific files + * - Cross-platform process cleanup (Windows/Unix) + */ + +import { spawn, execSync, type ChildProcess } from 'child_process'; +import * as secureFs from '../lib/secure-fs.js'; +import { createLogger } from '@automaker/utils'; +import type { EventEmitter } from '../lib/events.js'; + +const logger = createLogger('TestRunnerService'); + +// Maximum scrollback buffer size (characters) +const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run + +// Throttle output to prevent overwhelming WebSocket under heavy load +// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes +// due to rapid React state updates and string concatenation overhead +const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability +const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency + +/** + * Status of a test run + */ +export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error'; + +/** + * Information about an active test run session + */ +export interface TestRunSession { + /** Unique identifier for this test run */ + id: string; + /** Path to the worktree where tests are running */ + worktreePath: string; + /** The command being run */ + command: string; + /** The spawned child process */ + process: ChildProcess | null; + /** When the test run started */ + startedAt: Date; + /** When the test run finished (if completed) */ + finishedAt: Date | null; + /** Current status of the test run */ + status: TestRunStatus; + /** Exit code from the process (if completed) */ + exitCode: number | null; + /** Specific test file being run (optional) */ + testFile?: string; + /** Scrollback buffer for log history (replay on reconnect) */ + scrollbackBuffer: string; + /** Pending output to be flushed to subscribers */ + outputBuffer: string; + /** Throttle timer for batching output */ + flushTimeout: NodeJS.Timeout | null; + /** Flag to indicate session is stopping (prevents output after stop) */ + stopping: boolean; +} + +/** + * Result of a test run operation + */ +export interface TestRunResult { + success: boolean; + result?: { + sessionId: string; + worktreePath: string; + command: string; + status: TestRunStatus; + testFile?: string; + message: string; + }; + error?: string; +} + +/** + * Test Runner Service class + * Manages test execution processes across worktrees + */ +class TestRunnerService { + private sessions: Map = new Map(); + private emitter: EventEmitter | null = null; + + /** + * Set the event emitter for streaming log events + * Called during service initialization with the global event emitter + */ + setEventEmitter(emitter: EventEmitter): void { + this.emitter = emitter; + } + + /** + * Helper to check if a file exists using secureFs + */ + private async fileExists(filePath: string): Promise { + try { + await secureFs.access(filePath); + return true; + } catch { + return false; + } + } + + /** + * Append data to scrollback buffer with size limit enforcement + * Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE + */ + private appendToScrollback(session: TestRunSession, data: string): void { + session.scrollbackBuffer += data; + if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) { + session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE); + } + } + + /** + * Flush buffered output to WebSocket subscribers + * Sends batched output to prevent overwhelming clients under heavy load + */ + private flushOutput(session: TestRunSession): void { + // Skip flush if session is stopping or buffer is empty + if (session.stopping || session.outputBuffer.length === 0) { + session.flushTimeout = null; + return; + } + + let dataToSend = session.outputBuffer; + if (dataToSend.length > OUTPUT_BATCH_SIZE) { + // Send in batches if buffer is large + dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE); + session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE); + // Schedule another flush for remaining data + session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS); + } else { + session.outputBuffer = ''; + session.flushTimeout = null; + } + + // Emit output event for WebSocket streaming + if (this.emitter) { + this.emitter.emit('test-runner:output', { + sessionId: session.id, + worktreePath: session.worktreePath, + content: dataToSend, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Handle incoming stdout/stderr data from test process + * Buffers data for scrollback replay and schedules throttled emission + */ + private handleProcessOutput(session: TestRunSession, data: Buffer): void { + // Skip output if session is stopping + if (session.stopping) { + return; + } + + const content = data.toString(); + + // Append to scrollback buffer for replay on reconnect + this.appendToScrollback(session, content); + + // Buffer output for throttled live delivery + session.outputBuffer += content; + + // Schedule flush if not already scheduled + if (!session.flushTimeout) { + session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS); + } + + // Also log for debugging (existing behavior) + logger.debug(`[${session.id}] ${content.trim()}`); + } + + /** + * Kill any process running (platform-specific cleanup) + */ + private killProcessTree(pid: number): void { + try { + if (process.platform === 'win32') { + // Windows: use taskkill to kill process tree + execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' }); + } else { + // Unix: kill the process group + try { + process.kill(-pid, 'SIGTERM'); + } catch { + // Fallback to killing just the process + process.kill(pid, 'SIGTERM'); + } + } + } catch (error) { + logger.debug(`Error killing process ${pid}:`, error); + } + } + + /** + * Generate a unique session ID + */ + private generateSessionId(): string { + return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + } + + /** + * Sanitize a test file path to prevent command injection + * Allows only safe characters for file paths + */ + private sanitizeTestFile(testFile: string): string { + // Remove any shell metacharacters and normalize path + // Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths) + return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, ''); + } + + /** + * Start tests in a worktree using the provided command + * + * @param worktreePath - Path to the worktree where tests should run + * @param options - Configuration for the test run + * @returns TestRunResult with session info or error + */ + async startTests( + worktreePath: string, + options: { + command: string; + testFile?: string; + } + ): Promise { + const { command, testFile } = options; + + // Check if already running + const existingSession = this.getActiveSession(worktreePath); + if (existingSession) { + return { + success: false, + error: `Tests are already running for this worktree (session: ${existingSession.id})`, + }; + } + + // Verify the worktree exists + if (!(await this.fileExists(worktreePath))) { + return { + success: false, + error: `Worktree path does not exist: ${worktreePath}`, + }; + } + + if (!command) { + return { + success: false, + error: 'No test command provided', + }; + } + + // Build the final command (append test file if specified) + let finalCommand = command; + if (testFile) { + // Sanitize test file path to prevent command injection + const sanitizedFile = this.sanitizeTestFile(testFile); + // Append the test file to the command + // Most test runners support: command -- file or command file + finalCommand = `${command} -- ${sanitizedFile}`; + } + + // Parse command into cmd and args (shell execution) + // We use shell: true to support complex commands like "npm run test:server" + logger.info(`Starting tests in ${worktreePath}`); + logger.info(`Command: ${finalCommand}`); + + // Create session + const sessionId = this.generateSessionId(); + const session: TestRunSession = { + id: sessionId, + worktreePath, + command: finalCommand, + process: null, + startedAt: new Date(), + finishedAt: null, + status: 'pending', + exitCode: null, + testFile, + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + }; + + // Spawn the test process using shell + const env = { + ...process.env, + FORCE_COLOR: '1', + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + CI: 'true', // Helps some test runners format output better + }; + + const testProcess = spawn(finalCommand, [], { + cwd: worktreePath, + env, + shell: true, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', // Use process groups on Unix for cleanup + }); + + session.process = testProcess; + session.status = 'running'; + + // Track if process failed early + const status = { error: null as string | null, exited: false }; + + // Helper to clean up resources and emit events + const cleanupAndFinish = ( + exitCode: number | null, + finalStatus: TestRunStatus, + errorMessage?: string + ) => { + session.finishedAt = new Date(); + session.exitCode = exitCode; + session.status = finalStatus; + + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + + // Flush any remaining output + if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) { + this.emitter.emit('test-runner:output', { + sessionId: session.id, + worktreePath: session.worktreePath, + content: session.outputBuffer, + timestamp: new Date().toISOString(), + }); + session.outputBuffer = ''; + } + + // Emit completed event + if (this.emitter && !session.stopping) { + this.emitter.emit('test-runner:completed', { + sessionId: session.id, + worktreePath: session.worktreePath, + command: session.command, + status: finalStatus, + exitCode, + error: errorMessage, + duration: session.finishedAt.getTime() - session.startedAt.getTime(), + timestamp: new Date().toISOString(), + }); + } + }; + + // Capture stdout + if (testProcess.stdout) { + testProcess.stdout.on('data', (data: Buffer) => { + this.handleProcessOutput(session, data); + }); + } + + // Capture stderr + if (testProcess.stderr) { + testProcess.stderr.on('data', (data: Buffer) => { + this.handleProcessOutput(session, data); + }); + } + + testProcess.on('error', (error) => { + logger.error(`Process error for ${sessionId}:`, error); + status.error = error.message; + cleanupAndFinish(null, 'error', error.message); + }); + + testProcess.on('exit', (code) => { + logger.info(`Test process for ${worktreePath} exited with code ${code}`); + status.exited = true; + + // Determine final status based on exit code + let finalStatus: TestRunStatus; + if (session.stopping) { + finalStatus = 'cancelled'; + } else if (code === 0) { + finalStatus = 'passed'; + } else { + finalStatus = 'failed'; + } + + cleanupAndFinish(code, finalStatus); + }); + + // Store session + this.sessions.set(sessionId, session); + + // Wait a moment to see if the process fails immediately + await new Promise((resolve) => setTimeout(resolve, 200)); + + if (status.error) { + return { + success: false, + error: `Failed to start tests: ${status.error}`, + }; + } + + if (status.exited) { + // Process already exited - check if it was immediate failure + const exitedSession = this.sessions.get(sessionId); + if (exitedSession && exitedSession.status === 'error') { + return { + success: false, + error: `Test process exited immediately. Check output for details.`, + }; + } + } + + // Emit started event + if (this.emitter) { + this.emitter.emit('test-runner:started', { + sessionId, + worktreePath, + command: finalCommand, + testFile, + timestamp: new Date().toISOString(), + }); + } + + return { + success: true, + result: { + sessionId, + worktreePath, + command: finalCommand, + status: 'running', + testFile, + message: `Tests started: ${finalCommand}`, + }, + }; + } + + /** + * Stop a running test session + * + * @param sessionId - The ID of the test session to stop + * @returns Result with success status and message + */ + async stopTests(sessionId: string): Promise<{ + success: boolean; + result?: { sessionId: string; message: string }; + error?: string; + }> { + const session = this.sessions.get(sessionId); + + if (!session) { + return { + success: false, + error: `Test session not found: ${sessionId}`, + }; + } + + if (session.status !== 'running') { + return { + success: true, + result: { + sessionId, + message: `Tests already finished (status: ${session.status})`, + }, + }; + } + + logger.info(`Cancelling test session ${sessionId}`); + + // Mark as stopping to prevent further output events + session.stopping = true; + + // Clean up flush timeout + if (session.flushTimeout) { + clearTimeout(session.flushTimeout); + session.flushTimeout = null; + } + + // Kill the process + if (session.process && !session.process.killed && session.process.pid) { + this.killProcessTree(session.process.pid); + } + + session.status = 'cancelled'; + session.finishedAt = new Date(); + + // Emit cancelled event + if (this.emitter) { + this.emitter.emit('test-runner:completed', { + sessionId, + worktreePath: session.worktreePath, + command: session.command, + status: 'cancelled', + exitCode: null, + duration: session.finishedAt.getTime() - session.startedAt.getTime(), + timestamp: new Date().toISOString(), + }); + } + + return { + success: true, + result: { + sessionId, + message: 'Test run cancelled', + }, + }; + } + + /** + * Get the active test session for a worktree + */ + getActiveSession(worktreePath: string): TestRunSession | undefined { + for (const session of this.sessions.values()) { + if (session.worktreePath === worktreePath && session.status === 'running') { + return session; + } + } + return undefined; + } + + /** + * Get a test session by ID + */ + getSession(sessionId: string): TestRunSession | undefined { + return this.sessions.get(sessionId); + } + + /** + * Get buffered output for a test session + */ + getSessionOutput(sessionId: string): { + success: boolean; + result?: { + sessionId: string; + output: string; + status: TestRunStatus; + startedAt: string; + finishedAt: string | null; + }; + error?: string; + } { + const session = this.sessions.get(sessionId); + + if (!session) { + return { + success: false, + error: `Test session not found: ${sessionId}`, + }; + } + + return { + success: true, + result: { + sessionId, + output: session.scrollbackBuffer, + status: session.status, + startedAt: session.startedAt.toISOString(), + finishedAt: session.finishedAt?.toISOString() || null, + }, + }; + } + + /** + * List all test sessions (optionally filter by worktree) + */ + listSessions(worktreePath?: string): { + success: boolean; + result: { + sessions: Array<{ + sessionId: string; + worktreePath: string; + command: string; + status: TestRunStatus; + testFile?: string; + startedAt: string; + finishedAt: string | null; + exitCode: number | null; + }>; + }; + } { + let sessions = Array.from(this.sessions.values()); + + if (worktreePath) { + sessions = sessions.filter((s) => s.worktreePath === worktreePath); + } + + return { + success: true, + result: { + sessions: sessions.map((s) => ({ + sessionId: s.id, + worktreePath: s.worktreePath, + command: s.command, + status: s.status, + testFile: s.testFile, + startedAt: s.startedAt.toISOString(), + finishedAt: s.finishedAt?.toISOString() || null, + exitCode: s.exitCode, + })), + }, + }; + } + + /** + * Check if a worktree has an active test run + */ + isRunning(worktreePath: string): boolean { + return this.getActiveSession(worktreePath) !== undefined; + } + + /** + * Clean up old completed sessions (keep only recent ones) + */ + cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void { + const now = Date.now(); + for (const [sessionId, session] of this.sessions.entries()) { + if (session.status !== 'running' && session.finishedAt) { + if (now - session.finishedAt.getTime() > maxAgeMs) { + this.sessions.delete(sessionId); + logger.debug(`Cleaned up old test session: ${sessionId}`); + } + } + } + } + + /** + * Cancel all running test sessions (for cleanup) + */ + async cancelAll(): Promise { + logger.info(`Cancelling all ${this.sessions.size} test sessions`); + + for (const session of this.sessions.values()) { + if (session.status === 'running') { + await this.stopTests(session.id); + } + } + } + + /** + * Cleanup service resources + */ + async cleanup(): Promise { + await this.cancelAll(); + this.sessions.clear(); + } +} + +// Singleton instance +let testRunnerServiceInstance: TestRunnerService | null = null; + +export function getTestRunnerService(): TestRunnerService { + if (!testRunnerServiceInstance) { + testRunnerServiceInstance = new TestRunnerService(); + } + return testRunnerServiceInstance; +} + +// Cleanup on process exit +process.on('SIGTERM', () => { + if (testRunnerServiceInstance) { + testRunnerServiceInstance.cleanup().catch((err) => { + logger.error('Cleanup failed on SIGTERM:', err); + }); + } +}); + +process.on('SIGINT', () => { + if (testRunnerServiceInstance) { + testRunnerServiceInstance.cleanup().catch((err) => { + logger.error('Cleanup failed on SIGINT:', err); + }); + } +}); + +// Export the class for testing purposes +export { TestRunnerService }; diff --git a/jules_branch/apps/server/src/services/typed-event-bus.ts b/jules_branch/apps/server/src/services/typed-event-bus.ts new file mode 100644 index 0000000000000000000000000000000000000000..09d1e9bc0dab05a8828a4b9476907fc84d32096c --- /dev/null +++ b/jules_branch/apps/server/src/services/typed-event-bus.ts @@ -0,0 +1,112 @@ +/** + * TypedEventBus - Type-safe event emission wrapper for AutoModeService + * + * This class wraps the existing EventEmitter to provide type-safe event emission, + * specifically encapsulating the `emitAutoModeEvent` pattern used throughout AutoModeService. + * + * Key behavior: + * - emitAutoModeEvent wraps events in 'auto-mode:event' format for frontend consumption + * - Preserves all existing event emission patterns for backward compatibility + * - Frontend receives events in the exact same format as before (no breaking changes) + */ + +import type { EventEmitter, EventType, EventCallback } from '../lib/events.js'; + +/** + * Auto-mode event types that can be emitted through the TypedEventBus. + * These correspond to the event types expected by the frontend. + */ +export type AutoModeEventType = + | 'auto_mode_started' + | 'auto_mode_stopped' + | 'auto_mode_idle' + | 'auto_mode_error' + | 'auto_mode_paused_failures' + | 'auto_mode_feature_start' + | 'auto_mode_feature_complete' + | 'auto_mode_feature_resuming' + | 'auto_mode_progress' + | 'auto_mode_tool' + | 'auto_mode_task_started' + | 'auto_mode_task_complete' + | 'auto_mode_task_status' + | 'auto_mode_phase_complete' + | 'auto_mode_summary' + | 'auto_mode_resuming_features' + | 'planning_started' + | 'plan_approval_required' + | 'plan_approved' + | 'plan_auto_approved' + | 'plan_rejected' + | 'plan_revision_requested' + | 'plan_revision_warning' + | 'plan_spec_updated' + | 'pipeline_step_started' + | 'pipeline_step_complete' + | 'pipeline_test_failed' + | 'pipeline_merge_conflict' + | 'feature_status_changed' + | 'features_reconciled'; + +/** + * TypedEventBus wraps an EventEmitter to provide type-safe event emission + * with the auto-mode event wrapping pattern. + */ +export class TypedEventBus { + private events: EventEmitter; + + /** + * Create a TypedEventBus wrapping an existing EventEmitter. + * @param events - The underlying EventEmitter to wrap + */ + constructor(events: EventEmitter) { + this.events = events; + } + + /** + * Emit a raw event directly to subscribers. + * Use this for non-auto-mode events that don't need wrapping. + * @param type - The event type + * @param payload - The event payload + */ + emit(type: EventType, payload: unknown): void { + this.events.emit(type, payload); + } + + /** + * Emit an auto-mode event wrapped in the correct format for the client. + * All auto-mode events are sent as type "auto-mode:event" with the actual + * event type and data in the payload. + * + * This produces the exact same event format that the frontend expects: + * { type: eventType, ...data } + * + * @param eventType - The auto-mode event type (e.g., 'auto_mode_started') + * @param data - Additional data to include in the event payload + */ + emitAutoModeEvent(eventType: AutoModeEventType, data: Record): void { + // Wrap the event in auto-mode:event format expected by the client + this.events.emit('auto-mode:event', { + type: eventType, + ...data, + }); + } + + /** + * Subscribe to all events from the underlying emitter. + * @param callback - Function called with (type, payload) for each event + * @returns Unsubscribe function + */ + subscribe(callback: EventCallback): () => void { + return this.events.subscribe(callback); + } + + /** + * Get the underlying EventEmitter for cases where direct access is needed. + * Use sparingly - prefer the typed methods when possible. + * @returns The wrapped EventEmitter + */ + getUnderlyingEmitter(): EventEmitter { + return this.events; + } +} diff --git a/jules_branch/apps/server/src/services/worktree-branch-service.ts b/jules_branch/apps/server/src/services/worktree-branch-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..21134212371c8693e7a842598db3d7b481762506 --- /dev/null +++ b/jules_branch/apps/server/src/services/worktree-branch-service.ts @@ -0,0 +1,406 @@ +/** + * WorktreeBranchService - Switch branch operations without HTTP + * + * Handles branch switching with automatic stash/reapply of local changes. + * If there are uncommitted changes, they are stashed before switching and + * reapplied after. If the stash pop results in merge conflicts, returns + * a special response so the UI can create a conflict resolution task. + * + * For remote branches (e.g., "origin/feature"), automatically creates a + * local tracking branch and checks it out. + * + * Fetches the latest remote refs before switching to ensure remote branch + * references are up-to-date for accurate detection and checkout. + * + * Extracted from the worktree switch-branch route to improve organization + * and testability. Follows the same pattern as pull-service.ts and + * rebase-service.ts. + */ + +import { createLogger, getErrorMessage } from '@automaker/utils'; +import { execGitCommand } from '../lib/git.js'; +import type { EventEmitter } from '../lib/events.js'; +import { hasAnyChanges, stashChanges, popStash, localBranchExists } from './branch-utils.js'; + +const logger = createLogger('WorktreeBranchService'); + +// ============================================================================ +// Types +// ============================================================================ + +export interface SwitchBranchResult { + success: boolean; + error?: string; + result?: { + previousBranch: string; + currentBranch: string; + message: string; + hasConflicts?: boolean; + stashedChanges?: boolean; + }; + /** Set when checkout fails and stash pop produced conflicts during recovery */ + stashPopConflicts?: boolean; + /** Human-readable message when stash pop conflicts occur during error recovery */ + stashPopConflictMessage?: string; +} + +// ============================================================================ +// Local Helpers +// ============================================================================ + +/** Timeout for git fetch operations (30 seconds) */ +const FETCH_TIMEOUT_MS = 30_000; + +/** + * Fetch latest from all remotes (silently, with timeout). + * + * A process-level timeout is enforced via an AbortController so that a + * slow or unresponsive remote does not block the branch-switch flow + * indefinitely. Timeout errors are logged and treated as non-fatal + * (the same as network-unavailable errors) so the rest of the workflow + * continues normally. This is called before the branch switch to + * ensure remote refs are up-to-date for branch detection and checkout. + */ +async function fetchRemotes(cwd: string): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller); + } catch (error) { + if (controller.signal.aborted) { + // Fetch timed out - log and continue; callers should not be blocked by a slow remote + logger.warn( + `fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs` + ); + } else { + logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`); + } + // Non-fatal: continue with locally available refs regardless of failure type + } finally { + clearTimeout(timerId); + } +} + +/** + * Parse a remote branch name like "origin/feature-branch" into its parts. + * Splits on the first slash so the remote is the segment before the first '/' + * and the branch is everything after it (preserving any subsequent slashes). + * For example, "origin/feature/my-branch" → { remote: "origin", branch: "feature/my-branch" }. + * Returns null if the input contains no slash. + */ +function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null { + const firstSlash = branchName.indexOf('/'); + if (firstSlash === -1) return null; + return { + remote: branchName.substring(0, firstSlash), + branch: branchName.substring(firstSlash + 1), + }; +} + +/** + * Check if a branch name refers to a remote branch + */ +async function isRemoteBranch(cwd: string, branchName: string): Promise { + try { + const stdout = await execGitCommand(['branch', '-r', '--format=%(refname:short)'], cwd); + const remoteBranches = stdout + .trim() + .split('\n') + .map((b) => b.trim().replace(/^['"]|['"]$/g, '')) + .filter((b) => b); + return remoteBranches.includes(branchName); + } catch (err) { + logger.error('isRemoteBranch: failed to list remote branches — returning false', { + branchName, + cwd, + error: getErrorMessage(err), + }); + return false; + } +} + +// ============================================================================ +// Main Service Function +// ============================================================================ + +/** + * Perform a full branch switch workflow on the given worktree. + * + * The workflow: + * 1. Fetch latest from all remotes (ensures remote refs are up-to-date) + * 2. Get current branch name + * 3. Detect remote vs local branch and determine target + * 4. Return early if already on target branch + * 5. Validate branch existence + * 6. Stash local changes if any + * 7. Checkout the target branch + * 8. Reapply stashed changes (detect conflicts) + * 9. Handle error recovery (restore stash if checkout fails) + * + * @param worktreePath - Path to the git worktree + * @param branchName - Branch to switch to (can be local or remote like "origin/feature") + * @param events - Optional event emitter for lifecycle events + * @returns SwitchBranchResult with detailed status information + */ +export async function performSwitchBranch( + worktreePath: string, + branchName: string, + events?: EventEmitter +): Promise { + // Emit start event + events?.emit('switch:start', { worktreePath, branchName }); + + // 1. Fetch latest from all remotes before switching + // This ensures remote branch refs are up-to-date so that isRemoteBranch() + // can detect newly created remote branches and local tracking branches + // are aware of upstream changes. + await fetchRemotes(worktreePath); + + // 2. Get current branch + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const previousBranch = currentBranchOutput.trim(); + + // 3. Determine the actual target branch name for checkout + let targetBranch = branchName; + let isRemote = false; + + // Check if this is a remote branch (e.g., "origin/feature-branch") + let parsedRemote: { remote: string; branch: string } | null = null; + if (await isRemoteBranch(worktreePath, branchName)) { + isRemote = true; + parsedRemote = parseRemoteBranch(branchName); + if (parsedRemote) { + targetBranch = parsedRemote.branch; + } else { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to parse remote branch name '${branchName}'`, + }); + return { + success: false, + error: `Failed to parse remote branch name '${branchName}'`, + }; + } + } + + // 4. Return early if already on the target branch + if (previousBranch === targetBranch) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + alreadyOnBranch: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Already on branch '${targetBranch}'`, + }, + }; + } + + // 5. Check if target branch exists as a local branch + if (!isRemote) { + if (!(await localBranchExists(worktreePath, branchName))) { + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Branch '${branchName}' does not exist`, + }); + return { + success: false, + error: `Branch '${branchName}' does not exist`, + }; + } + } + + // 6. Stash local changes if any exist + const hadChanges = await hasAnyChanges(worktreePath, { excludeWorktreePaths: true }); + let didStash = false; + + if (hadChanges) { + events?.emit('switch:stash', { + worktreePath, + previousBranch, + targetBranch, + action: 'push', + }); + const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`; + try { + didStash = await stashChanges(worktreePath, stashMessage, true); + } catch (stashError) { + const stashErrorMsg = getErrorMessage(stashError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: `Failed to stash local changes: ${stashErrorMsg}`, + }); + return { + success: false, + error: `Failed to stash local changes before switching branches: ${stashErrorMsg}`, + }; + } + } + + try { + // 7. Switch to the target branch + events?.emit('switch:checkout', { + worktreePath, + targetBranch, + isRemote, + previousBranch, + }); + + if (isRemote) { + if (!parsedRemote) { + throw new Error(`Failed to parse remote branch name '${branchName}'`); + } + if (await localBranchExists(worktreePath, parsedRemote.branch)) { + // Local branch exists, just checkout + await execGitCommand(['checkout', parsedRemote.branch], worktreePath); + } else { + // Create local tracking branch from remote + await execGitCommand(['checkout', '-b', parsedRemote.branch, branchName], worktreePath); + } + } else { + await execGitCommand(['checkout', targetBranch], worktreePath); + } + + // 8. Reapply stashed changes if we stashed earlier + let hasConflicts = false; + let conflictMessage = ''; + let stashReapplied = false; + + if (didStash) { + events?.emit('switch:pop', { + worktreePath, + targetBranch, + action: 'pop', + }); + + const popResult = await popStash(worktreePath); + hasConflicts = popResult.hasConflicts; + if (popResult.hasConflicts) { + conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason - the stash is still there + conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`; + } else { + stashReapplied = true; + } + } + + if (hasConflicts) { + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + hasConflicts: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: true, + stashedChanges: true, + }, + }; + } else if (didStash && !stashReapplied) { + // Stash pop failed for a non-conflict reason — stash is still present + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + stashPopFailed: true, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: conflictMessage, + hasConflicts: false, + stashedChanges: true, + }, + }; + } else { + const stashNote = stashReapplied ? ' (local changes stashed and reapplied)' : ''; + events?.emit('switch:done', { + worktreePath, + previousBranch, + currentBranch: targetBranch, + stashReapplied, + }); + return { + success: true, + result: { + previousBranch, + currentBranch: targetBranch, + message: `Switched to branch '${targetBranch}'${stashNote}`, + hasConflicts: false, + stashedChanges: stashReapplied, + }, + }; + } + } catch (checkoutError) { + // 9. Error recovery: if checkout failed and we stashed, try to restore the stash + if (didStash) { + const popResult = await popStash(worktreePath); + if (popResult.hasConflicts) { + // Stash pop itself produced merge conflicts — the working tree is now in a + // conflicted state even though the checkout failed. Surface this clearly so + // the caller can prompt the user (or AI) to resolve conflicts rather than + // simply retrying the branch switch. + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + stashPopConflicts: true, + }); + return { + success: false, + error: checkoutErrorMsg, + stashPopConflicts: true, + stashPopConflictMessage: + 'Stash pop resulted in conflicts: your stashed changes were partially reapplied ' + + 'but produced merge conflicts. Please resolve the conflicts before retrying the branch switch.', + }; + } else if (!popResult.success) { + // Stash pop failed for a non-conflict reason; the stash entry is still intact. + // Include this detail alongside the original checkout error. + const checkoutErrorMsg = getErrorMessage(checkoutError); + const combinedMessage = + `${checkoutErrorMsg}. Additionally, restoring your stashed changes failed: ` + + `${popResult.error ?? 'unknown error'} — your changes are still saved in the stash.`; + events?.emit('switch:error', { + worktreePath, + branchName, + error: combinedMessage, + }); + return { + success: false, + error: combinedMessage, + stashPopConflicts: false, + }; + } + // popResult.success === true: stash was cleanly restored, re-throw the checkout error + } + const checkoutErrorMsg = getErrorMessage(checkoutError); + events?.emit('switch:error', { + worktreePath, + branchName, + error: checkoutErrorMsg, + }); + throw checkoutError; + } +} diff --git a/jules_branch/apps/server/src/services/worktree-resolver.ts b/jules_branch/apps/server/src/services/worktree-resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..4048d1e805675ab8db65a4d93b1df22b738e3153 --- /dev/null +++ b/jules_branch/apps/server/src/services/worktree-resolver.ts @@ -0,0 +1,185 @@ +/** + * 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 { + private normalizeBranchName(branchName: string | null | undefined): string | null { + if (!branchName) return null; + let normalized = branchName.trim(); + if (!normalized) return null; + + normalized = normalized.replace(/^refs\/heads\//, ''); + normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, ''); + normalized = normalized.replace(/^(origin|upstream)\//, ''); + + return normalized || null; + } + + /** + * Get the current branch name for a git repository + * + * @param projectPath - Path to the git repository + * @returns The current branch name, or null if not in a git repo or on detached HEAD + */ + async getCurrentBranch(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git branch --show-current', { cwd: projectPath }); + const branch = stdout.trim(); + return branch || null; + } catch { + return null; + } + } + + /** + * Find an existing worktree for a given branch name + * + * @param projectPath - Path to the git repository (main worktree) + * @param branchName - Branch name to find worktree for + * @returns Absolute path to the worktree, or null if not found + */ + async findWorktreeForBranch(projectPath: string, branchName: string): Promise { + try { + const normalizedTargetBranch = this.normalizeBranchName(branchName); + if (!normalizedTargetBranch) return null; + + 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 = this.normalizeBranchName(line.slice(7)); + } else if (line === '' && currentPath && currentBranch) { + // End of a worktree entry + if (currentBranch === normalizedTargetBranch) { + // 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 === normalizedTargetBranch) { + return this.resolvePath(projectPath, currentPath); + } + + return null; + } catch { + return null; + } + } + + /** + * List all worktrees for a repository + * + * @param projectPath - Path to the git repository + * @returns Array of WorktreeInfo objects with normalized paths + */ + async listWorktrees(projectPath: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: projectPath, + }); + + const worktrees: WorktreeInfo[] = []; + const lines = stdout.split('\n'); + let currentPath: string | null = null; + let currentBranch: string | null = null; + let isFirstWorktree = true; + + for (const line of lines) { + if (line.startsWith('worktree ')) { + currentPath = line.slice(9); + } else if (line.startsWith('branch ')) { + currentBranch = this.normalizeBranchName(line.slice(7)); + } else if (line.startsWith('detached')) { + // Detached HEAD - branch is null + currentBranch = null; + } else if (line === '' && currentPath) { + // End of a worktree entry + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + currentPath = null; + currentBranch = null; + isFirstWorktree = false; + } + } + + // Handle last entry if file doesn't end with newline + if (currentPath) { + worktrees.push({ + path: this.resolvePath(projectPath, currentPath), + branch: currentBranch, + isMain: isFirstWorktree, + }); + } + + return worktrees; + } catch { + return []; + } + } + + /** + * Resolve a path to absolute, handling both relative and absolute inputs + * + * @param projectPath - Base path for relative resolution + * @param worktreePath - Path from git worktree list output + * @returns Absolute path + */ + private resolvePath(projectPath: string, worktreePath: string): string { + return path.isAbsolute(worktreePath) + ? path.resolve(worktreePath) + : path.resolve(projectPath, worktreePath); + } +} diff --git a/jules_branch/apps/server/src/services/worktree-service.ts b/jules_branch/apps/server/src/services/worktree-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cb7a2516e83fc13ba78192712aa9c42d999189d --- /dev/null +++ b/jules_branch/apps/server/src/services/worktree-service.ts @@ -0,0 +1,178 @@ +/** + * WorktreeService - File-system operations for git worktrees + * + * Extracted from the worktree create route to centralise file-copy logic, + * surface errors through an EventEmitter instead of swallowing them, and + * make the behaviour testable in isolation. + */ + +import path from 'path'; +import fs from 'fs/promises'; +import { execGitCommand } from '@automaker/git-utils'; +import type { EventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; + +/** + * Get the list of remote names that have a branch matching the given branch name. + * + * Uses `git for-each-ref` to check cached remote refs, returning the names of + * any remotes that already have a branch with the same name as `currentBranch`. + * Returns an empty array when `hasAnyRemotes` is false or when no matching + * remote refs are found. + * + * This helps the UI distinguish between "branch exists on the tracking remote" + * vs "branch was pushed to a different remote". + * + * @param worktreePath - Path to the git worktree + * @param currentBranch - Branch name to search for on remotes + * @param hasAnyRemotes - Whether the repository has any remotes configured + * @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch + */ +export async function getRemotesWithBranch( + worktreePath: string, + currentBranch: string, + hasAnyRemotes: boolean +): Promise { + if (!hasAnyRemotes) { + return []; + } + + try { + const remoteRefsOutput = await execGitCommand( + ['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`], + worktreePath + ); + + if (!remoteRefsOutput.trim()) { + return []; + } + + return remoteRefsOutput + .trim() + .split('\n') + .map((ref) => { + // Extract remote name from "remote/branch" format + const slashIdx = ref.indexOf('/'); + return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref; + }) + .filter((name) => name.length > 0); + } catch { + // Ignore errors - return empty array + return []; + } +} + +/** + * Error thrown when one or more file copy operations fail during + * `copyConfiguredFiles`. The caller can inspect `failures` for details. + */ +export class CopyFilesError extends Error { + constructor(public readonly failures: Array<{ path: string; error: string }>) { + super(`Failed to copy ${failures.length} file(s): ${failures.map((f) => f.path).join(', ')}`); + this.name = 'CopyFilesError'; + } +} + +/** + * WorktreeService encapsulates file-system operations that run against + * git worktrees (e.g. copying project-configured files into a new worktree). + * + * All operations emit typed events so the frontend can stream progress to the + * user. Errors are collected and surfaced to the caller rather than silently + * swallowed. + */ +export class WorktreeService { + /** + * Copy files / directories listed in the project's `worktreeCopyFiles` + * setting from `projectPath` into `worktreePath`. + * + * Security: paths containing `..` segments or absolute paths are rejected. + * + * Events emitted via `emitter`: + * - `worktree:copy-files:copied` – a file or directory was successfully copied + * - `worktree:copy-files:skipped` – a source file was not found (ENOENT) + * - `worktree:copy-files:failed` – an unexpected error occurred copying a file + * + * @throws {CopyFilesError} if any copy operation fails for a reason other + * than ENOENT (missing source file). + */ + async copyConfiguredFiles( + projectPath: string, + worktreePath: string, + settingsService: SettingsService | undefined, + emitter: EventEmitter + ): Promise { + if (!settingsService) return; + + const projectSettings = await settingsService.getProjectSettings(projectPath); + const copyFiles = projectSettings.worktreeCopyFiles; + + if (!copyFiles || copyFiles.length === 0) return; + + const failures: Array<{ path: string; error: string }> = []; + + for (const relativePath of copyFiles) { + // Security: prevent path traversal + const normalized = path.normalize(relativePath); + if (normalized === '' || normalized === '.') { + const reason = 'Suspicious path rejected (empty or current-dir)'; + emitter.emit('worktree:copy-files:skipped', { + path: relativePath, + reason, + }); + continue; + } + if (normalized.startsWith('..') || path.isAbsolute(normalized)) { + const reason = 'Suspicious path rejected (traversal or absolute)'; + emitter.emit('worktree:copy-files:skipped', { + path: relativePath, + reason, + }); + continue; + } + + const sourcePath = path.join(projectPath, normalized); + const destPath = path.join(worktreePath, normalized); + + try { + // Check if source exists + const stat = await fs.stat(sourcePath); + + // Ensure destination directory exists + const destDir = path.dirname(destPath); + await fs.mkdir(destDir, { recursive: true }); + + if (stat.isDirectory()) { + // Recursively copy directory + await fs.cp(sourcePath, destPath, { recursive: true, force: true }); + } else { + // Copy single file + await fs.copyFile(sourcePath, destPath); + } + + emitter.emit('worktree:copy-files:copied', { + path: normalized, + type: stat.isDirectory() ? 'directory' : 'file', + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + emitter.emit('worktree:copy-files:skipped', { + path: normalized, + reason: 'File not found in project root', + }); + } else { + const errorMessage = err instanceof Error ? err.message : String(err); + emitter.emit('worktree:copy-files:failed', { + path: normalized, + error: errorMessage, + }); + failures.push({ path: normalized, error: errorMessage }); + } + } + } + + if (failures.length > 0) { + throw new CopyFilesError(failures); + } + } +} diff --git a/jules_branch/apps/server/src/services/zai-usage-service.ts b/jules_branch/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a9d4dd865ad4c85df5a9e73db9b0edfa7df1d90 --- /dev/null +++ b/jules_branch/apps/server/src/services/zai-usage-service.ts @@ -0,0 +1,582 @@ +import { createLogger } from '@automaker/utils'; +import { createEventEmitter } from '../lib/events.js'; +import type { SettingsService } from './settings-service.js'; + +const logger = createLogger('ZaiUsage'); + +/** Default timeout for fetch requests in milliseconds */ +const FETCH_TIMEOUT_MS = 10_000; + +/** + * z.ai quota limit entry from the API + */ +export interface ZaiQuotaLimit { + limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string; + limit: number; + used: number; + remaining: number; + usedPercent: number; + nextResetTime: number; // epoch milliseconds +} + +/** + * z.ai usage details by model (for MCP tracking) + */ +export interface ZaiUsageDetail { + modelId: string; + used: number; + limit: number; +} + +/** + * z.ai plan types + */ +export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown'; + +/** + * z.ai usage data structure + */ +export interface ZaiUsageData { + quotaLimits: { + tokens?: ZaiQuotaLimit; + mcp?: ZaiQuotaLimit; + planType: ZaiPlanType; + } | null; + usageDetails?: ZaiUsageDetail[]; + lastUpdated: string; +} + +/** + * z.ai API limit entry - supports multiple field naming conventions + */ +interface ZaiApiLimit { + // Type field (z.ai uses 'type', others might use 'limitType') + type?: string; + limitType?: string; + // Limit value (z.ai uses 'usage' for total limit, others might use 'limit') + usage?: number; + limit?: number; + // Used value (z.ai uses 'currentValue', others might use 'used') + currentValue?: number; + used?: number; + // Remaining + remaining?: number; + // Percentage (z.ai uses 'percentage', others might use 'usedPercent') + percentage?: number; + usedPercent?: number; + // Reset time + nextResetTime?: number; + // Additional z.ai fields + unit?: number; + number?: number; + usageDetails?: Array<{ modelCode: string; usage: number }>; +} + +/** + * z.ai API response structure + * Flexible to handle various possible response formats + */ +interface ZaiApiResponse { + code?: number; + success?: boolean; + data?: { + limits?: ZaiApiLimit[]; + // Alternative: limits might be an object instead of array + tokensLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + timeLimit?: { + limit: number; + used: number; + remaining?: number; + usedPercent?: number; + nextResetTime?: number; + }; + // Quota-style fields + quota?: number; + quotaUsed?: number; + quotaRemaining?: number; + planName?: string; + plan?: string; + plan_type?: string; + packageName?: string; + usageDetails?: Array<{ + modelId: string; + used: number; + limit: number; + }>; + }; + // Root-level alternatives + limits?: ZaiApiLimit[]; + quota?: number; + quotaUsed?: number; + message?: string; +} + +/** Result from configure method */ +interface ConfigureResult { + success: boolean; + message: string; + isAvailable: boolean; +} + +/** Result from verifyApiKey method */ +interface VerifyResult { + success: boolean; + authenticated: boolean; + message?: string; + error?: string; +} + +/** + * z.ai Usage Service + * + * Fetches usage quota data from the z.ai API. + * Uses API token authentication stored via environment variable or settings. + */ +export class ZaiUsageService { + private apiToken: string | null = null; + private apiHost: string = 'https://api.z.ai'; + + /** + * Set the API token for authentication + */ + setApiToken(token: string): void { + this.apiToken = token; + logger.info('[setApiToken] API token configured'); + } + + /** + * Get the current API token + */ + getApiToken(): string | null { + // Priority: 1. Instance token, 2. Environment variable + return this.apiToken || process.env.Z_AI_API_KEY || null; + } + + /** + * Set the API host (for BigModel CN region support) + */ + setApiHost(host: string): void { + this.apiHost = host.startsWith('http') ? host : `https://${host}`; + logger.info(`[setApiHost] API host set to: ${this.apiHost}`); + } + + /** + * Get the API host + */ + getApiHost(): string { + // Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default + if (process.env.Z_AI_API_HOST) { + const envHost = process.env.Z_AI_API_HOST.trim(); + return envHost.startsWith('http') ? envHost : `https://${envHost}`; + } + return this.apiHost; + } + + /** + * Check if z.ai API is available (has token configured) + */ + isAvailable(): boolean { + const token = this.getApiToken(); + return Boolean(token && token.length > 0); + } + + /** + * Configure z.ai API token and host. + * Persists the token via settingsService and updates in-memory state. + */ + async configure( + options: { apiToken?: string; apiHost?: string }, + settingsService: SettingsService + ): Promise { + const emitter = createEventEmitter(); + + if (options.apiToken !== undefined) { + // Set in-memory token + this.setApiToken(options.apiToken || ''); + + // Persist to credentials + try { + await settingsService.updateCredentials({ + apiKeys: { zai: options.apiToken || '' }, + } as Parameters[0]); + logger.info('[configure] Saved z.ai API key to credentials'); + } catch (persistError) { + logger.error('[configure] Failed to persist z.ai API key:', persistError); + } + } + + if (options.apiHost) { + this.setApiHost(options.apiHost); + } + + const result: ConfigureResult = { + success: true, + message: 'z.ai configuration updated', + isAvailable: this.isAvailable(), + }; + + emitter.emit('notification:created', { + type: 'zai.configured', + success: result.success, + isAvailable: result.isAvailable, + }); + + return result; + } + + /** + * Verify an API key without storing it. + * Makes a test request to the z.ai quota URL with the given key. + */ + async verifyApiKey(apiKey: string | undefined): Promise { + const emitter = createEventEmitter(); + + if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) { + return { + success: false, + authenticated: false, + error: 'Please provide an API key to test.', + }; + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[verify] Testing API key against: ${quotaUrl}`); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + + let result: VerifyResult; + + if (response.ok) { + result = { + success: true, + authenticated: true, + message: 'Connection successful! z.ai API responded.', + }; + } else if (response.status === 401 || response.status === 403) { + result = { + success: false, + authenticated: false, + error: 'Invalid API key. Please check your key and try again.', + }; + } else { + result = { + success: false, + authenticated: false, + error: `API request failed: ${response.status} ${response.statusText}`, + }; + } + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: result.success, + authenticated: result.authenticated, + }); + + return result; + } catch (error) { + // Handle abort/timeout errors specifically + if (error instanceof Error && error.name === 'AbortError') { + const result: VerifyResult = { + success: false, + authenticated: false, + error: 'Request timed out. The z.ai API did not respond in time.', + }; + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: 'timeout', + }); + return result; + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Error verifying z.ai API key:', error); + + emitter.emit('notification:created', { + type: 'zai.verify.result', + success: false, + error: message, + }); + + return { + success: false, + authenticated: false, + error: `Network error: ${message}`, + }; + } + } + + /** + * Fetch usage data from z.ai API + */ + async fetchUsageData(): Promise { + logger.info('[fetchUsageData] Starting...'); + const emitter = createEventEmitter(); + + emitter.emit('notification:created', { type: 'zai.usage.start' }); + + const token = this.getApiToken(); + if (!token) { + logger.error('[fetchUsageData] No API token configured'); + const error = new Error( + 'z.ai API token not configured. Set Z_AI_API_KEY environment variable.' + ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); + throw error; + } + + const quotaUrl = + process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`; + + logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(quotaUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`); + throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as unknown as ZaiApiResponse; + logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2)); + + const result = this.parseApiResponse(data); + + emitter.emit('notification:created', { + type: 'zai.usage.success', + data: result, + }); + + return result; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + // Handle abort/timeout errors + if (error instanceof Error && error.name === 'AbortError') { + const timeoutError = new Error(`z.ai API request timed out after ${FETCH_TIMEOUT_MS}ms`); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: timeoutError.message, + }); + throw timeoutError; + } + + if (error instanceof Error && error.message.includes('z.ai API')) { + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: error.message, + }); + throw error; + } + + logger.error('[fetchUsageData] Failed to fetch:', error); + const fetchError = new Error( + `Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}` + ); + emitter.emit('notification:created', { + type: 'zai.usage.error', + error: fetchError.message, + }); + throw fetchError; + } + } + + /** + * Parse the z.ai API response into our data structure + * Handles multiple possible response formats from z.ai API + */ + private parseApiResponse(response: ZaiApiResponse): ZaiUsageData { + const result: ZaiUsageData = { + quotaLimits: { + planType: 'unknown', + }, + lastUpdated: new Date().toISOString(), + }; + + logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2)); + + // Try to find data - could be in response.data or at root level + let data = response.data; + + // Check for root-level limits array + if (!data && response.limits) { + logger.info('[parseApiResponse] Found limits at root level'); + data = { limits: response.limits }; + } + + // Check for root-level quota fields + if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) { + logger.info('[parseApiResponse] Found quota fields at root level'); + data = { quota: response.quota, quotaUsed: response.quotaUsed }; + } + + if (!data) { + logger.warn('[parseApiResponse] No data found in response'); + return result; + } + + logger.info('[parseApiResponse] Data keys:', Object.keys(data)); + + // Parse plan type from various possible field names + const planName = data.planName || data.plan || data.plan_type || data.packageName; + + if (planName) { + const normalizedPlan = String(planName).toLowerCase(); + if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) { + result.quotaLimits!.planType = normalizedPlan as ZaiPlanType; + } + logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`); + } + + // Parse quota limits from array format + if (data.limits && Array.isArray(data.limits)) { + logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries'); + for (const limit of data.limits) { + logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit)); + + // Handle different field naming conventions from z.ai API: + // - 'usage' is the total limit, 'currentValue' is the used amount + // - OR 'limit' is the total limit, 'used' is the used amount + const limitVal = limit.usage ?? limit.limit ?? 0; + const usedVal = limit.currentValue ?? limit.used ?? 0; + + // Get percentage from 'percentage' or 'usedPercent' field, or calculate it + const apiPercent = limit.percentage ?? limit.usedPercent; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + const usedPercent = + apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent; + + // Get limit type from 'type' or 'limitType' field + const rawLimitType = limit.type ?? limit.limitType ?? ''; + + const quotaLimit: ZaiQuotaLimit = { + limitType: rawLimitType || 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: limit.remaining ?? limitVal - usedVal, + usedPercent, + nextResetTime: limit.nextResetTime ?? 0, + }; + + // Match various possible limitType values + const limitType = String(rawLimitType).toUpperCase(); + if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') { + result.quotaLimits!.tokens = quotaLimit; + logger.info( + `[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') { + result.quotaLimits!.mcp = quotaLimit; + logger.info( + `[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)` + ); + } else { + // If limitType is unknown, use as tokens by default (first one) + if (!result.quotaLimits!.tokens) { + quotaLimit.limitType = 'TOKENS_LIMIT'; + result.quotaLimits!.tokens = quotaLimit; + logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`); + } + } + } + } + + // Parse alternative object-style limits + if (data.tokensLimit) { + const t = data.tokensLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed tokensLimit object'); + } + + if (data.timeLimit) { + const t = data.timeLimit; + const limitVal = t.limit ?? 0; + const usedVal = t.used ?? 0; + const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0; + result.quotaLimits!.mcp = { + limitType: 'TIME_LIMIT', + limit: limitVal, + used: usedVal, + remaining: t.remaining ?? limitVal - usedVal, + usedPercent: + t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent, + nextResetTime: t.nextResetTime ?? 0, + }; + logger.info('[parseApiResponse] Parsed timeLimit object'); + } + + // Parse simple quota/quotaUsed format as tokens + if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) { + const limitVal = Number(data.quota) || 0; + const usedVal = Number(data.quotaUsed) || 0; + result.quotaLimits!.tokens = { + limitType: 'TOKENS_LIMIT', + limit: limitVal, + used: usedVal, + remaining: + data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal, + usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0, + nextResetTime: 0, + }; + logger.info('[parseApiResponse] Parsed simple quota format'); + } + + // Parse usage details (MCP tracking) + if (data.usageDetails && Array.isArray(data.usageDetails)) { + result.usageDetails = data.usageDetails.map((detail) => ({ + modelId: detail.modelId, + used: detail.used, + limit: detail.limit, + })); + logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`); + } + + logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2)); + return result; + } +} diff --git a/jules_branch/apps/server/src/tests/cli-integration.test.ts b/jules_branch/apps/server/src/tests/cli-integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..695e8ea06285f20b3929cc42f084f851396cf8a7 --- /dev/null +++ b/jules_branch/apps/server/src/tests/cli-integration.test.ts @@ -0,0 +1,375 @@ +/** + * CLI Integration Tests + * + * Comprehensive tests for CLI detection, authentication, and operations + * across all providers (Claude, Codex, Cursor) + */ + +import { describe, it, expect } from 'vitest'; +import { + detectCli, + detectAllCLis, + findCommand, + getCliVersion, + getInstallInstructions, + validateCliInstallation, +} from '../lib/cli-detection.js'; +import { classifyError, getUserFriendlyErrorMessage } from '../lib/error-handler.js'; + +describe('CLI Detection Framework', () => { + describe('findCommand', () => { + it('should find existing command', async () => { + // Test with a command that should exist + const result = await findCommand(['node']); + expect(result).toBeTruthy(); + }); + + it('should return null for non-existent command', async () => { + const result = await findCommand(['nonexistent-command-12345']); + expect(result).toBeNull(); + }); + + it('should find first available command from alternatives', async () => { + const result = await findCommand(['nonexistent-command-12345', 'node']); + expect(result).toBeTruthy(); + expect(result).toContain('node'); + }); + }); + + describe('getCliVersion', () => { + it('should get version for existing command', async () => { + const version = await getCliVersion('node', ['--version'], 5000); + expect(version).toBeTruthy(); + expect(typeof version).toBe('string'); + }); + + it('should timeout for non-responsive command', async () => { + await expect(getCliVersion('sleep', ['10'], 1000)).rejects.toThrow(); + }, 15000); // Give extra time for test timeout + + it("should handle command that doesn't exist", async () => { + await expect( + getCliVersion('nonexistent-command-12345', ['--version'], 2000) + ).rejects.toThrow(); + }); + }); + + describe('getInstallInstructions', () => { + it('should return instructions for supported platforms', () => { + const claudeInstructions = getInstallInstructions('claude', 'darwin'); + expect(claudeInstructions).toContain('brew install'); + + const codexInstructions = getInstallInstructions('codex', 'linux'); + expect(codexInstructions).toContain('npm install'); + }); + + it('should handle unsupported platform', () => { + const instructions = getInstallInstructions('claude', 'unknown-platform' as NodeJS.Platform); + expect(instructions).toContain('No installation instructions available'); + }); + }); + + describe('validateCliInstallation', () => { + it('should validate properly installed CLI', () => { + const cliInfo = { + name: 'Test CLI', + command: 'node', + version: 'v18.0.0', + path: '/usr/bin/node', + installed: true, + authenticated: true, + authMethod: 'cli' as const, + }; + + const result = validateCliInstallation(cliInfo); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('should detect issues with installation', () => { + const cliInfo = { + name: 'Test CLI', + command: '', + version: '', + path: '', + installed: false, + authenticated: false, + authMethod: 'none' as const, + }; + + const result = validateCliInstallation(cliInfo); + expect(result.valid).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues).toContain('CLI is not installed'); + }); + }); +}); + +describe('Error Handling System', () => { + describe('classifyError', () => { + it('should classify authentication errors', () => { + const authError = new Error('invalid_api_key: Your API key is invalid'); + const result = classifyError(authError, 'claude'); + + expect(result.type).toBe('authentication'); + expect(result.severity).toBe('high'); + expect(result.userMessage).toContain('Authentication failed'); + expect(result.retryable).toBe(false); + expect(result.provider).toBe('claude'); + }); + + it('should classify billing errors', () => { + const billingError = new Error('credit balance is too low'); + const result = classifyError(billingError); + + expect(result.type).toBe('billing'); + expect(result.severity).toBe('high'); + expect(result.userMessage).toContain('insufficient credits'); + expect(result.retryable).toBe(false); + }); + + it('should classify rate limit errors', () => { + const rateLimitError = new Error('Rate limit reached. Try again later.'); + const result = classifyError(rateLimitError); + + expect(result.type).toBe('rate_limit'); + expect(result.severity).toBe('medium'); + expect(result.userMessage).toContain('Rate limit reached'); + expect(result.retryable).toBe(true); + }); + + it('should classify network errors', () => { + const networkError = new Error('ECONNREFUSED: Connection refused'); + const result = classifyError(networkError); + + expect(result.type).toBe('network'); + expect(result.severity).toBe('medium'); + expect(result.userMessage).toContain('Network connection issue'); + expect(result.retryable).toBe(true); + }); + + it('should handle unknown errors', () => { + const unknownError = new Error('Something completely unexpected happened'); + const result = classifyError(unknownError); + + expect(result.type).toBe('unknown'); + expect(result.severity).toBe('medium'); + expect(result.userMessage).toContain('unexpected error'); + expect(result.retryable).toBe(true); + }); + }); + + describe('getUserFriendlyErrorMessage', () => { + it('should include provider name in message', () => { + const error = new Error('invalid_api_key'); + const message = getUserFriendlyErrorMessage(error, 'claude'); + + expect(message).toContain('[CLAUDE]'); + }); + + it('should include suggested action when available', () => { + const error = new Error('invalid_api_key'); + const message = getUserFriendlyErrorMessage(error); + + expect(message).toContain('Verify your API key'); + }); + }); +}); + +describe('Provider-Specific Tests', () => { + describe('Claude CLI Detection', () => { + it('should detect Claude CLI if installed', async () => { + const result = await detectCli('claude'); + + if (result.detected) { + expect(result.cli.name).toBe('Claude CLI'); + expect(result.cli.installed).toBe(true); + expect(result.cli.command).toBeTruthy(); + } + // If not installed, that's also a valid test result + }); + + it('should handle missing Claude CLI gracefully', async () => { + // This test will pass regardless of whether Claude is installed + const result = await detectCli('claude'); + expect(typeof result.detected).toBe('boolean'); + expect(Array.isArray(result.issues)).toBe(true); + }); + }); + + describe('Codex CLI Detection', () => { + it('should detect Codex CLI if installed', async () => { + const result = await detectCli('codex'); + + if (result.detected) { + expect(result.cli.name).toBe('Codex CLI'); + expect(result.cli.installed).toBe(true); + expect(result.cli.command).toBeTruthy(); + } + }); + }); + + describe('Cursor CLI Detection', () => { + it('should detect Cursor CLI if installed', async () => { + const result = await detectCli('cursor'); + + if (result.detected) { + expect(result.cli.name).toBe('Cursor CLI'); + expect(result.cli.installed).toBe(true); + expect(result.cli.command).toBeTruthy(); + } + }); + }); +}); + +describe('Integration Tests', () => { + describe('detectAllCLis', () => { + it('should detect all available CLIs', async () => { + const results = await detectAllCLis(); + + expect(results).toHaveProperty('claude'); + expect(results).toHaveProperty('codex'); + expect(results).toHaveProperty('cursor'); + + // Each should have the expected structure + Object.values(results).forEach((result) => { + expect(result).toHaveProperty('cli'); + expect(result).toHaveProperty('detected'); + expect(result).toHaveProperty('issues'); + expect(result.cli).toHaveProperty('name'); + expect(result.cli).toHaveProperty('installed'); + expect(result.cli).toHaveProperty('authenticated'); + }); + }, 30000); // Longer timeout for CLI detection + + it('should handle concurrent CLI detection', async () => { + // Run detection multiple times concurrently + const promises = [detectAllCLis(), detectAllCLis(), detectAllCLis()]; + + const results = await Promise.all(promises); + + // All should return consistent results + expect(results).toHaveLength(3); + results.forEach((result) => { + expect(result).toHaveProperty('claude'); + expect(result).toHaveProperty('codex'); + expect(result).toHaveProperty('cursor'); + }); + }, 45000); + }); +}); + +describe('Error Recovery Tests', () => { + it('should handle partial CLI detection failures', async () => { + // Mock a scenario where some CLIs fail to detect + const results = await detectAllCLis(); + + // Should still return results for all providers + expect(results).toHaveProperty('claude'); + expect(results).toHaveProperty('codex'); + expect(results).toHaveProperty('cursor'); + + // Should provide error information for failures + Object.entries(results).forEach(([_provider, result]) => { + if (!result.detected && result.issues.length > 0) { + expect(result.issues.length).toBeGreaterThan(0); + expect(result.issues[0]).toBeTruthy(); + } + }); + }); + + it('should handle timeout during CLI detection', async () => { + // Test with very short timeout + const result = await detectCli('claude', { timeout: 1 }); + + // Should handle gracefully without throwing + expect(typeof result.detected).toBe('boolean'); + expect(Array.isArray(result.issues)).toBe(true); + }); +}); + +describe('Security Tests', () => { + it('should not expose sensitive information in error messages', () => { + const errorWithKey = new Error('invalid_api_key: sk-ant-abc123secret456'); + const message = getUserFriendlyErrorMessage(errorWithKey); + + // Should not expose the actual API key + expect(message).not.toContain('sk-ant-abc123secret456'); + expect(message).toContain('Authentication failed'); + }); + + it('should sanitize file paths in error messages', () => { + const errorWithPath = new Error('Permission denied: /home/user/.ssh/id_rsa'); + const message = getUserFriendlyErrorMessage(errorWithPath); + + // Should not expose sensitive file paths + expect(message).not.toContain('/home/user/.ssh/id_rsa'); + }); +}); + +// Performance Tests +describe('Performance Tests', () => { + it('should detect CLIs within reasonable time', async () => { + const startTime = Date.now(); + const results = await detectAllCLis(); + const endTime = Date.now(); + + const duration = endTime - startTime; + expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds + expect(results).toHaveProperty('claude'); + expect(results).toHaveProperty('codex'); + expect(results).toHaveProperty('cursor'); + }, 15000); + + it('should handle rapid repeated calls', async () => { + // Make multiple rapid calls + const promises = Array.from({ length: 10 }, () => detectAllCLis()); + const results = await Promise.all(promises); + + // All should complete successfully + expect(results).toHaveLength(10); + results.forEach((result) => { + expect(result).toHaveProperty('claude'); + expect(result).toHaveProperty('codex'); + expect(result).toHaveProperty('cursor'); + }); + }, 60000); +}); + +// Edge Cases +describe('Edge Cases', () => { + it('should handle empty CLI names', async () => { + await expect(detectCli('' as unknown as Parameters[0])).rejects.toThrow(); + }); + + it('should handle null CLI names', async () => { + await expect(detectCli(null as unknown as Parameters[0])).rejects.toThrow(); + }); + + it('should handle undefined CLI names', async () => { + await expect( + detectCli(undefined as unknown as Parameters[0]) + ).rejects.toThrow(); + }); + + it('should handle malformed error objects', () => { + const testCases = [ + null, + undefined, + '', + 123, + [], + { nested: { error: { message: 'test' } } }, + { error: 'simple string error' }, + ]; + + testCases.forEach((error) => { + expect(() => { + const result = classifyError(error); + expect(result).toHaveProperty('type'); + expect(result).toHaveProperty('severity'); + expect(result).toHaveProperty('userMessage'); + }).not.toThrow(); + }); + }); +}); diff --git a/jules_branch/apps/server/src/types/settings.ts b/jules_branch/apps/server/src/types/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..30ce17220b4665b19ff26b9fc2d2ed112253ff85 --- /dev/null +++ b/jules_branch/apps/server/src/types/settings.ts @@ -0,0 +1,49 @@ +/** + * Settings Types - Re-exported from @automaker/types + * + * This file now re-exports settings types from the shared @automaker/types package + * to maintain backward compatibility with existing imports in the server codebase. + */ + +export type { + ThemeMode, + ModelAlias, + PlanningMode, + ThinkingLevel, + ModelProvider, + KeyboardShortcuts, + ProjectRef, + TrashedProjectRef, + ChatSessionRef, + GlobalSettings, + Credentials, + BoardBackgroundSettings, + WorktreeInfo, + ProjectSettings, + PhaseModelConfig, + PhaseModelKey, + PhaseModelEntry, + FeatureTemplate, + // Claude-compatible provider types + ApiKeySource, + ClaudeCompatibleProviderType, + ClaudeModelAlias, + ProviderModel, + ClaudeCompatibleProvider, + ClaudeCompatibleProviderTemplate, + // Legacy profile types (deprecated) + ClaudeApiProfile, + ClaudeApiProfileTemplate, +} from '@automaker/types'; + +export { + DEFAULT_KEYBOARD_SHORTCUTS, + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_CREDENTIALS, + DEFAULT_PROJECT_SETTINGS, + DEFAULT_PHASE_MODELS, + DEFAULT_FEATURE_TEMPLATES, + SETTINGS_VERSION, + CREDENTIALS_VERSION, + PROJECT_SETTINGS_VERSION, +} from '@automaker/types'; diff --git a/jules_branch/apps/server/test/git-log-parser.test.js b/jules_branch/apps/server/test/git-log-parser.test.js new file mode 100644 index 0000000000000000000000000000000000000000..c81c49584b24da8dcbee1c97992f361ac6c5e2ba --- /dev/null +++ b/jules_branch/apps/server/test/git-log-parser.test.js @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { parseGitLogOutput } from '../src/lib/git-log-parser.js'; + +// Mock data: fields within each commit are newline-separated, +// commits are NUL-separated (matching the parser contract). +const mockGitOutput = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Mock data where commit bodies contain ---END--- markers +const mockOutputWithEndMarker = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Single-commit mock: fields newline-separated, no trailing NUL needed +const singleCommitOutput = + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body'; + +describe('parseGitLogOutput', () => { + describe('normal parsing (three commits)', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is the commit body'); + }); + + it('parses the second commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv'); + expect(commits[1].shortHash).toBe('e5f6g7'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl'); + expect(commits[2].shortHash).toBe('q1w2e3'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('parsing with ---END--- in commit messages', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits.length).toBe(3); + }); + + it('preserves ---END--- text in the body of the first commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toMatch(/---END---/); + }); + + it('preserves ---END--- text in the body of the second commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit without ---END--- interference', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('empty output', () => { + it('returns an empty array for an empty string', () => { + const commits = parseGitLogOutput(''); + expect(commits).toEqual([]); + expect(commits.length).toBe(0); + }); + }); + + describe('single-commit output', () => { + it('returns exactly one commit', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits.length).toBe(1); + }); + + it('parses the single commit fields correctly', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Single commit'); + expect(commits[0].body).toBe('Single commit body'); + }); + }); + + describe('multi-line commit body', () => { + // Test vector from test-proper-nul-format.js: commit with a 3-line body + const multiLineBodyOutput = + [ + 'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body', + 'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message', + 'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line', + ].join('\0') + '\0'; + + it('returns 3 commits', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].shortHash).toBe('abc1'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is a normal commit body'); + }); + + it('parses the second commit with ---END--- in body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[1].hash).toBe('def456'); + expect(commits[1].shortHash).toBe('def4'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toContain('---END---'); + }); + + it('parses the third commit with a multi-line body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[2].hash).toBe('ghi789'); + expect(commits[2].shortHash).toBe('ghi7'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line'); + }); + }); + + describe('commit with empty body (trailing blank lines after subject)', () => { + // Test vector from test-proper-nul-format.js: empty body commit + const emptyBodyOutput = + 'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0'; + + it('returns 1 commit', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits.length).toBe(1); + }); + + it('parses the commit subject correctly', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].hash).toBe('empty123'); + expect(commits[0].shortHash).toBe('empty1'); + expect(commits[0].author).toBe('Alice Brown'); + expect(commits[0].subject).toBe('Empty body commit'); + }); + + it('produces an empty body string when only blank lines follow the subject', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].body).toBe(''); + }); + }); + + describe('leading empty lines in a commit block', () => { + // Blocks that start with blank lines before the hash field + const outputWithLeadingBlanks = + '\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here'; + + it('returns 1 commit despite leading blank lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits.length).toBe(1); + }); + + it('parses the commit fields correctly when block has leading empty lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].subject).toBe('Subject here'); + expect(commits[0].body).toBe('Body here'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/fixtures/configs.ts b/jules_branch/apps/server/tests/fixtures/configs.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cf348e3fd59246dfc8ec01e0532417e92ecd5b4 --- /dev/null +++ b/jules_branch/apps/server/tests/fixtures/configs.ts @@ -0,0 +1,17 @@ +/** + * Configuration fixtures for testing + */ + +export const tomlConfigFixture = ` +experimental_use_rmcp_client = true + +[mcp_servers.automaker-tools] +command = "node" +args = ["/path/to/server.js"] +startup_timeout_sec = 10 +tool_timeout_sec = 60 +enabled_tools = ["UpdateFeatureStatus"] + +[mcp_servers.automaker-tools.env] +AUTOMAKER_PROJECT_PATH = "/path/to/project" +`; diff --git a/jules_branch/apps/server/tests/fixtures/images.ts b/jules_branch/apps/server/tests/fixtures/images.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7e768c6346d5bdcf594f597ccf23af4757fe115 --- /dev/null +++ b/jules_branch/apps/server/tests/fixtures/images.ts @@ -0,0 +1,14 @@ +/** + * Image fixtures for testing image handling + */ + +// 1x1 transparent PNG base64 data +export const pngBase64Fixture = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +export const imageDataFixture = { + base64: pngBase64Fixture, + mimeType: 'image/png', + filename: 'test.png', + originalPath: '/path/to/test.png', +}; diff --git a/jules_branch/apps/server/tests/fixtures/messages.ts b/jules_branch/apps/server/tests/fixtures/messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..56eb75f79b57a2c3761b4a85d840197a4f444ba2 --- /dev/null +++ b/jules_branch/apps/server/tests/fixtures/messages.ts @@ -0,0 +1,34 @@ +/** + * Message fixtures for testing providers and lib utilities + */ + +import type { ConversationMessage, ProviderMessage, ContentBlock } from '@automaker/types'; + +export const conversationHistoryFixture: ConversationMessage[] = [ + { + role: 'user', + content: 'Hello, can you help me?', + }, + { + role: 'assistant', + content: 'Of course! How can I assist you today?', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'base64data' }, + }, + ], + }, +]; + +export const claudeProviderMessageFixture: ProviderMessage = { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'This is a test response' }], + }, +}; diff --git a/jules_branch/apps/server/tests/integration/helpers/git-test-repo.ts b/jules_branch/apps/server/tests/integration/helpers/git-test-repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..de5e421569473626eae21e66a9916a05cb7a9ad6 --- /dev/null +++ b/jules_branch/apps/server/tests/integration/helpers/git-test-repo.ts @@ -0,0 +1,140 @@ +/** + * Helper for creating test git repositories for integration tests + */ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +const execAsync = promisify(exec); + +export interface TestRepo { + path: string; + cleanup: () => Promise; +} + +/** + * Create a temporary git repository for testing + */ +export async function createTestGitRepo(): Promise { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-')); + + // 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 + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'Test User', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test User', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; + + // Create initial commit + await fs.writeFile(path.join(tmpDir, 'README.md'), '# Test Project\n'); + await execAsync('git add .', { cwd: tmpDir, env: gitEnv }); + await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv }); + + return { + path: tmpDir, + cleanup: async () => { + try { + // Remove all worktrees first + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: tmpDir, + }).catch(() => ({ stdout: '' })); + + const worktrees = stdout + .split('\n\n') + .slice(1) // Skip main worktree + .map((block) => { + const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')); + return pathLine ? pathLine.replace('worktree ', '') : null; + }) + .filter(Boolean); + + for (const worktreePath of worktrees) { + try { + await execAsync(`git worktree remove "${worktreePath}" --force`, { + cwd: tmpDir, + }); + } catch (err) { + // Ignore errors + } + } + + // Remove the repository + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch (error) { + console.error('Failed to cleanup test repo:', error); + } + }, + }; +} + +/** + * Create a feature file in the test repo + */ +export async function createTestFeature( + repoPath: string, + featureId: string, + featureData: any +): Promise { + const featuresDir = path.join(repoPath, '.automaker', 'features'); + const featureDir = path.join(featuresDir, featureId); + + await fs.mkdir(featureDir, { recursive: true }); + await fs.writeFile(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2)); +} + +/** + * Get list of git branches + */ +export async function listBranches(repoPath: string): Promise { + const { stdout } = await execAsync('git branch --list', { cwd: repoPath }); + return stdout + .split('\n') + .map((line) => line.trim().replace(/^[*+]\s*/, '')) + .filter(Boolean); +} + +/** + * Get list of git worktrees + */ +export async function listWorktrees(repoPath: string): Promise { + try { + const { stdout } = await execAsync('git worktree list --porcelain', { + cwd: repoPath, + }); + + return stdout + .split('\n\n') + .slice(1) // Skip main worktree + .map((block) => { + const pathLine = block.split('\n').find((line) => line.startsWith('worktree ')); + return pathLine ? pathLine.replace('worktree ', '') : null; + }) + .filter(Boolean) as string[]; + } catch { + return []; + } +} + +/** + * Check if a branch exists + */ +export async function branchExists(repoPath: string, branchName: string): Promise { + const branches = await listBranches(repoPath); + return branches.includes(branchName); +} + +/** + * Check if a worktree exists + */ +export async function worktreeExists(repoPath: string, worktreePath: string): Promise { + const worktrees = await listWorktrees(repoPath); + return worktrees.some((wt) => wt === worktreePath); +} diff --git a/jules_branch/apps/server/tests/integration/routes/worktree/create.integration.test.ts b/jules_branch/apps/server/tests/integration/routes/worktree/create.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a751706b6d68d88f51e1f99e77aeeb45aa283904 --- /dev/null +++ b/jules_branch/apps/server/tests/integration/routes/worktree/create.integration.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createCreateHandler } from '@/routes/worktree/routes/create.js'; +import { AUTOMAKER_INITIAL_COMMIT_MESSAGE } from '@/routes/worktree/common.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +const execAsync = promisify(exec); + +describe('worktree create route - repositories without commits', () => { + let repoPath: string | null = null; + + async function initRepoWithoutCommit() { + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-')); + // 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 + } + + afterEach(async () => { + if (!repoPath) { + return; + } + await fs.rm(repoPath, { recursive: true, force: true }); + repoPath = null; + }); + + it('creates an initial commit before adding a worktree when HEAD is missing', async () => { + await initRepoWithoutCommit(); + const handler = createCreateHandler(); + + const json = vi.fn(); + const status = vi.fn().mockReturnThis(); + const req = { + body: { projectPath: repoPath, branchName: 'feature/no-head' }, + } as any; + const res = { + json, + status, + } as any; + + await handler(req, res); + + expect(status).not.toHaveBeenCalled(); + expect(json).toHaveBeenCalled(); + const payload = json.mock.calls[0][0]; + expect(payload.success).toBe(true); + + const { stdout: commitCount } = await execAsync('git rev-list --count HEAD', { + cwd: repoPath!, + }); + expect(Number(commitCount.trim())).toBeGreaterThan(0); + + const { stdout: latestMessage } = await execAsync('git log -1 --pretty=%B', { cwd: repoPath! }); + expect(latestMessage.trim()).toBe(AUTOMAKER_INITIAL_COMMIT_MESSAGE); + }); +}); diff --git a/jules_branch/apps/server/tests/setup.ts b/jules_branch/apps/server/tests/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..15ecc9dca741e86d005098fed35add1f9fac8e56 --- /dev/null +++ b/jules_branch/apps/server/tests/setup.ts @@ -0,0 +1,15 @@ +/** + * Vitest global setup file + * Runs before each test file + */ + +import { vi, beforeEach } from 'vitest'; + +// Set test environment variables +process.env.NODE_ENV = 'test'; +process.env.DATA_DIR = '/tmp/test-data'; + +// Reset all mocks before each test +beforeEach(() => { + vi.clearAllMocks(); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/app-spec-format.test.ts b/jules_branch/apps/server/tests/unit/lib/app-spec-format.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eef788140dcad38e6f180a596729d0fe26d11ec0 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/app-spec-format.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { + specToXml, + getStructuredSpecPromptInstruction, + getAppSpecFormatInstruction, + APP_SPEC_XML_FORMAT, + type SpecOutput, +} from '@/lib/app-spec-format.js'; + +describe('app-spec-format.ts', () => { + describe('specToXml', () => { + it('should convert minimal spec to XML', () => { + const spec: SpecOutput = { + project_name: 'Test Project', + overview: 'A test project', + technology_stack: ['TypeScript', 'Node.js'], + core_capabilities: ['Testing', 'Development'], + implemented_features: [{ name: 'Feature 1', description: 'First feature' }], + }; + + const xml = specToXml(spec); + + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain(''); + expect(xml).toContain('Test Project'); + expect(xml).toContain('TypeScript'); + expect(xml).toContain('Testing'); + }); + + it('should escape XML special characters', () => { + const spec: SpecOutput = { + project_name: 'Test & Project', + overview: 'Description with ', + technology_stack: ['TypeScript'], + core_capabilities: ['Cap'], + implemented_features: [], + }; + + const xml = specToXml(spec); + + expect(xml).toContain('Test & Project'); + expect(xml).toContain('<tags>'); + }); + + it('should include file_locations when provided', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [ + { + name: 'Feature', + description: 'Desc', + file_locations: ['src/index.ts'], + }, + ], + }; + + const xml = specToXml(spec); + + expect(xml).toContain(''); + expect(xml).toContain('src/index.ts'); + }); + + it('should not include file_locations when empty', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [{ name: 'Feature', description: 'Desc', file_locations: [] }], + }; + + const xml = specToXml(spec); + + expect(xml).not.toContain(''); + }); + + it('should include additional_requirements when provided', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [], + additional_requirements: ['Node.js 18+'], + }; + + const xml = specToXml(spec); + + expect(xml).toContain(''); + expect(xml).toContain('Node.js 18+'); + }); + + it('should include development_guidelines when provided', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [], + development_guidelines: ['Use ESLint'], + }; + + const xml = specToXml(spec); + + expect(xml).toContain(''); + expect(xml).toContain('Use ESLint'); + }); + + it('should include implementation_roadmap when provided', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [], + implementation_roadmap: [{ phase: 'Phase 1', status: 'completed', description: 'Setup' }], + }; + + const xml = specToXml(spec); + + expect(xml).toContain(''); + expect(xml).toContain('completed'); + }); + + it('should not include optional sections when empty', () => { + const spec: SpecOutput = { + project_name: 'Test', + overview: 'Test', + technology_stack: ['TS'], + core_capabilities: ['Cap'], + implemented_features: [], + additional_requirements: [], + development_guidelines: [], + implementation_roadmap: [], + }; + + const xml = specToXml(spec); + + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + expect(xml).not.toContain(''); + }); + }); + + describe('getStructuredSpecPromptInstruction', () => { + it('should return non-empty prompt instruction', () => { + const instruction = getStructuredSpecPromptInstruction(); + expect(instruction).toBeTruthy(); + expect(instruction.length).toBeGreaterThan(100); + }); + + it('should mention required fields', () => { + const instruction = getStructuredSpecPromptInstruction(); + expect(instruction).toContain('project_name'); + expect(instruction).toContain('overview'); + expect(instruction).toContain('technology_stack'); + }); + }); + + describe('getAppSpecFormatInstruction', () => { + it('should return non-empty format instruction', () => { + const instruction = getAppSpecFormatInstruction(); + expect(instruction).toBeTruthy(); + expect(instruction.length).toBeGreaterThan(100); + }); + + it('should include critical formatting requirements', () => { + const instruction = getAppSpecFormatInstruction(); + expect(instruction).toContain('CRITICAL FORMATTING REQUIREMENTS'); + }); + }); + + describe('APP_SPEC_XML_FORMAT', () => { + it('should contain valid XML template structure', () => { + expect(APP_SPEC_XML_FORMAT).toContain(''); + expect(APP_SPEC_XML_FORMAT).toContain(''); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/auth.test.ts b/jules_branch/apps/server/tests/unit/lib/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8708062f6ddf72ca5dc1ef090e99e19365658fb8 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/auth.test.ts @@ -0,0 +1,399 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createMockExpressContext } from '../../utils/mocks.js'; +import fs from 'fs'; +import path from 'path'; + +/** + * Note: auth.ts reads AUTOMAKER_API_KEY at module load time. + * We need to reset modules and reimport for each test to get fresh state. + */ +describe('auth.ts', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.AUTOMAKER_API_KEY; + delete process.env.AUTOMAKER_HIDE_API_KEY; + delete process.env.NODE_ENV; + }); + + describe('authMiddleware', () => { + it('should reject request without any authentication', async () => { + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + + authMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Authentication required.', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request with invalid API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.headers['x-api-key'] = 'wrong-key'; + + authMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid API key.', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should call next() with valid API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.headers['x-api-key'] = 'test-secret-key'; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should authenticate with session token in header', async () => { + const { authMiddleware, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + const { req, res, next } = createMockExpressContext(); + req.headers['x-session-token'] = token; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should reject invalid session token in header', async () => { + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.headers['x-session-token'] = 'invalid-token'; + + authMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid or expired session token.', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should authenticate with API key in query parameter', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { authMiddleware } = await import('@/lib/auth.js'); + const { req, res, next } = createMockExpressContext(); + req.query.apiKey = 'test-secret-key'; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should authenticate with session cookie', async () => { + const { authMiddleware, createSession, getSessionCookieName } = await import('@/lib/auth.js'); + const token = await createSession(); + const cookieName = getSessionCookieName(); + const { req, res, next } = createMockExpressContext(); + req.cookies = { [cookieName]: token }; + + authMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('createSession', () => { + it('should create a new session and return token', async () => { + const { createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + it('should create unique tokens for each session', async () => { + const { createSession } = await import('@/lib/auth.js'); + const token1 = await createSession(); + const token2 = await createSession(); + + expect(token1).not.toBe(token2); + }); + }); + + describe('validateSession', () => { + it('should validate a valid session token', async () => { + const { createSession, validateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(validateSession(token)).toBe(true); + }); + + it('should reject invalid session token', async () => { + const { validateSession } = await import('@/lib/auth.js'); + + expect(validateSession('invalid-token')).toBe(false); + }); + + it('should reject expired session token', async () => { + vi.useFakeTimers(); + const { createSession, validateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + // Advance time past session expiration (30 days) + vi.advanceTimersByTime(31 * 24 * 60 * 60 * 1000); + + expect(validateSession(token)).toBe(false); + vi.useRealTimers(); + }); + }); + + describe('invalidateSession', () => { + it('should invalidate a session token', async () => { + const { createSession, validateSession, invalidateSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(validateSession(token)).toBe(true); + await invalidateSession(token); + expect(validateSession(token)).toBe(false); + }); + }); + + describe('createWsConnectionToken', () => { + it('should create a WebSocket connection token', async () => { + const { createWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + it('should create unique tokens', async () => { + const { createWsConnectionToken } = await import('@/lib/auth.js'); + const token1 = createWsConnectionToken(); + const token2 = createWsConnectionToken(); + + expect(token1).not.toBe(token2); + }); + }); + + describe('validateWsConnectionToken', () => { + it('should validate a valid WebSocket token', async () => { + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(validateWsConnectionToken(token)).toBe(true); + }); + + it('should reject invalid WebSocket token', async () => { + const { validateWsConnectionToken } = await import('@/lib/auth.js'); + + expect(validateWsConnectionToken('invalid-token')).toBe(false); + }); + + it('should reject expired WebSocket token', async () => { + vi.useFakeTimers(); + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + // Advance time past token expiration (5 minutes) + vi.advanceTimersByTime(6 * 60 * 1000); + + expect(validateWsConnectionToken(token)).toBe(false); + vi.useRealTimers(); + }); + + it('should invalidate token after first use (single-use)', async () => { + const { createWsConnectionToken, validateWsConnectionToken } = await import('@/lib/auth.js'); + const token = createWsConnectionToken(); + + expect(validateWsConnectionToken(token)).toBe(true); + // Token should be deleted after first use + expect(validateWsConnectionToken(token)).toBe(false); + }); + }); + + describe('validateApiKey', () => { + it('should validate correct API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('test-secret-key')).toBe(true); + }); + + it('should reject incorrect API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('wrong-key')).toBe(false); + }); + + it('should reject empty string', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey('')).toBe(false); + }); + + it('should reject null/undefined', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + expect(validateApiKey(null as any)).toBe(false); + expect(validateApiKey(undefined as any)).toBe(false); + }); + + it('should use timing-safe comparison for different lengths', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { validateApiKey } = await import('@/lib/auth.js'); + + // Key with different length should be rejected without timing leak + expect(validateApiKey('short')).toBe(false); + expect(validateApiKey('very-long-key-that-does-not-match')).toBe(false); + }); + }); + + describe('getSessionCookieOptions', () => { + it('should return cookie options with httpOnly true', async () => { + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.httpOnly).toBe(true); + expect(options.sameSite).toBe('lax'); + expect(options.path).toBe('/'); + expect(options.maxAge).toBeGreaterThan(0); + }); + + it('should set secure to true in production', async () => { + process.env.NODE_ENV = 'production'; + + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.secure).toBe(true); + }); + + it('should set secure to false in non-production', async () => { + process.env.NODE_ENV = 'development'; + + const { getSessionCookieOptions } = await import('@/lib/auth.js'); + const options = getSessionCookieOptions(); + + expect(options.secure).toBe(false); + }); + }); + + describe('getSessionCookieName', () => { + it('should return the session cookie name', async () => { + const { getSessionCookieName } = await import('@/lib/auth.js'); + const name = getSessionCookieName(); + + expect(name).toBe('automaker_session'); + }); + }); + + describe('isRequestAuthenticated', () => { + it('should return true for authenticated request with API key', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { isRequestAuthenticated } = await import('@/lib/auth.js'); + const { req } = createMockExpressContext(); + req.headers['x-api-key'] = 'test-secret-key'; + + expect(isRequestAuthenticated(req)).toBe(true); + }); + + it('should return false for unauthenticated request', async () => { + const { isRequestAuthenticated } = await import('@/lib/auth.js'); + const { req } = createMockExpressContext(); + + expect(isRequestAuthenticated(req)).toBe(false); + }); + + it('should return true for authenticated request with session token', async () => { + const { isRequestAuthenticated, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + const { req } = createMockExpressContext(); + req.headers['x-session-token'] = token; + + expect(isRequestAuthenticated(req)).toBe(true); + }); + }); + + describe('checkRawAuthentication', () => { + it('should return true for valid API key in headers', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({ 'x-api-key': 'test-secret-key' }, {}, {})).toBe(true); + }); + + it('should return true for valid session token in headers', async () => { + const { checkRawAuthentication, createSession } = await import('@/lib/auth.js'); + const token = await createSession(); + + expect(checkRawAuthentication({ 'x-session-token': token }, {}, {})).toBe(true); + }); + + it('should return true for valid API key in query', async () => { + process.env.AUTOMAKER_API_KEY = 'test-secret-key'; + + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({}, { apiKey: 'test-secret-key' }, {})).toBe(true); + }); + + it('should return true for valid session cookie', async () => { + const { checkRawAuthentication, createSession, getSessionCookieName } = + await import('@/lib/auth.js'); + const token = await createSession(); + const cookieName = getSessionCookieName(); + + expect(checkRawAuthentication({}, {}, { [cookieName]: token })).toBe(true); + }); + + it('should return false for invalid credentials', async () => { + const { checkRawAuthentication } = await import('@/lib/auth.js'); + + expect(checkRawAuthentication({}, {}, {})).toBe(false); + }); + }); + + describe('isAuthEnabled', () => { + it('should always return true (auth is always required)', async () => { + const { isAuthEnabled } = await import('@/lib/auth.js'); + expect(isAuthEnabled()).toBe(true); + }); + }); + + describe('getAuthStatus', () => { + it('should return enabled status with api_key_or_session method', async () => { + const { getAuthStatus } = await import('@/lib/auth.js'); + const status = getAuthStatus(); + + expect(status).toEqual({ + enabled: true, + method: 'api_key_or_session', + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/automaker-paths.test.ts b/jules_branch/apps/server/tests/unit/lib/automaker-paths.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..09042ca03990340404b531ef77709e0076de8232 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/automaker-paths.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import os from 'os'; +import { + getAutomakerDir, + getFeaturesDir, + getFeatureDir, + getFeatureImagesDir, + getBoardDir, + getImagesDir, + getWorktreesDir, + getAppSpecPath, + getBranchTrackingPath, + ensureAutomakerDir, + getGlobalSettingsPath, + getCredentialsPath, + getProjectSettingsPath, + ensureDataDir, +} from '@automaker/platform'; + +describe('automaker-paths.ts', () => { + const projectPath = path.join('/test', 'project'); + + describe('getAutomakerDir', () => { + it('should return path to .automaker directory', () => { + expect(getAutomakerDir(projectPath)).toBe(path.join(projectPath, '.automaker')); + }); + + it('should handle paths with trailing slashes', () => { + const pathWithSlash = path.join('/test', 'project') + path.sep; + expect(getAutomakerDir(pathWithSlash)).toBe(path.join(pathWithSlash, '.automaker')); + }); + }); + + describe('getFeaturesDir', () => { + it('should return path to features directory', () => { + expect(getFeaturesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'features')); + }); + }); + + describe('getFeatureDir', () => { + it('should return path to specific feature directory', () => { + expect(getFeatureDir(projectPath, 'feature-123')).toBe( + path.join(projectPath, '.automaker', 'features', 'feature-123') + ); + }); + + it('should handle feature IDs with special characters', () => { + expect(getFeatureDir(projectPath, 'my-feature_v2')).toBe( + path.join(projectPath, '.automaker', 'features', 'my-feature_v2') + ); + }); + }); + + describe('getFeatureImagesDir', () => { + it('should return path to feature images directory', () => { + expect(getFeatureImagesDir(projectPath, 'feature-123')).toBe( + path.join(projectPath, '.automaker', 'features', 'feature-123', 'images') + ); + }); + }); + + describe('getBoardDir', () => { + it('should return path to board directory', () => { + expect(getBoardDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'board')); + }); + }); + + describe('getImagesDir', () => { + it('should return path to images directory', () => { + expect(getImagesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'images')); + }); + }); + + describe('getWorktreesDir', () => { + it('should return path to worktrees directory', () => { + expect(getWorktreesDir(projectPath)).toBe(path.join(projectPath, '.automaker', 'worktrees')); + }); + }); + + describe('getAppSpecPath', () => { + it('should return path to app_spec.txt file', () => { + expect(getAppSpecPath(projectPath)).toBe( + path.join(projectPath, '.automaker', 'app_spec.txt') + ); + }); + }); + + describe('getBranchTrackingPath', () => { + it('should return path to active-branches.json file', () => { + expect(getBranchTrackingPath(projectPath)).toBe( + path.join(projectPath, '.automaker', 'active-branches.json') + ); + }); + }); + + describe('ensureAutomakerDir', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `automaker-paths-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should create automaker directory and return path', async () => { + const result = await ensureAutomakerDir(testDir); + + expect(result).toBe(path.join(testDir, '.automaker')); + const stats = await fs.stat(result); + expect(stats.isDirectory()).toBe(true); + }); + + it('should succeed if directory already exists', async () => { + const automakerDir = path.join(testDir, '.automaker'); + await fs.mkdir(automakerDir, { recursive: true }); + + const result = await ensureAutomakerDir(testDir); + + expect(result).toBe(automakerDir); + }); + }); + + describe('getGlobalSettingsPath', () => { + it('should return path to settings.json in data directory', () => { + const dataDir = '/test/data'; + const result = getGlobalSettingsPath(dataDir); + expect(result).toBe(path.join(dataDir, 'settings.json')); + }); + + it('should handle paths with trailing slashes', () => { + const dataDir = '/test/data' + path.sep; + const result = getGlobalSettingsPath(dataDir); + expect(result).toBe(path.join(dataDir, 'settings.json')); + }); + }); + + describe('getCredentialsPath', () => { + it('should return path to credentials.json in data directory', () => { + const dataDir = '/test/data'; + const result = getCredentialsPath(dataDir); + expect(result).toBe(path.join(dataDir, 'credentials.json')); + }); + + it('should handle paths with trailing slashes', () => { + const dataDir = '/test/data' + path.sep; + const result = getCredentialsPath(dataDir); + expect(result).toBe(path.join(dataDir, 'credentials.json')); + }); + }); + + describe('getProjectSettingsPath', () => { + it('should return path to settings.json in project .automaker directory', () => { + const projectPath = '/test/project'; + const result = getProjectSettingsPath(projectPath); + expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json')); + }); + + it('should handle paths with trailing slashes', () => { + const projectPath = '/test/project' + path.sep; + const result = getProjectSettingsPath(projectPath); + expect(result).toBe(path.join(projectPath, '.automaker', 'settings.json')); + }); + }); + + describe('ensureDataDir', () => { + let testDir: string; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `data-dir-test-${Date.now()}`); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should create data directory and return path', async () => { + const result = await ensureDataDir(testDir); + + expect(result).toBe(testDir); + const stats = await fs.stat(testDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should succeed if directory already exists', async () => { + await fs.mkdir(testDir, { recursive: true }); + + const result = await ensureDataDir(testDir); + + expect(result).toBe(testDir); + }); + + it('should create nested directories', async () => { + const nestedDir = path.join(testDir, 'nested', 'deep'); + const result = await ensureDataDir(nestedDir); + + expect(result).toBe(nestedDir); + const stats = await fs.stat(nestedDir); + expect(stats.isDirectory()).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/conversation-utils.test.ts b/jules_branch/apps/server/tests/unit/lib/conversation-utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb7c6684ef8c1e24d01197bfb8e395a69b0d6963 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/conversation-utils.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { + extractTextFromContent, + normalizeContentBlocks, + formatHistoryAsText, + convertHistoryToMessages, +} from '@automaker/utils'; +import { conversationHistoryFixture } from '../../fixtures/messages.js'; + +describe('conversation-utils.ts', () => { + describe('extractTextFromContent', () => { + it('should return string content as-is', () => { + const result = extractTextFromContent('Hello world'); + expect(result).toBe('Hello world'); + }); + + it('should extract text from single text block', () => { + const content = [{ type: 'text', text: 'Hello' }]; + const result = extractTextFromContent(content); + expect(result).toBe('Hello'); + }); + + it('should extract and join multiple text blocks with newlines', () => { + const content = [ + { type: 'text', text: 'First block' }, + { type: 'text', text: 'Second block' }, + { type: 'text', text: 'Third block' }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe('First block\nSecond block\nThird block'); + }); + + it('should ignore non-text blocks', () => { + const content = [ + { type: 'text', text: 'Text content' }, + { type: 'image', source: { type: 'base64', data: 'abc' } }, + { type: 'text', text: 'More text' }, + { type: 'tool_use', name: 'bash', input: {} }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe('Text content\nMore text'); + }); + + it('should handle blocks without text property', () => { + const content = [ + { type: 'text', text: 'Valid' }, + { type: 'text' } as any, + { type: 'text', text: 'Also valid' }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe('Valid\n\nAlso valid'); + }); + + it('should handle empty array', () => { + const result = extractTextFromContent([]); + expect(result).toBe(''); + }); + + it('should handle array with only non-text blocks', () => { + const content = [ + { type: 'image', source: {} }, + { type: 'tool_use', name: 'test' }, + ]; + const result = extractTextFromContent(content); + expect(result).toBe(''); + }); + }); + + describe('normalizeContentBlocks', () => { + it('should convert string to content block array', () => { + const result = normalizeContentBlocks('Hello'); + expect(result).toEqual([{ type: 'text', text: 'Hello' }]); + }); + + it('should return array content as-is', () => { + const content = [ + { type: 'text', text: 'Hello' }, + { type: 'image', source: {} }, + ]; + const result = normalizeContentBlocks(content); + expect(result).toBe(content); + expect(result).toHaveLength(2); + }); + + it('should handle empty string', () => { + const result = normalizeContentBlocks(''); + expect(result).toEqual([{ type: 'text', text: '' }]); + }); + }); + + describe('formatHistoryAsText', () => { + it('should return empty string for empty history', () => { + const result = formatHistoryAsText([]); + expect(result).toBe(''); + }); + + it('should format single user message', () => { + const history = [{ role: 'user' as const, content: 'Hello' }]; + const result = formatHistoryAsText(history); + + expect(result).toContain('Previous conversation:'); + expect(result).toContain('User: Hello'); + expect(result).toContain('---'); + }); + + it('should format single assistant message', () => { + const history = [{ role: 'assistant' as const, content: 'Hi there' }]; + const result = formatHistoryAsText(history); + + expect(result).toContain('Assistant: Hi there'); + }); + + it('should format multiple messages with correct roles', () => { + const history = conversationHistoryFixture.slice(0, 2); + const result = formatHistoryAsText(history); + + expect(result).toContain('User: Hello, can you help me?'); + expect(result).toContain('Assistant: Of course! How can I assist you today?'); + expect(result).toContain('---'); + }); + + it('should handle messages with array content (multipart)', () => { + const history = [conversationHistoryFixture[2]]; // Has text + image + const result = formatHistoryAsText(history); + + expect(result).toContain('What is in this image?'); + expect(result).not.toContain('base64'); // Should not include image data + }); + + it('should format all messages from fixture', () => { + const result = formatHistoryAsText(conversationHistoryFixture); + + expect(result).toContain('Previous conversation:'); + expect(result).toContain('User: Hello, can you help me?'); + expect(result).toContain('Assistant: Of course!'); + expect(result).toContain('User: What is in this image?'); + expect(result).toContain('---'); + }); + + it('should separate messages with double newlines', () => { + const history = [ + { role: 'user' as const, content: 'First' }, + { role: 'assistant' as const, content: 'Second' }, + ]; + const result = formatHistoryAsText(history); + + expect(result).toMatch(/User: First\n\nAssistant: Second/); + }); + }); + + describe('convertHistoryToMessages', () => { + it('should convert empty history', () => { + const result = convertHistoryToMessages([]); + expect(result).toEqual([]); + }); + + it('should convert single message to SDK format', () => { + const history = [{ role: 'user' as const, content: 'Hello' }]; + const result = convertHistoryToMessages(history); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'user', + session_id: '', + message: { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + parent_tool_use_id: null, + }); + }); + + it('should normalize string content to array', () => { + const history = [{ role: 'assistant' as const, content: 'Response' }]; + const result = convertHistoryToMessages(history); + + expect(result[0].message.content).toEqual([{ type: 'text', text: 'Response' }]); + }); + + it('should preserve array content', () => { + const history = [ + { + role: 'user' as const, + content: [ + { type: 'text', text: 'Hello' }, + { type: 'image', source: {} }, + ], + }, + ]; + const result = convertHistoryToMessages(history); + + expect(result[0].message.content).toHaveLength(2); + expect(result[0].message.content[0]).toEqual({ type: 'text', text: 'Hello' }); + }); + + it('should convert multiple messages', () => { + const history = conversationHistoryFixture.slice(0, 2); + const result = convertHistoryToMessages(history); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe('user'); + expect(result[1].type).toBe('assistant'); + }); + + it('should set correct fields for SDK format', () => { + const history = [{ role: 'user' as const, content: 'Test' }]; + const result = convertHistoryToMessages(history); + + expect(result[0].session_id).toBe(''); + expect(result[0].parent_tool_use_id).toBeNull(); + expect(result[0].type).toBe('user'); + expect(result[0].message.role).toBe('user'); + }); + + it('should handle all messages from fixture', () => { + const result = convertHistoryToMessages(conversationHistoryFixture); + + expect(result).toHaveLength(3); + expect(result[0].message.content).toBeInstanceOf(Array); + expect(result[1].message.content).toBeInstanceOf(Array); + expect(result[2].message.content).toBeInstanceOf(Array); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/dependency-resolver.test.ts b/jules_branch/apps/server/tests/unit/lib/dependency-resolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b018dacdd85f9bd1fc17843b4ae5d6b3f97e6661 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/dependency-resolver.test.ts @@ -0,0 +1,432 @@ +import { describe, it, expect } from 'vitest'; +import { + resolveDependencies, + areDependenciesSatisfied, + getBlockingDependencies, + type DependencyResolutionResult, +} from '@automaker/dependency-resolver'; +import type { Feature } from '@automaker/types'; + +// Helper to create test features +function createFeature( + id: string, + options: { + status?: string; + priority?: number; + dependencies?: string[]; + category?: string; + description?: string; + } = {} +): Feature { + return { + id, + category: options.category || 'test', + description: options.description || `Feature ${id}`, + status: options.status || 'backlog', + priority: options.priority, + dependencies: options.dependencies, + }; +} + +describe('dependency-resolver.ts', () => { + describe('resolveDependencies', () => { + it('should handle empty feature list', () => { + const result = resolveDependencies([]); + + expect(result.orderedFeatures).toEqual([]); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it('should handle features with no dependencies', () => { + const features = [ + createFeature('f1', { priority: 1 }), + createFeature('f2', { priority: 2 }), + createFeature('f3', { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe('f1'); // Highest priority first + expect(result.orderedFeatures[1].id).toBe('f2'); + expect(result.orderedFeatures[2].id).toBe('f3'); + expect(result.circularDependencies).toEqual([]); + expect(result.missingDependencies.size).toBe(0); + expect(result.blockedFeatures.size).toBe(0); + }); + + it('should order features by dependencies (simple chain)', () => { + const features = [ + createFeature('f3', { dependencies: ['f2'] }), + createFeature('f1'), + createFeature('f2', { dependencies: ['f1'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(3); + expect(result.orderedFeatures[0].id).toBe('f1'); + expect(result.orderedFeatures[1].id).toBe('f2'); + expect(result.orderedFeatures[2].id).toBe('f3'); + expect(result.circularDependencies).toEqual([]); + }); + + it('should respect priority within same dependency level', () => { + const features = [ + createFeature('f1', { priority: 3, dependencies: ['base'] }), + createFeature('f2', { priority: 1, dependencies: ['base'] }), + createFeature('f3', { priority: 2, dependencies: ['base'] }), + createFeature('base'), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe('base'); + expect(result.orderedFeatures[1].id).toBe('f2'); // Priority 1 + expect(result.orderedFeatures[2].id).toBe('f3'); // Priority 2 + expect(result.orderedFeatures[3].id).toBe('f1'); // Priority 3 + }); + + it('should use default priority of 2 when not specified', () => { + const features = [ + createFeature('f1', { priority: 1 }), + createFeature('f2'), // No priority = default 2 + createFeature('f3', { priority: 3 }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures[0].id).toBe('f1'); + expect(result.orderedFeatures[1].id).toBe('f2'); + expect(result.orderedFeatures[2].id).toBe('f3'); + }); + + it('should detect missing dependencies', () => { + const features = [ + createFeature('f1', { dependencies: ['missing1', 'missing2'] }), + createFeature('f2', { dependencies: ['f1', 'missing3'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.missingDependencies.size).toBe(2); + expect(result.missingDependencies.get('f1')).toEqual(['missing1', 'missing2']); + expect(result.missingDependencies.get('f2')).toEqual(['missing3']); + expect(result.orderedFeatures).toHaveLength(2); + }); + + it('should detect blocked features (incomplete dependencies)', () => { + const features = [ + createFeature('f1', { status: 'in_progress' }), + createFeature('f2', { status: 'backlog', dependencies: ['f1'] }), + createFeature('f3', { status: 'completed' }), + createFeature('f4', { status: 'backlog', dependencies: ['f3'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(1); + expect(result.blockedFeatures.get('f2')).toEqual(['f1']); + expect(result.blockedFeatures.has('f4')).toBe(false); // f3 is completed + }); + + it('should not block features whose dependencies are verified', () => { + const features = [ + createFeature('f1', { status: 'verified' }), + createFeature('f2', { status: 'backlog', dependencies: ['f1'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.size).toBe(0); + }); + + it('should detect circular dependencies (simple cycle)', () => { + const features = [ + createFeature('f1', { dependencies: ['f2'] }), + createFeature('f2', { dependencies: ['f1'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies).toHaveLength(1); + expect(result.circularDependencies[0]).toContain('f1'); + expect(result.circularDependencies[0]).toContain('f2'); + expect(result.orderedFeatures).toHaveLength(2); // Features still included + }); + + it('should detect circular dependencies (multi-node cycle)', () => { + const features = [ + createFeature('f1', { dependencies: ['f3'] }), + createFeature('f2', { dependencies: ['f1'] }), + createFeature('f3', { dependencies: ['f2'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(3); + }); + + it('should handle mixed valid and circular dependencies', () => { + const features = [ + createFeature('base'), + createFeature('f1', { dependencies: ['base', 'f2'] }), + createFeature('f2', { dependencies: ['f1'] }), // Circular with f1 + createFeature('f3', { dependencies: ['base'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures[0].id).toBe('base'); + expect(result.orderedFeatures).toHaveLength(4); + }); + + it('should handle complex dependency graph', () => { + const features = [ + createFeature('ui', { dependencies: ['api', 'auth'], priority: 1 }), + createFeature('api', { dependencies: ['db'], priority: 2 }), + createFeature('auth', { dependencies: ['db'], priority: 1 }), + createFeature('db', { priority: 1 }), + createFeature('tests', { dependencies: ['ui'], priority: 3 }), + ]; + + const result = resolveDependencies(features); + + const order = result.orderedFeatures.map((f) => f.id); + + expect(order[0]).toBe('db'); + expect(order.indexOf('db')).toBeLessThan(order.indexOf('api')); + expect(order.indexOf('db')).toBeLessThan(order.indexOf('auth')); + expect(order.indexOf('api')).toBeLessThan(order.indexOf('ui')); + expect(order.indexOf('auth')).toBeLessThan(order.indexOf('ui')); + expect(order.indexOf('ui')).toBeLessThan(order.indexOf('tests')); + expect(result.circularDependencies).toEqual([]); + }); + + it('should handle features with empty dependencies array', () => { + const features = [ + createFeature('f1', { dependencies: [] }), + createFeature('f2', { dependencies: [] }), + ]; + + const result = resolveDependencies(features); + + expect(result.orderedFeatures).toHaveLength(2); + expect(result.circularDependencies).toEqual([]); + expect(result.blockedFeatures.size).toBe(0); + }); + + it('should track multiple blocking dependencies', () => { + const features = [ + createFeature('f1', { status: 'in_progress' }), + createFeature('f2', { status: 'backlog' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + const result = resolveDependencies(features); + + expect(result.blockedFeatures.get('f3')).toEqual(['f1', 'f2']); + }); + + it('should handle self-referencing dependency', () => { + const features = [createFeature('f1', { dependencies: ['f1'] })]; + + const result = resolveDependencies(features); + + expect(result.circularDependencies.length).toBeGreaterThan(0); + expect(result.orderedFeatures).toHaveLength(1); + }); + }); + + describe('areDependenciesSatisfied', () => { + it('should return true for feature with no dependencies', () => { + const feature = createFeature('f1'); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it('should return true for feature with empty dependencies array', () => { + const feature = createFeature('f1', { dependencies: [] }); + const allFeatures = [feature]; + + expect(areDependenciesSatisfied(feature, allFeatures)).toBe(true); + }); + + it('should return true when all dependencies are completed', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'completed' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it('should return true when all dependencies are verified', () => { + const allFeatures = [ + createFeature('f1', { status: 'verified' }), + createFeature('f2', { status: 'verified' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it('should return true when dependencies are mix of completed and verified', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'verified' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(true); + }); + + it('should return false when any dependency is in_progress', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'in_progress' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it('should return false when any dependency is in backlog', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'backlog' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[2], allFeatures)).toBe(false); + }); + + it('should return false when dependency is missing', () => { + const allFeatures = [createFeature('f1', { status: 'backlog', dependencies: ['missing'] })]; + + expect(areDependenciesSatisfied(allFeatures[0], allFeatures)).toBe(false); + }); + + it('should return false when multiple dependencies are incomplete', () => { + const allFeatures = [ + createFeature('f1', { status: 'backlog' }), + createFeature('f2', { status: 'in_progress' }), + createFeature('f3', { status: 'waiting_approval' }), + createFeature('f4', { status: 'backlog', dependencies: ['f1', 'f2', 'f3'] }), + ]; + + expect(areDependenciesSatisfied(allFeatures[3], allFeatures)).toBe(false); + }); + }); + + describe('getBlockingDependencies', () => { + it('should return empty array for feature with no dependencies', () => { + const feature = createFeature('f1'); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it('should return empty array for feature with empty dependencies array', () => { + const feature = createFeature('f1', { dependencies: [] }); + const allFeatures = [feature]; + + expect(getBlockingDependencies(feature, allFeatures)).toEqual([]); + }); + + it('should return empty array when all dependencies are completed', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'completed' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it('should return empty array when all dependencies are verified', () => { + const allFeatures = [ + createFeature('f1', { status: 'verified' }), + createFeature('f2', { status: 'verified' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual([]); + }); + + it('should return blocking dependencies in backlog status', () => { + const allFeatures = [ + createFeature('f1', { status: 'backlog' }), + createFeature('f2', { status: 'completed' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']); + }); + + it('should return blocking dependencies in in_progress status', () => { + const allFeatures = [ + createFeature('f1', { status: 'in_progress' }), + createFeature('f2', { status: 'verified' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']); + }); + + it('should return blocking dependencies in waiting_approval status', () => { + const allFeatures = [ + createFeature('f1', { status: 'waiting_approval' }), + createFeature('f2', { status: 'completed' }), + createFeature('f3', { status: 'backlog', dependencies: ['f1', 'f2'] }), + ]; + + expect(getBlockingDependencies(allFeatures[2], allFeatures)).toEqual(['f1']); + }); + + it('should return all blocking dependencies', () => { + const allFeatures = [ + createFeature('f1', { status: 'backlog' }), + createFeature('f2', { status: 'in_progress' }), + createFeature('f3', { status: 'waiting_approval' }), + createFeature('f4', { status: 'completed' }), + createFeature('f5', { status: 'backlog', dependencies: ['f1', 'f2', 'f3', 'f4'] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(3); + expect(blocking).toContain('f1'); + expect(blocking).toContain('f2'); + expect(blocking).toContain('f3'); + expect(blocking).not.toContain('f4'); + }); + + it('should handle missing dependencies', () => { + const allFeatures = [createFeature('f1', { status: 'backlog', dependencies: ['missing'] })]; + + // Missing dependencies won't be in the blocking list since they don't exist + expect(getBlockingDependencies(allFeatures[0], allFeatures)).toEqual([]); + }); + + it('should handle mix of completed, verified, and incomplete dependencies', () => { + const allFeatures = [ + createFeature('f1', { status: 'completed' }), + createFeature('f2', { status: 'verified' }), + createFeature('f3', { status: 'in_progress' }), + createFeature('f4', { status: 'backlog' }), + createFeature('f5', { status: 'backlog', dependencies: ['f1', 'f2', 'f3', 'f4'] }), + ]; + + const blocking = getBlockingDependencies(allFeatures[4], allFeatures); + expect(blocking).toHaveLength(2); + expect(blocking).toContain('f3'); + expect(blocking).toContain('f4'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/enhancement-prompts.test.ts b/jules_branch/apps/server/tests/unit/lib/enhancement-prompts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..77d118d3e5bb23dd070225169189603290c0e054 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/enhancement-prompts.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect } from 'vitest'; +import { + getEnhancementPrompt, + getSystemPrompt, + getExamples, + buildUserPrompt, + isValidEnhancementMode, + getAvailableEnhancementModes, + IMPROVE_SYSTEM_PROMPT, + TECHNICAL_SYSTEM_PROMPT, + SIMPLIFY_SYSTEM_PROMPT, + ACCEPTANCE_SYSTEM_PROMPT, + IMPROVE_EXAMPLES, + TECHNICAL_EXAMPLES, + SIMPLIFY_EXAMPLES, + ACCEPTANCE_EXAMPLES, + type EnhancementMode, +} from '@/lib/enhancement-prompts.js'; + +const ENHANCEMENT_MODES: EnhancementMode[] = [ + 'improve', + 'technical', + 'simplify', + 'acceptance', + 'ux-reviewer', +]; + +describe('enhancement-prompts.ts', () => { + describe('System Prompt Constants', () => { + it('should have non-empty improve system prompt', () => { + expect(IMPROVE_SYSTEM_PROMPT).toBeDefined(); + expect(IMPROVE_SYSTEM_PROMPT.length).toBeGreaterThan(100); + expect(IMPROVE_SYSTEM_PROMPT).toContain('ANALYZE'); + expect(IMPROVE_SYSTEM_PROMPT).toContain('CLARIFY'); + }); + + it('should have non-empty technical system prompt', () => { + expect(TECHNICAL_SYSTEM_PROMPT).toBeDefined(); + expect(TECHNICAL_SYSTEM_PROMPT.length).toBeGreaterThan(100); + expect(TECHNICAL_SYSTEM_PROMPT).toContain('technical'); + }); + + it('should have non-empty simplify system prompt', () => { + expect(SIMPLIFY_SYSTEM_PROMPT).toBeDefined(); + expect(SIMPLIFY_SYSTEM_PROMPT.length).toBeGreaterThan(100); + expect(SIMPLIFY_SYSTEM_PROMPT).toContain('simplify'); + }); + + it('should have non-empty acceptance system prompt', () => { + expect(ACCEPTANCE_SYSTEM_PROMPT).toBeDefined(); + expect(ACCEPTANCE_SYSTEM_PROMPT.length).toBeGreaterThan(100); + expect(ACCEPTANCE_SYSTEM_PROMPT).toContain('acceptance criteria'); + }); + }); + + describe('Example Constants', () => { + it('should have improve examples with input and output', () => { + expect(IMPROVE_EXAMPLES).toBeDefined(); + expect(IMPROVE_EXAMPLES.length).toBeGreaterThan(0); + IMPROVE_EXAMPLES.forEach((example) => { + expect(example.input).toBeDefined(); + expect(example.output).toBeDefined(); + expect(example.input.length).toBeGreaterThan(0); + expect(example.output.length).toBeGreaterThan(0); + }); + }); + + it('should have technical examples with input and output', () => { + expect(TECHNICAL_EXAMPLES).toBeDefined(); + expect(TECHNICAL_EXAMPLES.length).toBeGreaterThan(0); + TECHNICAL_EXAMPLES.forEach((example) => { + expect(example.input).toBeDefined(); + expect(example.output).toBeDefined(); + }); + }); + + it('should have simplify examples with input and output', () => { + expect(SIMPLIFY_EXAMPLES).toBeDefined(); + expect(SIMPLIFY_EXAMPLES.length).toBeGreaterThan(0); + SIMPLIFY_EXAMPLES.forEach((example) => { + expect(example.input).toBeDefined(); + expect(example.output).toBeDefined(); + }); + }); + + it('should have acceptance examples with input and output', () => { + expect(ACCEPTANCE_EXAMPLES).toBeDefined(); + expect(ACCEPTANCE_EXAMPLES.length).toBeGreaterThan(0); + ACCEPTANCE_EXAMPLES.forEach((example) => { + expect(example.input).toBeDefined(); + expect(example.output).toBeDefined(); + }); + }); + }); + + describe('getEnhancementPrompt', () => { + it('should return config for improve mode', () => { + const config = getEnhancementPrompt('improve'); + expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); + expect(config.description).toContain('clear'); + }); + + it('should return config for technical mode', () => { + const config = getEnhancementPrompt('technical'); + expect(config.systemPrompt).toBe(TECHNICAL_SYSTEM_PROMPT); + expect(config.description).toContain('technical'); + }); + + it('should return config for simplify mode', () => { + const config = getEnhancementPrompt('simplify'); + expect(config.systemPrompt).toBe(SIMPLIFY_SYSTEM_PROMPT); + expect(config.description).toContain('concise'); + }); + + it('should return config for acceptance mode', () => { + const config = getEnhancementPrompt('acceptance'); + expect(config.systemPrompt).toBe(ACCEPTANCE_SYSTEM_PROMPT); + expect(config.description).toContain('acceptance'); + }); + + it('should handle case-insensitive mode', () => { + const config = getEnhancementPrompt('IMPROVE'); + expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); + }); + + it('should fall back to improve for invalid mode', () => { + const config = getEnhancementPrompt('invalid-mode'); + expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); + }); + + it('should fall back to improve for empty string', () => { + const config = getEnhancementPrompt(''); + expect(config.systemPrompt).toBe(IMPROVE_SYSTEM_PROMPT); + }); + }); + + describe('getSystemPrompt', () => { + it('should return correct system prompt for each mode', () => { + expect(getSystemPrompt('improve')).toBe(IMPROVE_SYSTEM_PROMPT); + expect(getSystemPrompt('technical')).toBe(TECHNICAL_SYSTEM_PROMPT); + expect(getSystemPrompt('simplify')).toBe(SIMPLIFY_SYSTEM_PROMPT); + expect(getSystemPrompt('acceptance')).toBe(ACCEPTANCE_SYSTEM_PROMPT); + }); + }); + + describe('getExamples', () => { + it('should return correct examples for each mode', () => { + expect(getExamples('improve')).toBe(IMPROVE_EXAMPLES); + expect(getExamples('technical')).toBe(TECHNICAL_EXAMPLES); + expect(getExamples('simplify')).toBe(SIMPLIFY_EXAMPLES); + expect(getExamples('acceptance')).toBe(ACCEPTANCE_EXAMPLES); + }); + + it('should return arrays with example objects', () => { + const modes: EnhancementMode[] = ['improve', 'technical', 'simplify', 'acceptance']; + modes.forEach((mode) => { + const examples = getExamples(mode); + expect(Array.isArray(examples)).toBe(true); + expect(examples.length).toBeGreaterThan(0); + }); + }); + }); + + describe('buildUserPrompt', () => { + const testText = 'Add a logout button'; + + it('should build prompt with examples by default', () => { + const prompt = buildUserPrompt('improve', testText); + expect(prompt).toContain('Example 1:'); + expect(prompt).toContain(testText); + expect(prompt).toContain('Please enhance the following task description:'); + }); + + it('should build prompt without examples when includeExamples is false', () => { + const prompt = buildUserPrompt('improve', testText, false); + expect(prompt).not.toContain('Example 1:'); + expect(prompt).toContain(testText); + expect(prompt).toContain('Please enhance the following task description:'); + }); + + it('should include all examples for improve mode', () => { + const prompt = buildUserPrompt('improve', testText); + IMPROVE_EXAMPLES.forEach((example, index) => { + expect(prompt).toContain(`Example ${index + 1}:`); + expect(prompt).toContain(example.input); + }); + }); + + it('should include separator between examples', () => { + const prompt = buildUserPrompt('improve', testText); + expect(prompt).toContain('---'); + }); + + it('should work with all enhancement modes', () => { + ENHANCEMENT_MODES.forEach((mode) => { + const prompt = buildUserPrompt(mode, testText); + expect(prompt).toContain(testText); + expect(prompt.length).toBeGreaterThan(100); + }); + }); + + it('should preserve the original text exactly', () => { + const specialText = 'Add feature with special chars: <>&"\''; + const prompt = buildUserPrompt('improve', specialText); + expect(prompt).toContain(specialText); + }); + }); + + describe('isValidEnhancementMode', () => { + it('should return true for valid modes', () => { + expect(isValidEnhancementMode('improve')).toBe(true); + expect(isValidEnhancementMode('technical')).toBe(true); + expect(isValidEnhancementMode('simplify')).toBe(true); + expect(isValidEnhancementMode('acceptance')).toBe(true); + expect(isValidEnhancementMode('ux-reviewer')).toBe(true); + }); + + it('should return false for invalid modes', () => { + expect(isValidEnhancementMode('invalid')).toBe(false); + expect(isValidEnhancementMode('IMPROVE')).toBe(false); // case-sensitive + expect(isValidEnhancementMode('')).toBe(false); + expect(isValidEnhancementMode('random')).toBe(false); + }); + }); + + describe('getAvailableEnhancementModes', () => { + it('should return all enhancement modes', () => { + const modes = getAvailableEnhancementModes(); + expect(modes).toHaveLength(ENHANCEMENT_MODES.length); + ENHANCEMENT_MODES.forEach((mode) => { + expect(modes).toContain(mode); + }); + }); + + it('should return an array', () => { + const modes = getAvailableEnhancementModes(); + expect(Array.isArray(modes)).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/error-handler.test.ts b/jules_branch/apps/server/tests/unit/lib/error-handler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..87a160368abd86cc46136d6e5851e9dc6d971974 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/error-handler.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from 'vitest'; +import { + isAbortError, + isAuthenticationError, + isCancellationError, + classifyError, + getUserFriendlyErrorMessage, + type ErrorType, +} from '@automaker/utils'; + +describe('error-handler.ts', () => { + describe('isAbortError', () => { + it('should detect AbortError by error name', () => { + const error = new Error('Operation cancelled'); + error.name = 'AbortError'; + expect(isAbortError(error)).toBe(true); + }); + + it('should detect abort error by message content', () => { + const error = new Error('Request was aborted'); + expect(isAbortError(error)).toBe(true); + }); + + it('should return false for non-abort errors', () => { + const error = new Error('Something else went wrong'); + expect(isAbortError(error)).toBe(false); + }); + + it('should return false for non-Error objects', () => { + expect(isAbortError('not an error')).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + }); + }); + + describe('isCancellationError', () => { + it("should detect 'cancelled' message", () => { + expect(isCancellationError('Operation was cancelled')).toBe(true); + }); + + it("should detect 'canceled' message", () => { + expect(isCancellationError('Request was canceled')).toBe(true); + }); + + it("should detect 'stopped' message", () => { + expect(isCancellationError('Process was stopped')).toBe(true); + }); + + it("should detect 'aborted' message", () => { + expect(isCancellationError('Task was aborted')).toBe(true); + }); + + it('should be case insensitive', () => { + expect(isCancellationError('CANCELLED')).toBe(true); + expect(isCancellationError('Canceled')).toBe(true); + }); + + it('should return false for non-cancellation errors', () => { + expect(isCancellationError('File not found')).toBe(false); + expect(isCancellationError('Network error')).toBe(false); + }); + }); + + describe('isAuthenticationError', () => { + it("should detect 'Authentication failed' message", () => { + expect(isAuthenticationError('Authentication failed')).toBe(true); + }); + + it("should detect 'Invalid API key' message", () => { + expect(isAuthenticationError('Invalid API key provided')).toBe(true); + }); + + it("should detect 'authentication_failed' message", () => { + expect(isAuthenticationError('authentication_failed')).toBe(true); + }); + + it("should detect 'Fix external API key' message", () => { + expect(isAuthenticationError('Fix external API key configuration')).toBe(true); + }); + + it('should return false for non-authentication errors', () => { + expect(isAuthenticationError('Network connection error')).toBe(false); + expect(isAuthenticationError('File not found')).toBe(false); + }); + + it('should be case sensitive', () => { + expect(isAuthenticationError('authentication Failed')).toBe(false); + }); + }); + + describe('classifyError', () => { + it('should classify authentication errors', () => { + const error = new Error('Authentication failed'); + const result = classifyError(error); + + expect(result.type).toBe('authentication'); + expect(result.isAuth).toBe(true); + expect(result.isAbort).toBe(false); + expect(result.message).toBe('Authentication failed'); + expect(result.originalError).toBe(error); + }); + + it('should classify abort errors', () => { + const error = new Error('Operation aborted'); + error.name = 'AbortError'; + const result = classifyError(error); + + expect(result.type).toBe('abort'); + expect(result.isAbort).toBe(true); + expect(result.isAuth).toBe(false); + expect(result.message).toBe('Operation aborted'); + }); + + it('should prioritize auth over abort if both match', () => { + const error = new Error('Authentication failed and aborted'); + const result = classifyError(error); + + expect(result.type).toBe('authentication'); + expect(result.isAuth).toBe(true); + expect(result.isAbort).toBe(true); // Still detected as abort too + }); + + it('should classify cancellation errors', () => { + const error = new Error('Operation was cancelled'); + const result = classifyError(error); + + expect(result.type).toBe('cancellation'); + expect(result.isCancellation).toBe(true); + expect(result.isAbort).toBe(false); + expect(result.isAuth).toBe(false); + }); + + it('should prioritize abort over cancellation if both match', () => { + const error = new Error('Operation aborted'); + error.name = 'AbortError'; + const result = classifyError(error); + + expect(result.type).toBe('abort'); + expect(result.isAbort).toBe(true); + expect(result.isCancellation).toBe(true); // Still detected as cancellation too + }); + + it("should classify cancellation errors with 'canceled' spelling", () => { + const error = new Error('Request was canceled'); + const result = classifyError(error); + + expect(result.type).toBe('cancellation'); + expect(result.isCancellation).toBe(true); + }); + + it("should classify cancellation errors with 'stopped' message", () => { + const error = new Error('Process was stopped'); + const result = classifyError(error); + + expect(result.type).toBe('cancellation'); + expect(result.isCancellation).toBe(true); + }); + + it('should classify generic Error as execution error', () => { + const error = new Error('Something went wrong'); + const result = classifyError(error); + + expect(result.type).toBe('execution'); + expect(result.isAuth).toBe(false); + expect(result.isAbort).toBe(false); + }); + + it('should classify non-Error objects as unknown', () => { + const error = 'string error'; + const result = classifyError(error); + + expect(result.type).toBe('unknown'); + expect(result.message).toBe('string error'); + }); + + it('should handle null and undefined', () => { + const nullResult = classifyError(null); + expect(nullResult.type).toBe('unknown'); + expect(nullResult.message).toBe('Unknown error'); + + const undefinedResult = classifyError(undefined); + expect(undefinedResult.type).toBe('unknown'); + expect(undefinedResult.message).toBe('Unknown error'); + }); + }); + + describe('getUserFriendlyErrorMessage', () => { + it('should return friendly message for abort errors', () => { + const error = new Error('abort'); + const result = getUserFriendlyErrorMessage(error); + expect(result).toBe('Operation was cancelled'); + }); + + it('should return friendly message for authentication errors', () => { + const error = new Error('Authentication failed'); + const result = getUserFriendlyErrorMessage(error); + expect(result).toBe('Authentication failed. Please check your API key.'); + }); + + it('should return original message for other errors', () => { + const error = new Error('File not found'); + const result = getUserFriendlyErrorMessage(error); + expect(result).toBe('File not found'); + }); + + it('should handle non-Error objects', () => { + const result = getUserFriendlyErrorMessage('Custom error'); + expect(result).toBe('Custom error'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/events.test.ts b/jules_branch/apps/server/tests/unit/lib/events.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8a780928cfde77e9a47ef39e5616d16b06c69f4 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/events.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createEventEmitter, type EventType } from '@/lib/events.js'; + +describe('events.ts', () => { + describe('createEventEmitter', () => { + it('should emit events to single subscriber', () => { + const emitter = createEventEmitter(); + const callback = vi.fn(); + + emitter.subscribe(callback); + emitter.emit('agent:stream', { message: 'test' }); + + expect(callback).toHaveBeenCalledOnce(); + expect(callback).toHaveBeenCalledWith('agent:stream', { message: 'test' }); + }); + + it('should emit events to multiple subscribers', () => { + const emitter = createEventEmitter(); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + emitter.subscribe(callback1); + emitter.subscribe(callback2); + emitter.subscribe(callback3); + emitter.emit('feature:started', { id: '123' }); + + expect(callback1).toHaveBeenCalledOnce(); + expect(callback2).toHaveBeenCalledOnce(); + expect(callback3).toHaveBeenCalledOnce(); + expect(callback1).toHaveBeenCalledWith('feature:started', { id: '123' }); + }); + + it('should support unsubscribe functionality', () => { + const emitter = createEventEmitter(); + const callback = vi.fn(); + + const unsubscribe = emitter.subscribe(callback); + emitter.emit('agent:stream', { test: 1 }); + + expect(callback).toHaveBeenCalledOnce(); + + unsubscribe(); + emitter.emit('agent:stream', { test: 2 }); + + expect(callback).toHaveBeenCalledOnce(); // Still called only once + }); + + it('should handle errors in subscribers without crashing', () => { + const emitter = createEventEmitter(); + const errorCallback = vi.fn(() => { + throw new Error('Subscriber error'); + }); + const normalCallback = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + emitter.subscribe(errorCallback); + emitter.subscribe(normalCallback); + + expect(() => { + emitter.emit('feature:error', { error: 'test' }); + }).not.toThrow(); + + expect(errorCallback).toHaveBeenCalledOnce(); + expect(normalCallback).toHaveBeenCalledOnce(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should emit different event types', () => { + const emitter = createEventEmitter(); + const callback = vi.fn(); + + emitter.subscribe(callback); + + const eventTypes: EventType[] = [ + 'agent:stream', + 'auto-mode:started', + 'feature:completed', + 'project:analysis-progress', + ]; + + eventTypes.forEach((type) => { + emitter.emit(type, { type }); + }); + + expect(callback).toHaveBeenCalledTimes(4); + }); + + it('should handle emitting without subscribers', () => { + const emitter = createEventEmitter(); + + expect(() => { + emitter.emit('agent:stream', { test: true }); + }).not.toThrow(); + }); + + it('should allow multiple subscriptions and unsubscriptions', () => { + const emitter = createEventEmitter(); + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const callback3 = vi.fn(); + + const unsub1 = emitter.subscribe(callback1); + const unsub2 = emitter.subscribe(callback2); + const unsub3 = emitter.subscribe(callback3); + + emitter.emit('feature:started', { test: 1 }); + expect(callback1).toHaveBeenCalledOnce(); + expect(callback2).toHaveBeenCalledOnce(); + expect(callback3).toHaveBeenCalledOnce(); + + unsub2(); + + emitter.emit('feature:started', { test: 2 }); + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledOnce(); // Still just once + expect(callback3).toHaveBeenCalledTimes(2); + + unsub1(); + unsub3(); + + emitter.emit('feature:started', { test: 3 }); + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledOnce(); + expect(callback3).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/file-editor-store-logic.test.ts b/jules_branch/apps/server/tests/unit/lib/file-editor-store-logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6eabbd36e82de6869975f5e16769c0d17a22c3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/file-editor-store-logic.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from 'vitest'; +import { + computeIsDirty, + updateTabWithContent as updateTabContent, + markTabAsSaved as markTabSaved, +} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts'; + +/** + * Unit tests for the file editor store logic, focusing on the unsaved indicator fix. + * + * The bug was: File unsaved indicators weren't working reliably - editing a file + * and saving it would sometimes leave the dirty indicator (dot) visible. + * + * Root causes: + * 1. Stale closure in handleSave - captured activeTab could have old content + * 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store + * + * Fix: + * - handleSave now gets fresh state from store using getState() + * - handleSave gets current content from editor via getValue() + * - Content is synced to store before saving if it differs + * + * Since we can't easily test the React/zustand store in node environment, + * we test the pure logic that the store uses for dirty state tracking. + */ + +describe('File editor dirty state logic', () => { + describe('updateTabContent', () => { + it('should set isDirty to true when content differs from originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + const updated = updateTabContent(tab, 'modified content'); + + expect(updated.isDirty).toBe(true); + expect(updated.content).toBe('modified content'); + expect(updated.originalContent).toBe('original content'); + }); + + it('should set isDirty to false when content matches originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + // First modify it + let updated = updateTabContent(tab, 'modified content'); + expect(updated.isDirty).toBe(true); + + // Now update back to original + updated = updateTabContent(updated, 'original content'); + expect(updated.isDirty).toBe(false); + }); + + it('should handle empty content correctly', () => { + const tab = { + content: '', + originalContent: '', + isDirty: false, + }; + + const updated = updateTabContent(tab, 'new content'); + + expect(updated.isDirty).toBe(true); + }); + }); + + describe('markTabSaved', () => { + it('should set isDirty to false and update both content and originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + // First modify + let updated = updateTabContent(tab, 'modified content'); + expect(updated.isDirty).toBe(true); + + // Then save + updated = markTabSaved(updated, 'modified content'); + + expect(updated.isDirty).toBe(false); + expect(updated.content).toBe('modified content'); + expect(updated.originalContent).toBe('modified content'); + }); + + it('should correctly clear dirty state when save is triggered after edit', () => { + // This test simulates the bug scenario: + // 1. User edits file -> isDirty = true + // 2. User saves -> markTabSaved should set isDirty = false + let tab = { + content: 'initial', + originalContent: 'initial', + isDirty: false, + }; + + // Simulate user editing + tab = updateTabContent(tab, 'initial\nnew line'); + + // Should be dirty + expect(tab.isDirty).toBe(true); + + // Simulate save (with the content that was saved) + tab = markTabSaved(tab, 'initial\nnew line'); + + // Should NOT be dirty anymore + expect(tab.isDirty).toBe(false); + }); + }); + + describe('race condition handling', () => { + it('should correctly handle updateTabContent after markTabSaved with same content', () => { + // This tests the scenario where: + // 1. CodeMirror has a pending onChange with content "B" + // 2. User presses save when editor shows "B" + // 3. markTabSaved is called with "B" + // 4. CodeMirror's pending onChange fires with "B" (same content) + // Result: isDirty should remain false + let tab = { + content: 'A', + originalContent: 'A', + isDirty: false, + }; + + // User edits to "B" + tab = updateTabContent(tab, 'B'); + + // Save with "B" + tab = markTabSaved(tab, 'B'); + + // Late onChange with same content "B" + tab = updateTabContent(tab, 'B'); + + expect(tab.isDirty).toBe(false); + expect(tab.content).toBe('B'); + }); + + it('should correctly handle updateTabContent after markTabSaved with different content', () => { + // This tests the scenario where: + // 1. CodeMirror has a pending onChange with content "C" + // 2. User presses save when store has "B" + // 3. markTabSaved is called with "B" + // 4. CodeMirror's pending onChange fires with "C" (different content) + // Result: isDirty should be true (file changed after save) + let tab = { + content: 'A', + originalContent: 'A', + isDirty: false, + }; + + // User edits to "B" + tab = updateTabContent(tab, 'B'); + + // Save with "B" + tab = markTabSaved(tab, 'B'); + + // Late onChange with different content "C" + tab = updateTabContent(tab, 'C'); + + // File changed after save, so it should be dirty + expect(tab.isDirty).toBe(true); + expect(tab.content).toBe('C'); + expect(tab.originalContent).toBe('B'); + }); + + it('should handle rapid edit-save-edit cycle correctly', () => { + // Simulate rapid user actions + let tab = { + content: 'v1', + originalContent: 'v1', + isDirty: false, + }; + + // Edit 1 + tab = updateTabContent(tab, 'v2'); + expect(tab.isDirty).toBe(true); + + // Save 1 + tab = markTabSaved(tab, 'v2'); + expect(tab.isDirty).toBe(false); + + // Edit 2 + tab = updateTabContent(tab, 'v3'); + expect(tab.isDirty).toBe(true); + + // Save 2 + tab = markTabSaved(tab, 'v3'); + expect(tab.isDirty).toBe(false); + + // Edit 3 (back to v2) + tab = updateTabContent(tab, 'v2'); + expect(tab.isDirty).toBe(true); + + // Save 3 + tab = markTabSaved(tab, 'v2'); + expect(tab.isDirty).toBe(false); + }); + }); + + describe('handleSave stale closure fix simulation', () => { + it('demonstrates the fix: using fresh content instead of closure content', () => { + // This test demonstrates why the fix was necessary. + // The old handleSave captured activeTab in closure, which could be stale. + // The fix gets fresh state from getState() and uses editor.getValue(). + + // Simulate store state + let storeState = { + tabs: [ + { + id: 'tab-1', + content: 'A', + originalContent: 'A', + isDirty: false, + }, + ], + activeTabId: 'tab-1', + }; + + // Simulate a "stale closure" capturing the tab state + const staleClosureTab = storeState.tabs[0]; + + // User edits - store state updates + storeState = { + ...storeState, + tabs: [ + { + id: 'tab-1', + content: 'B', + originalContent: 'A', + isDirty: true, + }, + ], + }; + + // OLD BUG: Using stale closure tab would save "A" (old content) + const oldBugSavedContent = staleClosureTab!.content; + expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B" + + // FIX: Using fresh state from getState() gets correct content + const freshTab = storeState.tabs[0]; + const fixedSavedContent = freshTab!.content; + expect(fixedSavedContent).toBe('B'); // Correct! + }); + + it('demonstrates syncing editor content before save', () => { + // This test demonstrates why we need to get content from editor directly. + // The store might have stale content if onChange hasn't fired yet. + + // Simulate store state (has old content because onChange hasn't fired) + let storeContent = 'A'; + + // Editor has newer content (not yet synced to store) + const editorContent = 'B'; + + // FIX: Use editor content if available, fall back to store content + const contentToSave = editorContent ?? storeContent; + + expect(contentToSave).toBe('B'); // Correctly saves editor content + + // Simulate syncing to store before save + if (editorContent !== null && editorContent !== storeContent) { + storeContent = editorContent; + } + + // Now store is synced + expect(storeContent).toBe('B'); + + // After save, markTabSaved would set originalContent = savedContent + // and isDirty = false (if no more changes come in) + }); + }); + + describe('edge cases', () => { + it('should handle whitespace-only changes as dirty', () => { + let tab = { + content: 'hello', + originalContent: 'hello', + isDirty: false, + }; + + tab = updateTabContent(tab, 'hello '); + expect(tab.isDirty).toBe(true); + }); + + it('should treat CRLF and LF line endings as equivalent (not dirty)', () => { + let tab = { + content: 'line1\nline2', + originalContent: 'line1\nline2', + isDirty: false, + }; + + // CodeMirror normalizes \r\n to \n internally, so content that only + // differs by line endings should NOT be considered dirty. + tab = updateTabContent(tab, 'line1\r\nline2'); + expect(tab.isDirty).toBe(false); + }); + + it('should handle unicode content correctly', () => { + let tab = { + content: '你好世界', + originalContent: '你好世界', + isDirty: false, + }; + + tab = updateTabContent(tab, '你好宇宙'); + expect(tab.isDirty).toBe(true); + + tab = markTabSaved(tab, '你好宇宙'); + expect(tab.isDirty).toBe(false); + }); + + it('should handle very large content efficiently', () => { + // Generate a large string (1MB) + const largeOriginal = 'x'.repeat(1024 * 1024); + const largeModified = largeOriginal + 'y'; + + let tab = { + content: largeOriginal, + originalContent: largeOriginal, + isDirty: false, + }; + + tab = updateTabContent(tab, largeModified); + + expect(tab.isDirty).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/fs-utils.test.ts b/jules_branch/apps/server/tests/unit/lib/fs-utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ac49e6d8211104292ee6cb9657fcc653eb7b0ad --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/fs-utils.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSafe, existsSafe } from '@automaker/utils'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('fs-utils.ts', () => { + let testDir: string; + + beforeEach(async () => { + // Create a temporary test directory + testDir = path.join(os.tmpdir(), `fs-utils-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('mkdirSafe', () => { + it('should create a new directory', async () => { + const newDir = path.join(testDir, 'new-directory'); + await mkdirSafe(newDir); + + const stats = await fs.stat(newDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should succeed if directory already exists', async () => { + const existingDir = path.join(testDir, 'existing'); + await fs.mkdir(existingDir); + + // Should not throw + await expect(mkdirSafe(existingDir)).resolves.toBeUndefined(); + }); + + it('should create nested directories', async () => { + const nestedDir = path.join(testDir, 'a', 'b', 'c'); + await mkdirSafe(nestedDir); + + const stats = await fs.stat(nestedDir); + expect(stats.isDirectory()).toBe(true); + }); + + it('should throw if path exists as a file', async () => { + const filePath = path.join(testDir, 'file.txt'); + await fs.writeFile(filePath, 'content'); + + await expect(mkdirSafe(filePath)).rejects.toThrow('Path exists and is not a directory'); + }); + + it('should succeed if path is a symlink to a directory', async () => { + const realDir = path.join(testDir, 'real-dir'); + const symlinkPath = path.join(testDir, 'link-to-dir'); + await fs.mkdir(realDir); + await fs.symlink(realDir, symlinkPath); + + // Should not throw + await expect(mkdirSafe(symlinkPath)).resolves.toBeUndefined(); + }); + + it('should handle ELOOP error gracefully when checking path', async () => { + // Mock lstat to throw ELOOP error + const originalLstat = fs.lstat; + const mkdirSafePath = path.join(testDir, 'eloop-path'); + + vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ELOOP' }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(mkdirSafePath)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it('should handle EEXIST error gracefully when creating directory', async () => { + const newDir = path.join(testDir, 'race-condition-dir'); + + // Mock lstat to return ENOENT (path doesn't exist) + // Then mock mkdir to throw EEXIST (race condition) + vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ENOENT' }); + vi.spyOn(fs, 'mkdir').mockRejectedValueOnce({ code: 'EEXIST' }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(newDir)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it('should handle ELOOP error gracefully when creating directory', async () => { + const newDir = path.join(testDir, 'eloop-create-dir'); + + // Mock lstat to return ENOENT (path doesn't exist) + // Then mock mkdir to throw ELOOP + vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ENOENT' }); + vi.spyOn(fs, 'mkdir').mockRejectedValueOnce({ code: 'ELOOP' }); + + // Should not throw, should return gracefully + await expect(mkdirSafe(newDir)).resolves.toBeUndefined(); + + vi.restoreAllMocks(); + }); + }); + + describe('existsSafe', () => { + it('should return true for existing file', async () => { + const filePath = path.join(testDir, 'test-file.txt'); + await fs.writeFile(filePath, 'content'); + + const exists = await existsSafe(filePath); + expect(exists).toBe(true); + }); + + it('should return true for existing directory', async () => { + const dirPath = path.join(testDir, 'test-dir'); + await fs.mkdir(dirPath); + + const exists = await existsSafe(dirPath); + expect(exists).toBe(true); + }); + + it('should return false for non-existent path', async () => { + const nonExistent = path.join(testDir, 'does-not-exist'); + + const exists = await existsSafe(nonExistent); + expect(exists).toBe(false); + }); + + it('should return true for symlink', async () => { + const realFile = path.join(testDir, 'real-file.txt'); + const symlinkPath = path.join(testDir, 'link-to-file'); + await fs.writeFile(realFile, 'content'); + await fs.symlink(realFile, symlinkPath); + + const exists = await existsSafe(symlinkPath); + expect(exists).toBe(true); + }); + + it("should return true for broken symlink (symlink exists even if target doesn't)", async () => { + const symlinkPath = path.join(testDir, 'broken-link'); + const nonExistent = path.join(testDir, 'non-existent-target'); + await fs.symlink(nonExistent, symlinkPath); + + const exists = await existsSafe(symlinkPath); + expect(exists).toBe(true); + }); + + it('should return true for ELOOP error (symlink loop)', async () => { + // Mock lstat to throw ELOOP error + vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'ELOOP' }); + + const exists = await existsSafe('/some/path/with/loop'); + expect(exists).toBe(true); + + vi.restoreAllMocks(); + }); + + it('should throw for other errors', async () => { + // Mock lstat to throw a non-ENOENT, non-ELOOP error + vi.spyOn(fs, 'lstat').mockRejectedValueOnce({ code: 'EACCES' }); + + await expect(existsSafe('/some/path')).rejects.toMatchObject({ code: 'EACCES' }); + + vi.restoreAllMocks(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/git-log-parser.test.ts b/jules_branch/apps/server/tests/unit/lib/git-log-parser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53c5342c963e6f6db4333e647e59b2fcadf72736 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/git-log-parser.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { parseGitLogOutput } from '../../../src/lib/git-log-parser.js'; + +// Mock data: fields within each commit are newline-separated, +// commits are NUL-separated (matching the parser contract). +const mockGitOutput = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Mock data where commit bodies contain ---END--- markers +const mockOutputWithEndMarker = [ + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is the commit body\n---END--- is in this message', + 'e5f6g7h8i9j0klmnoprstuv\ne5f6g7\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in the message', + 'q1w2e3r4t5y6u7i8o9p0asdfghjkl\nq1w2e3\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nEmpty body', +].join('\0'); + +// Single-commit mock: fields newline-separated, no trailing NUL needed +const singleCommitOutput = + 'a1b2c3d4e5f67890abcd1234567890abcd1234\na1b2c3\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSingle commit\nSingle commit body'; + +describe('parseGitLogOutput', () => { + describe('normal parsing (three commits)', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is the commit body'); + }); + + it('parses the second commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[1].hash).toBe('e5f6g7h8i9j0klmnoprstuv'); + expect(commits[1].shortHash).toBe('e5f6g7'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit fields correctly', () => { + const commits = parseGitLogOutput(mockGitOutput); + expect(commits[2].hash).toBe('q1w2e3r4t5y6u7i8o9p0asdfghjkl'); + expect(commits[2].shortHash).toBe('q1w2e3'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('parsing with ---END--- in commit messages', () => { + it('returns the correct number of commits', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits.length).toBe(3); + }); + + it('preserves ---END--- text in the body of the first commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toMatch(/---END---/); + }); + + it('preserves ---END--- text in the body of the second commit', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toMatch(/---END---/); + }); + + it('parses the third commit without ---END--- interference', () => { + const commits = parseGitLogOutput(mockOutputWithEndMarker); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('Empty body'); + }); + }); + + describe('empty output', () => { + it('returns an empty array for an empty string', () => { + const commits = parseGitLogOutput(''); + expect(commits).toEqual([]); + expect(commits.length).toBe(0); + }); + }); + + describe('single-commit output', () => { + it('returns exactly one commit', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits.length).toBe(1); + }); + + it('parses the single commit fields correctly', () => { + const commits = parseGitLogOutput(singleCommitOutput); + expect(commits[0].hash).toBe('a1b2c3d4e5f67890abcd1234567890abcd1234'); + expect(commits[0].shortHash).toBe('a1b2c3'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Single commit'); + expect(commits[0].body).toBe('Single commit body'); + }); + }); + + describe('multi-line commit body', () => { + // Test vector from test-proper-nul-format.js: commit with a 3-line body + const multiLineBodyOutput = + [ + 'abc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nInitial commit\nThis is a normal commit body', + 'def456\ndef4\nJane Smith\njane@example.com\n2023-01-02T12:00:00Z\nFix bug\nFixed the bug with ---END--- in this message', + 'ghi789\nghi7\nBob Johnson\nbob@example.com\n2023-01-03T12:00:00Z\nAnother commit\nThis body has multiple lines\nSecond line\nThird line', + ].join('\0') + '\0'; + + it('returns 3 commits', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits.length).toBe(3); + }); + + it('parses the first commit correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].shortHash).toBe('abc1'); + expect(commits[0].author).toBe('John Doe'); + expect(commits[0].authorEmail).toBe('john@example.com'); + expect(commits[0].date).toBe('2023-01-01T12:00:00Z'); + expect(commits[0].subject).toBe('Initial commit'); + expect(commits[0].body).toBe('This is a normal commit body'); + }); + + it('parses the second commit with ---END--- in body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[1].hash).toBe('def456'); + expect(commits[1].shortHash).toBe('def4'); + expect(commits[1].author).toBe('Jane Smith'); + expect(commits[1].subject).toBe('Fix bug'); + expect(commits[1].body).toContain('---END---'); + }); + + it('parses the third commit with a multi-line body correctly', () => { + const commits = parseGitLogOutput(multiLineBodyOutput); + expect(commits[2].hash).toBe('ghi789'); + expect(commits[2].shortHash).toBe('ghi7'); + expect(commits[2].author).toBe('Bob Johnson'); + expect(commits[2].subject).toBe('Another commit'); + expect(commits[2].body).toBe('This body has multiple lines\nSecond line\nThird line'); + }); + }); + + describe('commit with empty body (trailing blank lines after subject)', () => { + // Test vector from test-proper-nul-format.js: empty body commit + const emptyBodyOutput = + 'empty123\nempty1\nAlice Brown\nalice@example.com\n2023-01-04T12:00:00Z\nEmpty body commit\n\n\0'; + + it('returns 1 commit', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits.length).toBe(1); + }); + + it('parses the commit subject correctly', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].hash).toBe('empty123'); + expect(commits[0].shortHash).toBe('empty1'); + expect(commits[0].author).toBe('Alice Brown'); + expect(commits[0].subject).toBe('Empty body commit'); + }); + + it('produces an empty body string when only blank lines follow the subject', () => { + const commits = parseGitLogOutput(emptyBodyOutput); + expect(commits[0].body).toBe(''); + }); + }); + + describe('leading empty lines in a commit block', () => { + // Blocks that start with blank lines before the hash field + const outputWithLeadingBlanks = + '\n\nabc123\nabc1\nJohn Doe\njohn@example.com\n2023-01-01T12:00:00Z\nSubject here\nBody here'; + + it('returns 1 commit despite leading blank lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits.length).toBe(1); + }); + + it('parses the commit fields correctly when block has leading empty lines', () => { + const commits = parseGitLogOutput(outputWithLeadingBlanks); + expect(commits[0].hash).toBe('abc123'); + expect(commits[0].subject).toBe('Subject here'); + expect(commits[0].body).toBe('Body here'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/image-handler.test.ts b/jules_branch/apps/server/tests/unit/lib/image-handler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e48ad429e31682a8545e8ca7f895f8aa61b6f7e --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/image-handler.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getMimeTypeForImage, + readImageAsBase64, + convertImagesToContentBlocks, + formatImagePathsForPrompt, +} from '@automaker/utils'; +import { pngBase64Fixture } from '../../fixtures/images.js'; +import * as fs from 'fs/promises'; + +vi.mock('fs/promises'); + +describe('image-handler.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getMimeTypeForImage', () => { + it('should return correct MIME type for .jpg', () => { + expect(getMimeTypeForImage('test.jpg')).toBe('image/jpeg'); + expect(getMimeTypeForImage('/path/to/test.jpg')).toBe('image/jpeg'); + }); + + it('should return correct MIME type for .jpeg', () => { + expect(getMimeTypeForImage('test.jpeg')).toBe('image/jpeg'); + }); + + it('should return correct MIME type for .png', () => { + expect(getMimeTypeForImage('test.png')).toBe('image/png'); + }); + + it('should return correct MIME type for .gif', () => { + expect(getMimeTypeForImage('test.gif')).toBe('image/gif'); + }); + + it('should return correct MIME type for .webp', () => { + expect(getMimeTypeForImage('test.webp')).toBe('image/webp'); + }); + + it('should be case-insensitive', () => { + expect(getMimeTypeForImage('test.PNG')).toBe('image/png'); + expect(getMimeTypeForImage('test.JPG')).toBe('image/jpeg'); + expect(getMimeTypeForImage('test.GIF')).toBe('image/gif'); + expect(getMimeTypeForImage('test.WEBP')).toBe('image/webp'); + }); + + it('should default to image/png for unknown extensions', () => { + expect(getMimeTypeForImage('test.unknown')).toBe('image/png'); + expect(getMimeTypeForImage('test.txt')).toBe('image/png'); + expect(getMimeTypeForImage('test')).toBe('image/png'); + }); + + it('should handle paths with multiple dots', () => { + expect(getMimeTypeForImage('my.image.file.jpg')).toBe('image/jpeg'); + }); + }); + + describe('readImageAsBase64', () => { + // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) + it.skipIf(process.platform === 'win32')( + 'should read image and return base64 data', + async () => { + const mockBuffer = Buffer.from(pngBase64Fixture, 'base64'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await readImageAsBase64('/path/to/test.png'); + + expect(result).toMatchObject({ + base64: pngBase64Fixture, + mimeType: 'image/png', + filename: 'test.png', + originalPath: '/path/to/test.png', + }); + expect(fs.readFile).toHaveBeenCalledWith('/path/to/test.png'); + } + ); + + it('should handle different image formats', async () => { + const mockBuffer = Buffer.from('jpeg-data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await readImageAsBase64('/path/to/photo.jpg'); + + expect(result.mimeType).toBe('image/jpeg'); + expect(result.filename).toBe('photo.jpg'); + expect(result.base64).toBe(mockBuffer.toString('base64')); + }); + + it('should extract filename from path', async () => { + const mockBuffer = Buffer.from('data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await readImageAsBase64('/deep/nested/path/image.webp'); + + expect(result.filename).toBe('image.webp'); + }); + + it('should throw error if file cannot be read', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); + + await expect(readImageAsBase64('/nonexistent.png')).rejects.toThrow('File not found'); + }); + }); + + describe('convertImagesToContentBlocks', () => { + it('should convert single image to content block', async () => { + const mockBuffer = Buffer.from(pngBase64Fixture, 'base64'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await convertImagesToContentBlocks(['/path/test.png']); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: pngBase64Fixture, + }, + }); + }); + + it('should convert multiple images to content blocks', async () => { + const mockBuffer = Buffer.from('test-data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await convertImagesToContentBlocks(['/a.png', '/b.jpg', '/c.webp']); + + expect(result).toHaveLength(3); + expect(result[0].source.media_type).toBe('image/png'); + expect(result[1].source.media_type).toBe('image/jpeg'); + expect(result[2].source.media_type).toBe('image/webp'); + }); + + it('should resolve relative paths with workDir', async () => { + const mockBuffer = Buffer.from('data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + await convertImagesToContentBlocks(['relative.png'], '/work/dir'); + + // Use path-agnostic check since Windows uses backslashes + const calls = vi.mocked(fs.readFile).mock.calls; + expect(calls[0][0]).toMatch(/relative\.png$/); + expect(calls[0][0]).toContain('work'); + expect(calls[0][0]).toContain('dir'); + }); + + // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) + it.skipIf(process.platform === 'win32')( + 'should handle absolute paths without workDir', + async () => { + const mockBuffer = Buffer.from('data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + await convertImagesToContentBlocks(['/absolute/path.png']); + + expect(fs.readFile).toHaveBeenCalledWith('/absolute/path.png'); + } + ); + + it('should continue processing on individual image errors', async () => { + vi.mocked(fs.readFile) + .mockResolvedValueOnce(Buffer.from('ok1')) + .mockRejectedValueOnce(new Error('Failed')) + .mockResolvedValueOnce(Buffer.from('ok2')); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await convertImagesToContentBlocks(['/a.png', '/b.png', '/c.png']); + + expect(result).toHaveLength(2); // Only successful images + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should return empty array for empty input', async () => { + const result = await convertImagesToContentBlocks([]); + expect(result).toEqual([]); + }); + + // Skip on Windows as path.resolve converts Unix paths to Windows paths (CI runs on Linux) + it.skipIf(process.platform === 'win32')('should handle undefined workDir', async () => { + const mockBuffer = Buffer.from('data'); + vi.mocked(fs.readFile).mockResolvedValue(mockBuffer); + + const result = await convertImagesToContentBlocks(['/test.png'], undefined); + + expect(result).toHaveLength(1); + expect(fs.readFile).toHaveBeenCalledWith('/test.png'); + }); + }); + + describe('formatImagePathsForPrompt', () => { + it('should format single image path as bulleted list', () => { + const result = formatImagePathsForPrompt(['/path/image.png']); + + expect(result).toContain('\n\nAttached images:'); + expect(result).toContain('- /path/image.png'); + }); + + it('should format multiple image paths as bulleted list', () => { + const result = formatImagePathsForPrompt(['/path/a.png', '/path/b.jpg', '/path/c.webp']); + + expect(result).toContain('Attached images:'); + expect(result).toContain('- /path/a.png'); + expect(result).toContain('- /path/b.jpg'); + expect(result).toContain('- /path/c.webp'); + }); + + it('should return empty string for empty array', () => { + const result = formatImagePathsForPrompt([]); + expect(result).toBe(''); + }); + + it('should start with double newline', () => { + const result = formatImagePathsForPrompt(['/test.png']); + expect(result.startsWith('\n\n')).toBe(true); + }); + + it('should handle paths with special characters', () => { + const result = formatImagePathsForPrompt(['/path/with spaces/image.png']); + expect(result).toContain('- /path/with spaces/image.png'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/json-extractor.test.ts b/jules_branch/apps/server/tests/unit/lib/json-extractor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc0681fbc3337f0a90f54af2ee213e10db11e0ac --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/json-extractor.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { extractJson, extractJsonWithKey, extractJsonWithArray } from '@/lib/json-extractor.js'; + +describe('json-extractor.ts', () => { + const mockLogger = { + debug: vi.fn(), + warn: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('extractJson', () => { + describe('Strategy 1: JSON in ```json code block', () => { + it('should extract JSON from ```json code block', () => { + const responseText = `Here is the result: +\`\`\`json +{"name": "test", "value": 42} +\`\`\` +That's all!`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ name: 'test', value: 42 }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON from ```json code block'); + }); + + it('should handle multiline JSON in code block', () => { + const responseText = `\`\`\`json +{ + "items": [ + {"id": 1}, + {"id": 2} + ] +} +\`\`\``; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ items: [{ id: 1 }, { id: 2 }] }); + }); + }); + + describe('Strategy 2: JSON in ``` code block (no language)', () => { + it('should extract JSON from unmarked code block', () => { + const responseText = `Result: +\`\`\` +{"status": "ok"} +\`\`\``; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ status: 'ok' }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON from ``` code block'); + }); + + it('should handle array JSON in unmarked code block', () => { + const responseText = `\`\`\` +[1, 2, 3] +\`\`\``; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should skip non-JSON code blocks and find JSON via brace matching', () => { + // When code block contains non-JSON, later strategies will try to extract + // The first { in the response is in the function code, so brace matching + // will try that and fail. The JSON after the code block is found via strategy 5. + const responseText = `\`\`\` +return true; +\`\`\` +Here is the JSON: {"actual": "json"}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ actual: 'json' }); + }); + }); + + describe('Strategy 3: Find JSON with required key', () => { + it('should find JSON containing required key', () => { + const responseText = `Some text before {"features": ["a", "b"]} and after`; + + const result = extractJson(responseText, { + logger: mockLogger, + requiredKey: 'features', + }); + + expect(result).toEqual({ features: ['a', 'b'] }); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Extracting JSON with required key "features"' + ); + }); + + it('should skip JSON without required key', () => { + const responseText = `{"wrong": "key"} {"features": ["correct"]}`; + + const result = extractJson(responseText, { + logger: mockLogger, + requiredKey: 'features', + }); + + expect(result).toEqual({ features: ['correct'] }); + }); + }); + + describe('Strategy 4: Find any JSON by brace matching', () => { + it('should extract JSON by matching braces', () => { + const responseText = `Let me provide the response: {"result": "success", "data": {"nested": true}}. Done.`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ result: 'success', data: { nested: true } }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracting JSON by brace matching'); + }); + + it('should handle deeply nested objects', () => { + const responseText = `{"a": {"b": {"c": {"d": "deep"}}}}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ a: { b: { c: { d: 'deep' } } } }); + }); + }); + + describe('Strategy 5: First { to last }', () => { + it('should extract from first to last brace when other strategies fail', () => { + // Create malformed JSON that brace matching fails but first-to-last works + const responseText = `Prefix {"key": "value"} suffix text`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ key: 'value' }); + }); + }); + + describe('Strategy 6: Parse entire response as JSON', () => { + it('should parse entire response when it is valid JSON object', () => { + const responseText = `{"complete": "json"}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ complete: 'json' }); + }); + + it('should parse entire response when it is valid JSON array', () => { + const responseText = `["a", "b", "c"]`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should handle whitespace around JSON', () => { + const responseText = ` + {"trimmed": true} + `; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ trimmed: true }); + }); + }); + + describe('requireArray option', () => { + it('should validate required key contains array', () => { + const responseText = `{"items": ["a", "b", "c"]}`; + + const result = extractJson(responseText, { + logger: mockLogger, + requiredKey: 'items', + requireArray: true, + }); + + expect(result).toEqual({ items: ['a', 'b', 'c'] }); + }); + + it('should reject when required key is not an array', () => { + const responseText = `{"items": "not an array"}`; + + const result = extractJson(responseText, { + logger: mockLogger, + requiredKey: 'items', + requireArray: true, + }); + + expect(result).toBeNull(); + }); + }); + + describe('error handling', () => { + it('should return null for invalid JSON', () => { + const responseText = `This is not JSON at all`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalledWith('Failed to extract JSON from response'); + }); + + it('should return null for malformed JSON', () => { + const responseText = `{"broken": }`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toBeNull(); + }); + + it('should return null for empty input', () => { + const result = extractJson('', { logger: mockLogger }); + + expect(result).toBeNull(); + }); + + it('should return null when required key is missing', () => { + const responseText = `{"other": "key"}`; + + const result = extractJson(responseText, { + logger: mockLogger, + requiredKey: 'missing', + }); + + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should handle JSON with escaped characters', () => { + const responseText = `{"text": "Hello \\"World\\"", "path": "C:\\\\Users"}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ text: 'Hello "World"', path: 'C:\\Users' }); + }); + + it('should handle JSON with unicode', () => { + const responseText = `{"emoji": "🚀", "japanese": "日本語"}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ emoji: '🚀', japanese: '日本語' }); + }); + + it('should work without custom logger', () => { + const responseText = `{"simple": "test"}`; + + const result = extractJson(responseText); + + expect(result).toEqual({ simple: 'test' }); + }); + + it('should handle multiple JSON objects in text - takes first valid one', () => { + const responseText = `First: {"a": 1} Second: {"b": 2}`; + + const result = extractJson(responseText, { logger: mockLogger }); + + expect(result).toEqual({ a: 1 }); + }); + }); + }); + + describe('extractJsonWithKey', () => { + it('should extract JSON with specified required key', () => { + const responseText = `{"suggestions": [{"title": "Test"}]}`; + + const result = extractJsonWithKey(responseText, 'suggestions', { logger: mockLogger }); + + expect(result).toEqual({ suggestions: [{ title: 'Test' }] }); + }); + + it('should return null when key is missing', () => { + const responseText = `{"other": "data"}`; + + const result = extractJsonWithKey(responseText, 'suggestions', { logger: mockLogger }); + + expect(result).toBeNull(); + }); + }); + + describe('extractJsonWithArray', () => { + it('should extract JSON with array at specified key', () => { + const responseText = `{"features": ["feature1", "feature2"]}`; + + const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger }); + + expect(result).toEqual({ features: ['feature1', 'feature2'] }); + }); + + it('should return null when key value is not an array', () => { + const responseText = `{"features": "not an array"}`; + + const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger }); + + expect(result).toBeNull(); + }); + + it('should return null when key is missing', () => { + const responseText = `{"other": ["array"]}`; + + const result = extractJsonWithArray(responseText, 'features', { logger: mockLogger }); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/logger.test.ts b/jules_branch/apps/server/tests/unit/lib/logger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..234a7373d56eb94a950f9bfb635edeea7f206c46 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/logger.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + LogLevel, + createLogger, + getLogLevel, + setLogLevel, + setColorsEnabled, + setTimestampsEnabled, +} from '@automaker/utils'; + +describe('logger.ts', () => { + let consoleSpy: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let originalLogLevel: LogLevel; + + beforeEach(() => { + originalLogLevel = getLogLevel(); + // Disable colors and timestamps for predictable test output + setColorsEnabled(false); + setTimestampsEnabled(false); + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + setLogLevel(originalLogLevel); + consoleSpy.log.mockRestore(); + consoleSpy.warn.mockRestore(); + consoleSpy.error.mockRestore(); + }); + + describe('LogLevel enum', () => { + it('should have correct numeric values', () => { + expect(LogLevel.ERROR).toBe(0); + expect(LogLevel.WARN).toBe(1); + expect(LogLevel.INFO).toBe(2); + expect(LogLevel.DEBUG).toBe(3); + }); + }); + + describe('setLogLevel and getLogLevel', () => { + it('should set and get log level', () => { + setLogLevel(LogLevel.DEBUG); + expect(getLogLevel()).toBe(LogLevel.DEBUG); + + setLogLevel(LogLevel.ERROR); + expect(getLogLevel()).toBe(LogLevel.ERROR); + }); + }); + + describe('createLogger', () => { + it('should create a logger with context prefix', () => { + setLogLevel(LogLevel.INFO); + const logger = createLogger('TestContext'); + + logger.info('test message'); + + // New format: 'LEVEL [Context]' as first arg, then message + expect(consoleSpy.log).toHaveBeenCalledWith('INFO [TestContext]', 'test message'); + }); + + it('should log error at all log levels', () => { + const logger = createLogger('Test'); + + setLogLevel(LogLevel.ERROR); + logger.error('error message'); + expect(consoleSpy.error).toHaveBeenCalledWith('ERROR [Test]', 'error message'); + }); + + it('should log warn when level is WARN or higher', () => { + const logger = createLogger('Test'); + + setLogLevel(LogLevel.ERROR); + logger.warn('warn message 1'); + expect(consoleSpy.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.WARN); + logger.warn('warn message 2'); + // Note: warn uses console.log in Node.js implementation + expect(consoleSpy.log).toHaveBeenCalledWith('WARN [Test]', 'warn message 2'); + }); + + it('should log info when level is INFO or higher', () => { + const logger = createLogger('Test'); + + setLogLevel(LogLevel.WARN); + logger.info('info message 1'); + expect(consoleSpy.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.INFO); + logger.info('info message 2'); + expect(consoleSpy.log).toHaveBeenCalledWith('INFO [Test]', 'info message 2'); + }); + + it('should log debug only when level is DEBUG', () => { + const logger = createLogger('Test'); + + setLogLevel(LogLevel.INFO); + logger.debug('debug message 1'); + expect(consoleSpy.log).not.toHaveBeenCalled(); + + setLogLevel(LogLevel.DEBUG); + logger.debug('debug message 2'); + expect(consoleSpy.log).toHaveBeenCalledWith('DEBUG [Test]', 'debug message 2'); + }); + + it('should pass multiple arguments to log functions', () => { + setLogLevel(LogLevel.DEBUG); + const logger = createLogger('Multi'); + + logger.info('message', { data: 'value' }, 123); + expect(consoleSpy.log).toHaveBeenCalledWith( + 'INFO [Multi]', + 'message', + { data: 'value' }, + 123 + ); + }); + + it('should include timestamps when enabled', () => { + setTimestampsEnabled(true); + setLogLevel(LogLevel.INFO); + const logger = createLogger('Timestamp'); + + logger.info('test'); + + // First arg should contain ISO timestamp format + const firstArg = consoleSpy.log.mock.calls[0][0]; + expect(firstArg).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z INFO \[Timestamp\]$/); + expect(consoleSpy.log.mock.calls[0][1]).toBe('test'); + + setTimestampsEnabled(false); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/model-resolver.test.ts b/jules_branch/apps/server/tests/unit/lib/model-resolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..65e3115dfdfefde39bee8ae32ccc14f9a34fcff0 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/model-resolver.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + resolveModelString, + getEffectiveModel, + CLAUDE_MODEL_MAP, + CURSOR_MODEL_MAP, + DEFAULT_MODELS, +} from '@automaker/model-resolver'; + +describe('model-resolver.ts', () => { + let consoleSpy: any; + + beforeEach(() => { + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + consoleSpy.log.mockRestore(); + consoleSpy.warn.mockRestore(); + }); + + describe('resolveModelString', () => { + it("should resolve 'haiku' alias to full model string", () => { + const result = resolveModelString('haiku'); + expect(result).toBe(CLAUDE_MODEL_MAP.haiku); + }); + + it("should resolve 'sonnet' alias to full model string", () => { + const result = resolveModelString('sonnet'); + expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); + }); + + it("should resolve 'opus' alias to full model string", () => { + const result = resolveModelString('opus'); + expect(result).toBe('claude-opus-4-6'); + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('Migrated legacy ID: "opus" -> "claude-opus"') + ); + }); + + it('should pass through unknown models unchanged (may be provider models)', () => { + // Unknown models now pass through unchanged to support ClaudeCompatibleProvider models + // like GLM-4.7, MiniMax-M2.1, o1, etc. + const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123', 'GLM-4.7']; + models.forEach((model) => { + const result = resolveModelString(model); + // Should pass through unchanged (could be provider models) + expect(result).toBe(model); + }); + }); + + it('should pass through full Claude model strings', () => { + const models = [CLAUDE_MODEL_MAP.opus, CLAUDE_MODEL_MAP.sonnet, CLAUDE_MODEL_MAP.haiku]; + models.forEach((model) => { + const result = resolveModelString(model); + expect(result).toBe(model); + }); + expect(consoleSpy.log).toHaveBeenCalledWith( + expect.stringContaining('Using full Claude model string') + ); + }); + + it('should return default model when modelKey is undefined', () => { + const result = resolveModelString(undefined); + expect(result).toBe(DEFAULT_MODELS.claude); + }); + + it('should return custom default model when provided', () => { + const customDefault = 'custom-model'; + const result = resolveModelString(undefined, customDefault); + expect(result).toBe(customDefault); + }); + + it('should pass through unknown model key unchanged (no warning)', () => { + const result = resolveModelString('unknown-model'); + // Unknown models pass through unchanged (could be provider models) + expect(result).toBe('unknown-model'); + // No warning - unknown models are valid for providers + expect(consoleSpy.warn).not.toHaveBeenCalled(); + }); + + it('should handle empty string', () => { + const result = resolveModelString(''); + expect(result).toBe(DEFAULT_MODELS.claude); + }); + + describe('Cursor models', () => { + it('should pass through cursor-prefixed models unchanged', () => { + const result = resolveModelString('cursor-composer-1'); + expect(result).toBe('cursor-composer-1'); + expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model')); + }); + + it('should add cursor- prefix to bare Cursor model IDs', () => { + const result = resolveModelString('composer-1'); + expect(result).toBe('cursor-composer-1'); + }); + + it('should handle cursor-auto model', () => { + const result = resolveModelString('cursor-auto'); + expect(result).toBe('cursor-auto'); + }); + + it('should handle all known Cursor model IDs with prefix', () => { + const cursorModelIds = Object.keys(CURSOR_MODEL_MAP); + cursorModelIds.forEach((modelId) => { + const result = resolveModelString(`cursor-${modelId}`); + expect(result).toBe(`cursor-${modelId}`); + }); + }); + }); + }); + + describe('getEffectiveModel', () => { + it('should prioritize explicit model over session and default', () => { + const result = getEffectiveModel('opus', 'haiku', 'gpt-5.2'); + expect(result).toBe('claude-opus-4-6'); + }); + + it('should use session model when explicit is not provided', () => { + const result = getEffectiveModel(undefined, 'sonnet', 'gpt-5.2'); + expect(result).toBe(CLAUDE_MODEL_MAP.sonnet); + }); + + it('should use default when neither explicit nor session is provided', () => { + const customDefault = CLAUDE_MODEL_MAP.haiku; + const result = getEffectiveModel(undefined, undefined, customDefault); + expect(result).toBe(customDefault); + }); + + it('should use Claude default when no arguments provided', () => { + const result = getEffectiveModel(); + expect(result).toBe(DEFAULT_MODELS.claude); + }); + + it('should handle explicit empty strings as undefined', () => { + const result = getEffectiveModel('', 'haiku'); + expect(result).toBe(CLAUDE_MODEL_MAP.haiku); + }); + }); + + describe('CLAUDE_MODEL_MAP', () => { + it('should have haiku, sonnet, opus mappings', () => { + expect(CLAUDE_MODEL_MAP).toHaveProperty('haiku'); + expect(CLAUDE_MODEL_MAP).toHaveProperty('sonnet'); + expect(CLAUDE_MODEL_MAP).toHaveProperty('opus'); + }); + + it('should have valid Claude model strings', () => { + expect(CLAUDE_MODEL_MAP.haiku).toContain('haiku'); + expect(CLAUDE_MODEL_MAP.sonnet).toContain('sonnet'); + expect(CLAUDE_MODEL_MAP.opus).toContain('opus'); + }); + }); + + describe('DEFAULT_MODELS', () => { + it('should have claude default', () => { + expect(DEFAULT_MODELS).toHaveProperty('claude'); + }); + + it('should have valid default model', () => { + expect(DEFAULT_MODELS.claude).toContain('claude'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/nul-delimiter.test.ts b/jules_branch/apps/server/tests/unit/lib/nul-delimiter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cf20bdc77013ceb43579502bb033931ede11ae0 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/nul-delimiter.test.ts @@ -0,0 +1,83 @@ +// Automated tests for NUL character behavior in git commit parsing + +import { describe, it, expect } from 'vitest'; + +describe('NUL character behavior', () => { + // Create a string with NUL characters + const str1 = + 'abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00This is a normal commit body\x00'; + + describe('split on NUL character', () => { + const parts = str1.split('\0'); + + it('should produce the expected number of parts', () => { + // 7 fields + 1 trailing empty string from the trailing \x00 + expect(parts.length).toBe(8); + }); + + it('should contain the expected part values', () => { + expect(parts[0]).toBe('abc123'); + expect(parts[1]).toBe('abc1'); + expect(parts[2]).toBe('John Doe'); + expect(parts[3]).toBe('john@example.com'); + expect(parts[4]).toBe('2023-01-01T12:00:00Z'); + expect(parts[5]).toBe('Initial commit'); + expect(parts[6]).toBe('This is a normal commit body'); + expect(parts[7]).toBe(''); + }); + + it('should have correct lengths for each part', () => { + expect(parts[0].length).toBe(6); // 'abc123' + expect(parts[1].length).toBe(4); // 'abc1' + expect(parts[2].length).toBe(8); // 'John Doe' + expect(parts[3].length).toBe(16); // 'john@example.com' + expect(parts[4].length).toBe(20); // '2023-01-01T12:00:00Z' + expect(parts[5].length).toBe(14); // 'Initial commit' + expect(parts[6].length).toBe(28); // 'This is a normal commit body' + expect(parts[7].length).toBe(0); // trailing empty + }); + }); + + describe('git format split and filter', () => { + const gitFormat = `abc123\x00abc1\x00John Doe\x00john@example.com\x002023-01-01T12:00:00Z\x00Initial commit\x00Body text here\x00def456\x00def4\x00Jane Smith\x00jane@example.com\x002023-01-02T12:00:00Z\x00Second commit\x00Body with ---END--- text\x00`; + + const gitParts = gitFormat.split('\0').filter((block) => block.trim()); + + it('should produce the expected number of non-empty parts after filtering', () => { + // 14 non-empty field strings (7 fields per commit × 2 commits); trailing empty is filtered out + expect(gitParts.length).toBe(14); + }); + + it('should contain correct field values for the first commit', () => { + const fields = gitParts.slice(0, 7); + expect(fields.length).toBe(7); + expect(fields[0]).toBe('abc123'); // hash + expect(fields[1]).toBe('abc1'); // shortHash + expect(fields[2]).toBe('John Doe'); // author + expect(fields[3]).toBe('john@example.com'); // authorEmail + expect(fields[4]).toBe('2023-01-01T12:00:00Z'); // date + expect(fields[5]).toBe('Initial commit'); // subject + expect(fields[6]).toBe('Body text here'); // body + }); + + it('should contain correct field values for the second commit', () => { + const fields = gitParts.slice(7, 14); + expect(fields.length).toBe(7); + expect(fields[0]).toBe('def456'); // hash + expect(fields[1]).toBe('def4'); // shortHash + expect(fields[2]).toBe('Jane Smith'); // author + expect(fields[3]).toBe('jane@example.com'); // authorEmail + expect(fields[4]).toBe('2023-01-02T12:00:00Z'); // date + expect(fields[5]).toBe('Second commit'); // subject + expect(fields[6]).toBe('Body with ---END--- text'); // body (---END--- handled correctly) + }); + + it('each part should have the expected number of newline-delimited fields', () => { + // Each gitPart is a single field value (no internal newlines), so split('\n') yields 1 field + gitParts.forEach((block) => { + const fields = block.split('\n'); + expect(fields.length).toBe(1); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/prompt-builder.test.ts b/jules_branch/apps/server/tests/unit/lib/prompt-builder.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1577c4aa9621b8c3302e7fe53c2d9f35f495de52 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/prompt-builder.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as utils from '@automaker/utils'; +import * as fs from 'fs/promises'; + +// Mock fs module for the image-handler's readFile calls +vi.mock('fs/promises'); + +describe('prompt-builder.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Setup default mock for fs.readFile to return a valid image buffer + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('fake-image-data')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('buildPromptWithImages', () => { + it('should return plain text when no images provided', async () => { + const result = await utils.buildPromptWithImages('Hello world'); + + expect(result).toEqual({ + content: 'Hello world', + hasImages: false, + }); + }); + + it('should return plain text when imagePaths is empty array', async () => { + const result = await utils.buildPromptWithImages('Hello world', []); + + expect(result).toEqual({ + content: 'Hello world', + hasImages: false, + }); + }); + + it('should build content blocks with single image', async () => { + const result = await utils.buildPromptWithImages('Describe this image', ['/test.png']); + + expect(result.hasImages).toBe(true); + expect(Array.isArray(result.content)).toBe(true); + const content = result.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'Describe this image' }); + expect(content[1].type).toBe('image'); + }); + + it('should build content blocks with multiple images', async () => { + const result = await utils.buildPromptWithImages('Analyze these', ['/a.png', '/b.jpg']); + + expect(result.hasImages).toBe(true); + const content = result.content as Array<{ type: string }>; + expect(content).toHaveLength(3); // 1 text + 2 images + expect(content[0].type).toBe('text'); + expect(content[1].type).toBe('image'); + expect(content[2].type).toBe('image'); + }); + + it('should include image paths in text when requested', async () => { + const result = await utils.buildPromptWithImages( + 'Base prompt', + ['/test.png'], + undefined, + true + ); + + const content = result.content as Array<{ type: string; text?: string }>; + expect(content[0].text).toContain('Base prompt'); + expect(content[0].text).toContain('/test.png'); + }); + + it('should not include image paths by default', async () => { + const result = await utils.buildPromptWithImages('Base prompt', ['/test.png']); + + const content = result.content as Array<{ type: string; text?: string }>; + expect(content[0].text).toBe('Base prompt'); + expect(content[0].text).not.toContain('Attached'); + }); + + it('should handle empty text content', async () => { + const result = await utils.buildPromptWithImages('', ['/test.png']); + + expect(result.hasImages).toBe(true); + // When text is empty/whitespace, should only have image blocks + const content = result.content as Array<{ type: string }>; + expect(content.every((block) => block.type === 'image')).toBe(true); + }); + + it('should trim text content before checking if empty', async () => { + const result = await utils.buildPromptWithImages(' ', ['/test.png']); + + const content = result.content as Array<{ type: string }>; + // Whitespace-only text should be excluded + expect(content.every((block) => block.type === 'image')).toBe(true); + }); + + it("should return text when only one block and it's text", async () => { + // Make readFile reject to simulate image load failure + vi.mocked(fs.readFile).mockRejectedValue(new Error('File not found')); + + const result = await utils.buildPromptWithImages('Just text', ['/missing.png']); + + // If no images are successfully loaded, should return just the text + expect(result.content).toBe('Just text'); + expect(result.hasImages).toBe(true); // Still true because images were requested + }); + + it('should pass workDir for path resolution', async () => { + // The function should use workDir to resolve relative paths + const result = await utils.buildPromptWithImages('Test', ['relative.png'], '/work/dir'); + + // Verify it tried to read the file (with resolved path including workDir) + expect(fs.readFile).toHaveBeenCalled(); + // The path should be resolved using workDir + const readCall = vi.mocked(fs.readFile).mock.calls[0][0]; + expect(readCall).toContain('relative.png'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/sdk-options.test.ts b/jules_branch/apps/server/tests/unit/lib/sdk-options.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f552efd95b16d0b9b33fac87a0b1b68d112bb5aa --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/sdk-options.test.ts @@ -0,0 +1,519 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('sdk-options.ts', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + vi.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('TOOL_PRESETS', () => { + it('should export readOnly tools', async () => { + const { TOOL_PRESETS } = await import('@/lib/sdk-options.js'); + expect(TOOL_PRESETS.readOnly).toEqual(['Read', 'Glob', 'Grep']); + }); + + it('should export specGeneration tools', async () => { + const { TOOL_PRESETS } = await import('@/lib/sdk-options.js'); + expect(TOOL_PRESETS.specGeneration).toEqual(['Read', 'Glob', 'Grep']); + }); + + it('should export fullAccess tools', async () => { + const { TOOL_PRESETS } = await import('@/lib/sdk-options.js'); + expect(TOOL_PRESETS.fullAccess).toContain('Read'); + expect(TOOL_PRESETS.fullAccess).toContain('Write'); + expect(TOOL_PRESETS.fullAccess).toContain('Edit'); + expect(TOOL_PRESETS.fullAccess).toContain('Bash'); + }); + + it('should export chat tools matching fullAccess', async () => { + const { TOOL_PRESETS } = await import('@/lib/sdk-options.js'); + expect(TOOL_PRESETS.chat).toEqual(TOOL_PRESETS.fullAccess); + }); + }); + + describe('MAX_TURNS', () => { + it('should export turn presets', async () => { + const { MAX_TURNS } = await import('@/lib/sdk-options.js'); + expect(MAX_TURNS.quick).toBe(50); + expect(MAX_TURNS.standard).toBe(100); + expect(MAX_TURNS.extended).toBe(250); + expect(MAX_TURNS.maximum).toBe(1000); + }); + }); + + describe('getModelForUseCase', () => { + it('should return explicit model when provided', async () => { + const { getModelForUseCase } = await import('@/lib/sdk-options.js'); + const result = getModelForUseCase('spec', 'claude-sonnet-4-6'); + expect(result).toBe('claude-sonnet-4-6'); + }); + + it('should use environment variable for spec model', async () => { + process.env.AUTOMAKER_MODEL_SPEC = 'claude-sonnet-4-6'; + const { getModelForUseCase } = await import('@/lib/sdk-options.js'); + const result = getModelForUseCase('spec'); + expect(result).toBe('claude-sonnet-4-6'); + }); + + it('should use default model for spec when no override', async () => { + delete process.env.AUTOMAKER_MODEL_SPEC; + delete process.env.AUTOMAKER_MODEL_DEFAULT; + const { getModelForUseCase } = await import('@/lib/sdk-options.js'); + const result = getModelForUseCase('spec'); + expect(result).toContain('claude'); + }); + + it('should fall back to AUTOMAKER_MODEL_DEFAULT', async () => { + delete process.env.AUTOMAKER_MODEL_SPEC; + process.env.AUTOMAKER_MODEL_DEFAULT = 'claude-sonnet-4-6'; + const { getModelForUseCase } = await import('@/lib/sdk-options.js'); + const result = getModelForUseCase('spec'); + expect(result).toBe('claude-sonnet-4-6'); + }); + }); + + describe('createSpecGenerationOptions', () => { + it('should create options with spec generation settings', async () => { + const { createSpecGenerationOptions, TOOL_PRESETS, MAX_TURNS } = + await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ cwd: '/test/path' }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.specGeneration]); + expect(options.permissionMode).toBe('default'); + }); + + it('should include system prompt when provided', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + systemPrompt: 'Custom prompt', + }); + + expect(options.systemPrompt).toBe('Custom prompt'); + }); + + it('should include abort controller when provided', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const abortController = new AbortController(); + const options = createSpecGenerationOptions({ + cwd: '/test/path', + abortController, + }); + + expect(options.abortController).toBe(abortController); + }); + }); + + describe('createFeatureGenerationOptions', () => { + it('should create options with feature generation settings', async () => { + const { createFeatureGenerationOptions, TOOL_PRESETS, MAX_TURNS } = + await import('@/lib/sdk-options.js'); + + const options = createFeatureGenerationOptions({ cwd: '/test/path' }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(MAX_TURNS.quick); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + }); + + describe('createSuggestionsOptions', () => { + it('should create options with suggestions settings', async () => { + const { createSuggestionsOptions, TOOL_PRESETS, MAX_TURNS } = + await import('@/lib/sdk-options.js'); + + const options = createSuggestionsOptions({ cwd: '/test/path' }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(MAX_TURNS.extended); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + + it('should include systemPrompt when provided', async () => { + const { createSuggestionsOptions } = await import('@/lib/sdk-options.js'); + + const options = createSuggestionsOptions({ + cwd: '/test/path', + systemPrompt: 'Custom prompt', + }); + + expect(options.systemPrompt).toBe('Custom prompt'); + }); + + it('should include abortController when provided', async () => { + const { createSuggestionsOptions } = await import('@/lib/sdk-options.js'); + + const abortController = new AbortController(); + const options = createSuggestionsOptions({ + cwd: '/test/path', + abortController, + }); + + expect(options.abortController).toBe(abortController); + }); + + it('should include outputFormat when provided', async () => { + const { createSuggestionsOptions } = await import('@/lib/sdk-options.js'); + + const options = createSuggestionsOptions({ + cwd: '/test/path', + outputFormat: { type: 'json' }, + }); + + expect(options.outputFormat).toEqual({ type: 'json' }); + }); + }); + + describe('createChatOptions', () => { + it('should create options with chat settings', async () => { + const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ cwd: '/test/path' }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(MAX_TURNS.standard); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]); + }); + + it('should prefer explicit model over session model', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/test/path', + model: 'claude-opus-4-20250514', + sessionModel: 'claude-haiku-3-5-20241022', + }); + + expect(options.model).toBe('claude-opus-4-20250514'); + }); + + it('should use session model when explicit model not provided', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/test/path', + sessionModel: 'claude-sonnet-4-6', + }); + + expect(options.model).toBe('claude-sonnet-4-6'); + }); + }); + + describe('createAutoModeOptions', () => { + it('should create options with auto mode settings', async () => { + const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } = + await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ cwd: '/test/path' }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]); + }); + + it('should include systemPrompt when provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + systemPrompt: 'Custom prompt', + }); + + expect(options.systemPrompt).toBe('Custom prompt'); + }); + + it('should include abortController when provided', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const abortController = new AbortController(); + const options = createAutoModeOptions({ + cwd: '/test/path', + abortController, + }); + + expect(options.abortController).toBe(abortController); + }); + }); + + describe('createCustomOptions', () => { + it('should create options with custom settings', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + maxTurns: 10, + allowedTools: ['Read', 'Write'], + }); + + expect(options.cwd).toBe('/test/path'); + expect(options.maxTurns).toBe(10); + expect(options.allowedTools).toEqual(['Read', 'Write']); + }); + + it('should use defaults when optional params not provided', async () => { + const { createCustomOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ cwd: '/test/path' }); + + expect(options.maxTurns).toBe(MAX_TURNS.maximum); + expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); + }); + + it('should include systemPrompt when provided', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + systemPrompt: 'Custom prompt', + }); + + expect(options.systemPrompt).toBe('Custom prompt'); + }); + + it('should include abortController when provided', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const abortController = new AbortController(); + const options = createCustomOptions({ + cwd: '/test/path', + abortController, + }); + + expect(options.abortController).toBe(abortController); + }); + }); + + describe('getThinkingTokenBudget (from @automaker/types)', () => { + it('should return undefined for "none" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('none')).toBeUndefined(); + }); + + it('should return undefined for undefined thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget(undefined)).toBeUndefined(); + }); + + it('should return 1024 for "low" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('low')).toBe(1024); + }); + + it('should return 10000 for "medium" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('medium')).toBe(10000); + }); + + it('should return 16000 for "high" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('high')).toBe(16000); + }); + + it('should return 32000 for "ultrathink" thinking level', async () => { + const { getThinkingTokenBudget } = await import('@automaker/types'); + expect(getThinkingTokenBudget('ultrathink')).toBe(32000); + }); + }); + + describe('THINKING_TOKEN_BUDGET constant', () => { + it('should have correct values for all thinking levels', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + expect(THINKING_TOKEN_BUDGET.none).toBeUndefined(); + expect(THINKING_TOKEN_BUDGET.low).toBe(1024); + expect(THINKING_TOKEN_BUDGET.medium).toBe(10000); + expect(THINKING_TOKEN_BUDGET.high).toBe(16000); + expect(THINKING_TOKEN_BUDGET.ultrathink).toBe(32000); + }); + + it('should have minimum of 1024 for enabled thinking levels', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + // Per Claude SDK docs: minimum is 1024 tokens + expect(THINKING_TOKEN_BUDGET.low).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.medium).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.high).toBeGreaterThanOrEqual(1024); + expect(THINKING_TOKEN_BUDGET.ultrathink).toBeGreaterThanOrEqual(1024); + }); + + it('should have ultrathink at or below 32000 to avoid timeouts', async () => { + const { THINKING_TOKEN_BUDGET } = await import('@automaker/types'); + + // Per Claude SDK docs: above 32000 risks timeouts + expect(THINKING_TOKEN_BUDGET.ultrathink).toBeLessThanOrEqual(32000); + }); + }); + + describe('thinking level integration with SDK options', () => { + describe('createSpecGenerationOptions with thinkingLevel', () => { + it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ cwd: '/test/path' }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should include maxThinkingTokens for "low" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'low', + }); + + expect(options.maxThinkingTokens).toBe(1024); + }); + + it('should include maxThinkingTokens for "high" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'high', + }); + + expect(options.maxThinkingTokens).toBe(16000); + }); + + it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => { + const { createSpecGenerationOptions } = await import('@/lib/sdk-options.js'); + + const options = createSpecGenerationOptions({ + cwd: '/test/path', + thinkingLevel: 'ultrathink', + }); + + expect(options.maxThinkingTokens).toBe(32000); + }); + }); + + describe('createAutoModeOptions with thinkingLevel', () => { + it('should not include maxThinkingTokens when thinkingLevel is undefined', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ cwd: '/test/path' }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should include maxThinkingTokens for "medium" thinkingLevel', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'medium', + }); + + expect(options.maxThinkingTokens).toBe(10000); + }); + + it('should include maxThinkingTokens for "ultrathink" thinkingLevel', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'ultrathink', + }); + + expect(options.maxThinkingTokens).toBe(32000); + }); + }); + + describe('createChatOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createChatOptions } = await import('@/lib/sdk-options.js'); + + const options = createChatOptions({ + cwd: '/test/path', + thinkingLevel: 'high', + }); + + expect(options.maxThinkingTokens).toBe(16000); + }); + }); + + describe('createSuggestionsOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createSuggestionsOptions } = await import('@/lib/sdk-options.js'); + + const options = createSuggestionsOptions({ + cwd: '/test/path', + thinkingLevel: 'low', + }); + + expect(options.maxThinkingTokens).toBe(1024); + }); + }); + + describe('createCustomOptions with thinkingLevel', () => { + it('should include maxThinkingTokens for enabled thinkingLevel', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + thinkingLevel: 'medium', + }); + + expect(options.maxThinkingTokens).toBe(10000); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createCustomOptions } = await import('@/lib/sdk-options.js'); + + const options = createCustomOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); + + describe('adaptive thinking for Opus 4.6', () => { + it('should not set maxThinkingTokens for adaptive thinking (model decides)', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'adaptive', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + + it('should not include maxThinkingTokens when thinkingLevel is "none"', async () => { + const { createAutoModeOptions } = await import('@/lib/sdk-options.js'); + + const options = createAutoModeOptions({ + cwd: '/test/path', + thinkingLevel: 'none', + }); + + expect(options.maxThinkingTokens).toBeUndefined(); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/security.test.ts b/jules_branch/apps/server/tests/unit/lib/security.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd90d5983f941d1b40b1ee401127c50afc4a8ba6 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/security.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import path from 'path'; + +/** + * Note: security.ts maintains module-level state (allowed paths Set). + * We need to reset modules and reimport for each test to get fresh state. + */ +describe('security.ts', () => { + beforeEach(() => { + vi.resetModules(); + }); + + describe('initAllowedPaths', () => { + it('should load ALLOWED_ROOT_DIRECTORY if set', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/projects'; + delete process.env.DATA_DIR; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve('/projects')); + }); + + it('should include DATA_DIR if set', async () => { + delete process.env.ALLOWED_ROOT_DIRECTORY; + process.env.DATA_DIR = '/data/dir'; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve('/data/dir')); + }); + + it('should include both ALLOWED_ROOT_DIRECTORY and DATA_DIR if both set', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/projects'; + process.env.DATA_DIR = '/data'; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toContain(path.resolve('/projects')); + expect(allowed).toContain(path.resolve('/data')); + expect(allowed).toHaveLength(2); + }); + + it('should return empty array when no paths configured', async () => { + delete process.env.ALLOWED_ROOT_DIRECTORY; + delete process.env.DATA_DIR; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const allowed = getAllowedPaths(); + expect(allowed).toHaveLength(0); + }); + }); + + describe('isPathAllowed', () => { + it('should allow paths within ALLOWED_ROOT_DIRECTORY', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/project'; + process.env.DATA_DIR = ''; + + const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform'); + initAllowedPaths(); + + // Paths within allowed directory should be allowed + expect(isPathAllowed('/allowed/project/file.txt')).toBe(true); + expect(isPathAllowed('/allowed/project/subdir/file.txt')).toBe(true); + + // Paths outside allowed directory should be denied + expect(isPathAllowed('/not/allowed/file.txt')).toBe(false); + expect(isPathAllowed('/tmp/file.txt')).toBe(false); + expect(isPathAllowed('/etc/passwd')).toBe(false); + }); + + it('should allow all paths when no restrictions are configured', async () => { + delete process.env.DATA_DIR; + delete process.env.ALLOWED_ROOT_DIRECTORY; + + const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform'); + initAllowedPaths(); + + // All paths should be allowed when no restrictions are configured + expect(isPathAllowed('/allowed/project/file.txt')).toBe(true); + expect(isPathAllowed('/not/allowed/file.txt')).toBe(true); + expect(isPathAllowed('/tmp/file.txt')).toBe(true); + expect(isPathAllowed('/etc/passwd')).toBe(true); + expect(isPathAllowed('/any/path')).toBe(true); + }); + + it('should allow all paths when DATA_DIR is set but ALLOWED_ROOT_DIRECTORY is not', async () => { + process.env.DATA_DIR = '/data'; + delete process.env.ALLOWED_ROOT_DIRECTORY; + + const { initAllowedPaths, isPathAllowed } = await import('@automaker/platform'); + initAllowedPaths(); + + // DATA_DIR should be allowed + expect(isPathAllowed('/data/settings.json')).toBe(true); + // But all other paths should also be allowed when ALLOWED_ROOT_DIRECTORY is not set + expect(isPathAllowed('/allowed/project/file.txt')).toBe(true); + expect(isPathAllowed('/not/allowed/file.txt')).toBe(true); + expect(isPathAllowed('/tmp/file.txt')).toBe(true); + expect(isPathAllowed('/etc/passwd')).toBe(true); + expect(isPathAllowed('/any/path')).toBe(true); + }); + }); + + describe('validatePath', () => { + it('should return resolved path for allowed paths', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/allowed'; + process.env.DATA_DIR = ''; + + const { initAllowedPaths, validatePath } = await import('@automaker/platform'); + initAllowedPaths(); + + const result = validatePath('/allowed/file.txt'); + expect(result).toBe(path.resolve('/allowed/file.txt')); + }); + + it('should throw error for paths outside allowed directories', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/allowed'; + process.env.DATA_DIR = ''; + + const { initAllowedPaths, validatePath } = await import('@automaker/platform'); + initAllowedPaths(); + + // Disallowed paths should throw PathNotAllowedError + expect(() => validatePath('/disallowed/file.txt')).toThrow(); + }); + + it('should not throw error for any path when no restrictions are configured', async () => { + delete process.env.DATA_DIR; + delete process.env.ALLOWED_ROOT_DIRECTORY; + + const { initAllowedPaths, validatePath } = await import('@automaker/platform'); + initAllowedPaths(); + + // All paths are allowed when no restrictions configured + expect(() => validatePath('/disallowed/file.txt')).not.toThrow(); + expect(validatePath('/disallowed/file.txt')).toBe(path.resolve('/disallowed/file.txt')); + }); + + it('should resolve relative paths within allowed directory', async () => { + const cwd = process.cwd(); + process.env.ALLOWED_ROOT_DIRECTORY = cwd; + process.env.DATA_DIR = ''; + + const { initAllowedPaths, validatePath } = await import('@automaker/platform'); + initAllowedPaths(); + + const result = validatePath('./file.txt'); + expect(result).toBe(path.resolve(cwd, './file.txt')); + }); + }); + + describe('getAllowedPaths', () => { + it('should return array of allowed paths', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/projects'; + process.env.DATA_DIR = '/data'; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const result = getAllowedPaths(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); + expect(result).toContain(path.resolve('/projects')); + expect(result).toContain(path.resolve('/data')); + }); + + it('should return resolved paths', async () => { + process.env.ALLOWED_ROOT_DIRECTORY = '/test'; + process.env.DATA_DIR = ''; + + const { initAllowedPaths, getAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const result = getAllowedPaths(); + expect(result[0]).toBe(path.resolve('/test')); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/settings-helpers.test.ts b/jules_branch/apps/server/tests/unit/lib/settings-helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..edaa74d03d8c99221a8fe19235b42e02b41c94b3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/settings-helpers.test.ts @@ -0,0 +1,982 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getMCPServersFromSettings, + getProviderById, + getProviderByModelId, + resolveProviderContext, + getAllProviderModels, +} from '@/lib/settings-helpers.js'; +import type { SettingsService } from '@/services/settings-service.js'; + +// Mock the logger +vi.mock('@automaker/utils', async () => { + const actual = await vi.importActual('@automaker/utils'); + const mockLogger = { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }; + return { + ...actual, + createLogger: () => mockLogger, + }; +}); + +describe('settings-helpers.ts', () => { + describe('getMCPServersFromSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return empty object when settingsService is null', async () => { + const result = await getMCPServersFromSettings(null); + expect(result).toEqual({}); + }); + + it('should return empty object when settingsService is undefined', async () => { + const result = await getMCPServersFromSettings(undefined); + expect(result).toEqual({}); + }); + + it('should return empty object when no MCP servers configured', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should return empty object when mcpServers is undefined', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should convert enabled stdio server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'test-server', + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'test' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'test-server': { + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'test' }, + }, + }); + }); + + it('should convert enabled SSE server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'sse-server', + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'sse-server': { + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + }, + }); + }); + + it('should convert enabled HTTP server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'http-server', + type: 'http', + url: 'http://localhost:3000/api', + headers: { 'X-API-Key': 'secret' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'http-server': { + type: 'http', + url: 'http://localhost:3000/api', + headers: { 'X-API-Key': 'secret' }, + }, + }); + }); + + it('should filter out disabled servers', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'enabled-server', + type: 'stdio', + command: 'node', + enabled: true, + }, + { + id: '2', + name: 'disabled-server', + type: 'stdio', + command: 'python', + enabled: false, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(Object.keys(result)).toHaveLength(1); + expect(result['enabled-server']).toBeDefined(); + expect(result['disabled-server']).toBeUndefined(); + }); + + it('should treat servers without enabled field as enabled', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'implicit-enabled', + type: 'stdio', + command: 'node', + // enabled field not set + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result['implicit-enabled']).toBeDefined(); + }); + + it('should handle multiple enabled servers', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true }, + { id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(Object.keys(result)).toHaveLength(2); + expect(result['server1']).toBeDefined(); + expect(result['server2']).toBeDefined(); + }); + + it('should return empty object and log error on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService, '[Test]'); + expect(result).toEqual({}); + // Logger will be called with error, but we don't need to assert it + }); + + it('should throw error for SSE server without URL', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-sse', + type: 'sse', + enabled: true, + // url missing + }, + ], + }), + } as unknown as SettingsService; + + // The error is caught and logged, returns empty + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should throw error for HTTP server without URL', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-http', + type: 'http', + enabled: true, + // url missing + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should throw error for stdio server without command', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-stdio', + type: 'stdio', + enabled: true, + // command missing + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should default to stdio type when type is not specified', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'no-type', + command: 'node', + enabled: true, + // type not specified, should default to stdio + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result['no-type']).toEqual({ + type: 'stdio', + command: 'node', + args: undefined, + env: undefined, + }); + }); + }); + + describe('getProviderById', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return provider when found by ID', async () => { + const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('zai-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + }); + + it('should return undefined when provider not found', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('unknown', mockSettingsService); + expect(result.provider).toBeUndefined(); + }); + + it('should return provider even if disabled (caller handles enabled state)', async () => { + const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('disabled-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + }); + }); + + describe('getProviderByModelId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return provider and modelConfig when found by model ID', async () => { + const mockModel = { id: 'custom-model-1', name: 'Custom Model' }; + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig).toEqual(mockModel); + }); + + it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => { + const mockModel = { + id: 'custom-model-1', + name: 'Custom Model', + mapsToClaudeModel: 'sonnet-3-5', + }; + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.resolvedModel).toBeDefined(); + // resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar + }); + + it('should ignore disabled providers', async () => { + const mockModel = { id: 'custom-model-1', name: 'Custom Model' }; + const mockProvider = { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.provider).toBeUndefined(); + }); + }); + + describe('resolveProviderContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should resolve provider by explicit providerId', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'custom-model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'custom-model-1', + 'provider-1' + ); + + expect(result.provider).toEqual(mockProvider); + expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' }); + }); + + it('should return undefined provider when explicit providerId not found', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'some-model', + 'unknown-provider' + ); + + expect(result.provider).toBeUndefined(); + }); + + it('should fallback to model-based lookup when providerId not provided', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'custom-model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('custom-model-1'); + }); + + it('should resolve mapsToClaudeModel to actual Claude model', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [ + { + id: 'custom-model-1', + name: 'Custom Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + // resolveModelString('sonnet') should return a valid Claude model ID + expect(result.resolvedModel).toBeDefined(); + expect(result.resolvedModel).toContain('claude'); + }); + + it('should handle empty providers list', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + expect(result.resolvedModel).toBeUndefined(); + expect(result.modelConfig).toBeUndefined(); + }); + + it('should handle missing claudeCompatibleProviders field', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + }); + + it('should skip disabled providers during fallback lookup', async () => { + const disabledProvider = { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [{ id: 'model-in-disabled', name: 'Model' }], + }; + const enabledProvider = { + id: 'enabled-1', + name: 'Enabled Provider', + enabled: true, + models: [{ id: 'model-in-enabled', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [disabledProvider, enabledProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + // Should skip the disabled provider and find the model in the enabled one + const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled'); + expect(result.provider?.id).toBe('enabled-1'); + + // Should not find model that only exists in disabled provider + const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled'); + expect(result2.provider).toBeUndefined(); + }); + + it('should perform case-insensitive model ID matching', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'Custom-Model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('Custom-Model-1'); + }); + + it('should return error result on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + expect(result.credentials).toBeUndefined(); + expect(result.resolvedModel).toBeUndefined(); + expect(result.modelConfig).toBeUndefined(); + }); + + it('should persist and load provider config from server settings', async () => { + // This test verifies the main bug fix: providers are loaded from server settings + const savedProvider = { + id: 'saved-provider-1', + name: 'Saved Provider', + enabled: true, + apiKeySource: 'credentials' as const, + models: [ + { + id: 'saved-model-1', + name: 'Saved Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [savedProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ + anthropicApiKey: 'saved-api-key', + }), + } as unknown as SettingsService; + + // Simulate loading saved provider config + const result = await resolveProviderContext( + mockSettingsService, + 'saved-model-1', + 'saved-provider-1' + ); + + // Verify the provider is loaded from server settings + expect(result.provider).toEqual(savedProvider); + expect(result.provider?.id).toBe('saved-provider-1'); + expect(result.provider?.models).toHaveLength(1); + expect(result.credentials?.anthropicApiKey).toBe('saved-api-key'); + // Verify model mapping is resolved + expect(result.resolvedModel).toContain('claude'); + }); + + it('should accept custom logPrefix parameter', async () => { + // Verify that the logPrefix parameter is accepted (used by facade.ts) + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + // Call with custom logPrefix (as facade.ts does) + const result = await resolveProviderContext( + mockSettingsService, + 'model-1', + undefined, + '[CustomPrefix]' + ); + + // Function should work the same with custom prefix + expect(result.provider).toEqual(mockProvider); + }); + + // Session restore scenarios - provider.enabled: undefined should be treated as enabled + describe('session restore scenarios (enabled: undefined)', () => { + it('should treat provider with enabled: undefined as enabled', async () => { + // This is the main bug fix: when providers are loaded from settings on session restore, + // enabled might be undefined (not explicitly set) and should be treated as enabled + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: undefined, // Not explicitly set - should be treated as enabled + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'model-1'); + + // Provider should be found and used even though enabled is undefined + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('model-1'); + }); + + it('should use provider by ID when enabled is undefined', async () => { + // This tests the explicit providerId lookup with undefined enabled + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: undefined, // Not explicitly set - should be treated as enabled + models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'custom-model', + 'provider-1' + ); + + // Provider should be found and used even though enabled is undefined + expect(result.provider).toEqual(mockProvider); + expect(result.credentials?.anthropicApiKey).toBe('test-key'); + expect(result.resolvedModel).toContain('claude'); + }); + + it('should find model via fallback in provider with enabled: undefined', async () => { + // Test fallback model lookup when provider has undefined enabled + const providerWithUndefinedEnabled = { + id: 'provider-1', + name: 'Provider 1', + // enabled is not set (undefined) + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [providerWithUndefinedEnabled], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'model-1'); + + expect(result.provider).toEqual(providerWithUndefinedEnabled); + expect(result.modelConfig?.id).toBe('model-1'); + }); + + it('should still use provider for connection when model not found in its models array', async () => { + // This tests the fix: when providerId is explicitly set and provider is found, + // but the model isn't in that provider's models array, we still use that provider + // for connection settings (baseUrl, credentials) + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + baseUrl: 'https://custom-api.example.com', + models: [{ id: 'other-model', name: 'Other Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'unknown-model', // Model not in provider's models array + 'provider-1' + ); + + // Provider should still be returned for connection settings + expect(result.provider).toEqual(mockProvider); + // modelConfig should be undefined since the model wasn't found + expect(result.modelConfig).toBeUndefined(); + // resolvedModel should be undefined since no mapping was found + expect(result.resolvedModel).toBeUndefined(); + }); + + it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => { + // When providerId is set and provider is found, but model isn't there, + // we should still search for modelConfig in other providers + const provider1 = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + baseUrl: 'https://provider1.example.com', + models: [{ id: 'provider1-model', name: 'Provider 1 Model' }], + }; + const provider2 = { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + baseUrl: 'https://provider2.example.com', + models: [ + { + id: 'shared-model', + name: 'Shared Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [provider1, provider2], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'shared-model', // This model is in provider-2, not provider-1 + 'provider-1' // But we explicitly want to use provider-1 + ); + + // Provider should still be provider-1 (for connection settings) + expect(result.provider).toEqual(provider1); + // But modelConfig should be found from provider-2 + expect(result.modelConfig?.id).toBe('shared-model'); + // And the model mapping should be resolved + expect(result.resolvedModel).toContain('claude'); + }); + + it('should handle multiple providers with mixed enabled states', async () => { + // Test the full session restore scenario with multiple providers + const providers = [ + { + id: 'provider-1', + name: 'First Provider', + enabled: undefined, // Undefined after restore + models: [{ id: 'model-a', name: 'Model A' }], + }, + { + id: 'provider-2', + name: 'Second Provider', + // enabled field missing entirely + models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }], + }, + { + id: 'provider-3', + name: 'Disabled Provider', + enabled: false, // Explicitly disabled + models: [{ id: 'model-c', name: 'Model C' }], + }, + ]; + + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: providers, + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + // Provider 1 should work (enabled: undefined) + const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1'); + expect(result1.provider?.id).toBe('provider-1'); + expect(result1.modelConfig?.id).toBe('model-a'); + + // Provider 2 should work (enabled field missing) + const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2'); + expect(result2.provider?.id).toBe('provider-2'); + expect(result2.modelConfig?.id).toBe('model-b'); + expect(result2.resolvedModel).toContain('claude'); + + // Provider 3 with explicit providerId IS returned even if disabled + // (caller handles enabled state check) + const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3'); + // Provider is found but modelConfig won't be found since disabled providers + // skip model lookup in their models array + expect(result3.provider).toEqual(providers[2]); + expect(result3.modelConfig).toBeUndefined(); + }); + }); + }); + + describe('getAllProviderModels', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return all models from enabled providers', async () => { + const mockProviders = [ + { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [ + { id: 'model-1', name: 'Model 1' }, + { id: 'model-2', name: 'Model 2' }, + ], + }, + { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + models: [{ id: 'model-3', name: 'Model 3' }], + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toHaveLength(3); + expect(result[0].providerId).toBe('provider-1'); + expect(result[0].model.id).toBe('model-1'); + expect(result[2].providerId).toBe('provider-2'); + }); + + it('should filter out disabled providers', async () => { + const mockProviders = [ + { + id: 'enabled-1', + name: 'Enabled Provider', + enabled: true, + models: [{ id: 'model-1', name: 'Model 1' }], + }, + { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [{ id: 'model-2', name: 'Model 2' }], + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toHaveLength(1); + expect(result[0].providerId).toBe('enabled-1'); + }); + + it('should return empty array when no providers configured', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should handle missing claudeCompatibleProviders field', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should handle provider with no models', async () => { + const mockProviders = [ + { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [], + }, + { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + // no models field + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should return empty array on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/thinking-level-normalization.test.ts b/jules_branch/apps/server/tests/unit/lib/thinking-level-normalization.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..35f1b6e0455f447eacfd23460d56f312e289eac1 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/thinking-level-normalization.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeThinkingLevelForModel } from '@automaker/types'; + +describe('normalizeThinkingLevelForModel', () => { + it('preserves explicitly selected none for Opus models', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'none')).toBe('none'); + }); + + it('falls back to none when Opus receives an unsupported manual thinking level', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'medium')).toBe('none'); + }); + + it('keeps adaptive for Opus when adaptive is selected', () => { + expect(normalizeThinkingLevelForModel('claude-opus', 'adaptive')).toBe('adaptive'); + }); + + it('preserves supported manual levels for non-Opus models', () => { + expect(normalizeThinkingLevelForModel('claude-sonnet', 'high')).toBe('high'); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/validation-storage.test.ts b/jules_branch/apps/server/tests/unit/lib/validation-storage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..05b44fc7987fb72fa87c35af563f744ec33e8dc3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/validation-storage.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + writeValidation, + readValidation, + getAllValidations, + deleteValidation, + isValidationStale, + getValidationWithFreshness, + markValidationViewed, + getUnviewedValidationsCount, + type StoredValidation, +} from '@/lib/validation-storage.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('validation-storage.ts', () => { + let testProjectPath: string; + + beforeEach(async () => { + testProjectPath = path.join(os.tmpdir(), `validation-storage-test-${Date.now()}`); + await fs.mkdir(testProjectPath, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + const createMockValidation = (overrides: Partial = {}): StoredValidation => ({ + issueNumber: 123, + issueTitle: 'Test Issue', + validatedAt: new Date().toISOString(), + model: 'haiku', + result: { + verdict: 'valid', + confidence: 'high', + reasoning: 'Test reasoning', + }, + ...overrides, + }); + + describe('writeValidation', () => { + it('should write validation to storage', async () => { + const validation = createMockValidation(); + + await writeValidation(testProjectPath, 123, validation); + + // Verify file was created + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '123', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + + it('should create nested directories if they do not exist', async () => { + const validation = createMockValidation({ issueNumber: 456 }); + + await writeValidation(testProjectPath, 456, validation); + + const validationPath = path.join( + testProjectPath, + '.automaker', + 'validations', + '456', + 'validation.json' + ); + const content = await fs.readFile(validationPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(validation); + }); + }); + + describe('readValidation', () => { + it('should read validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await readValidation(testProjectPath, 123); + + expect(result).toEqual(validation); + }); + + it('should return null when validation does not exist', async () => { + const result = await readValidation(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('getAllValidations', () => { + it('should return all validations for a project', async () => { + const validation1 = createMockValidation({ issueNumber: 1, issueTitle: 'Issue 1' }); + const validation2 = createMockValidation({ issueNumber: 2, issueTitle: 'Issue 2' }); + const validation3 = createMockValidation({ issueNumber: 3, issueTitle: 'Issue 3' }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual(validation1); + expect(result[1]).toEqual(validation2); + expect(result[2]).toEqual(validation3); + }); + + it('should return empty array when no validations exist', async () => { + const result = await getAllValidations(testProjectPath); + + expect(result).toEqual([]); + }); + + it('should skip non-numeric directories', async () => { + const validation = createMockValidation({ issueNumber: 1 }); + await writeValidation(testProjectPath, 1, validation); + + // Create a non-numeric directory + const invalidDir = path.join(testProjectPath, '.automaker', 'validations', 'invalid'); + await fs.mkdir(invalidDir, { recursive: true }); + + const result = await getAllValidations(testProjectPath); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual(validation); + }); + }); + + describe('deleteValidation', () => { + it('should delete validation from storage', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await deleteValidation(testProjectPath, 123); + + expect(result).toBe(true); + + const readResult = await readValidation(testProjectPath, 123); + expect(readResult).toBeNull(); + }); + + it('should return true even when validation does not exist', async () => { + const result = await deleteValidation(testProjectPath, 999); + + expect(result).toBe(true); + }); + }); + + describe('isValidationStale', () => { + it('should return false for recent validation', () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + + it('should return true for validation older than 24 hours', () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); // 25 hours ago + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(true); + }); + + it('should return false for validation exactly at 24 hours', () => { + const exactDate = new Date(Date.now() - 24 * 60 * 60 * 1000 + 100); + + const validation = createMockValidation({ + validatedAt: exactDate.toISOString(), + }); + + const result = isValidationStale(validation); + + expect(result).toBe(false); + }); + }); + + describe('getValidationWithFreshness', () => { + it('should return validation with isStale false for recent validation', async () => { + const validation = createMockValidation({ + validatedAt: new Date().toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.validation).toEqual(validation); + expect(result!.isStale).toBe(false); + }); + + it('should return validation with isStale true for old validation', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation = createMockValidation({ + validatedAt: oldDate.toISOString(), + }); + await writeValidation(testProjectPath, 123, validation); + + const result = await getValidationWithFreshness(testProjectPath, 123); + + expect(result).not.toBeNull(); + expect(result!.isStale).toBe(true); + }); + + it('should return null when validation does not exist', async () => { + const result = await getValidationWithFreshness(testProjectPath, 999); + + expect(result).toBeNull(); + }); + }); + + describe('markValidationViewed', () => { + it('should mark validation as viewed', async () => { + const validation = createMockValidation(); + await writeValidation(testProjectPath, 123, validation); + + const result = await markValidationViewed(testProjectPath, 123); + + expect(result).toBe(true); + + const updated = await readValidation(testProjectPath, 123); + expect(updated).not.toBeNull(); + expect(updated!.viewedAt).toBeDefined(); + }); + + it('should return false when validation does not exist', async () => { + const result = await markValidationViewed(testProjectPath, 999); + + expect(result).toBe(false); + }); + }); + + describe('getUnviewedValidationsCount', () => { + it('should return count of unviewed non-stale validations', async () => { + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ issueNumber: 2 }); + const validation3 = createMockValidation({ + issueNumber: 3, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + await writeValidation(testProjectPath, 3, validation3); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(2); + }); + + it('should not count stale validations', async () => { + const oldDate = new Date(); + oldDate.setHours(oldDate.getHours() - 25); + + const validation1 = createMockValidation({ issueNumber: 1 }); + const validation2 = createMockValidation({ + issueNumber: 2, + validatedAt: oldDate.toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation1); + await writeValidation(testProjectPath, 2, validation2); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(1); + }); + + it('should return 0 when no validations exist', async () => { + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + + it('should return 0 when all validations are viewed', async () => { + const validation = createMockValidation({ + issueNumber: 1, + viewedAt: new Date().toISOString(), + }); + + await writeValidation(testProjectPath, 1, validation); + + const result = await getUnviewedValidationsCount(testProjectPath); + + expect(result).toBe(0); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/worktree-metadata.test.ts b/jules_branch/apps/server/tests/unit/lib/worktree-metadata.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f84af883e3a9bbd0afc02d3285596a4d7fda886 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/worktree-metadata.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + readWorktreeMetadata, + writeWorktreeMetadata, + updateWorktreePRInfo, + getWorktreePRInfo, + readAllWorktreeMetadata, + deleteWorktreeMetadata, + type WorktreeMetadata, + type WorktreePRInfo, +} from '@/lib/worktree-metadata.js'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('worktree-metadata.ts', () => { + let testProjectPath: string; + + beforeEach(async () => { + testProjectPath = path.join(os.tmpdir(), `worktree-metadata-test-${Date.now()}`); + await fs.mkdir(testProjectPath, { recursive: true }); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('sanitizeBranchName', () => { + // Test through readWorktreeMetadata and writeWorktreeMetadata + it('should sanitize branch names with invalid characters', async () => { + const branch = 'feature/test-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should sanitize branch names with Windows invalid characters', async () => { + const branch = 'feature:test*branch?'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should sanitize Windows reserved names', async () => { + const branch = 'CON'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should handle empty branch name', async () => { + const branch = ''; + const metadata: WorktreeMetadata = { + branch: 'branch', + createdAt: new Date().toISOString(), + }; + + // Empty branch name should be sanitized to "_branch" + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should handle branch name that becomes empty after sanitization', async () => { + // Test branch that would become empty after removing invalid chars + const branch = '///'; + const metadata: WorktreeMetadata = { + branch: 'branch', + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + }); + + describe('readWorktreeMetadata', () => { + it("should return null when metadata file doesn't exist", async () => { + const result = await readWorktreeMetadata(testProjectPath, 'nonexistent-branch'); + expect(result).toBeNull(); + }); + + it('should read existing metadata', async () => { + const branch = 'test-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should read metadata with PR info', async () => { + const branch = 'pr-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + pr: { + number: 123, + url: 'https://github.com/owner/repo/pull/123', + title: 'Test PR', + state: 'OPEN', + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + }); + + describe('writeWorktreeMetadata', () => { + it("should create metadata directory if it doesn't exist", async () => { + const branch = 'new-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata); + }); + + it('should overwrite existing metadata', async () => { + const branch = 'existing-branch'; + const metadata1: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + const metadata2: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + pr: { + number: 456, + url: 'https://github.com/owner/repo/pull/456', + title: 'Updated PR', + state: 'CLOSED', + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata1); + await writeWorktreeMetadata(testProjectPath, branch, metadata2); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toEqual(metadata2); + }); + }); + + describe('updateWorktreePRInfo', () => { + it("should create new metadata if it doesn't exist", async () => { + const branch = 'new-pr-branch'; + const prInfo: WorktreePRInfo = { + number: 789, + url: 'https://github.com/owner/repo/pull/789', + title: 'New PR', + state: 'OPEN', + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).not.toBeNull(); + expect(result?.branch).toBe(branch); + expect(result?.pr).toEqual(prInfo); + }); + + it('should update existing metadata with PR info', async () => { + const branch = 'existing-pr-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const prInfo: WorktreePRInfo = { + number: 999, + url: 'https://github.com/owner/repo/pull/999', + title: 'Updated PR', + state: 'MERGED', + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result?.pr).toEqual(prInfo); + }); + + it('should preserve existing metadata when updating PR info', async () => { + const branch = 'preserve-branch'; + const originalCreatedAt = new Date().toISOString(); + const metadata: WorktreeMetadata = { + branch, + createdAt: originalCreatedAt, + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const prInfo: WorktreePRInfo = { + number: 111, + url: 'https://github.com/owner/repo/pull/111', + title: 'PR', + state: 'OPEN', + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await readWorktreeMetadata(testProjectPath, branch); + expect(result?.createdAt).toBe(originalCreatedAt); + expect(result?.pr).toEqual(prInfo); + }); + }); + + describe('getWorktreePRInfo', () => { + it("should return null when metadata doesn't exist", async () => { + const result = await getWorktreePRInfo(testProjectPath, 'nonexistent'); + expect(result).toBeNull(); + }); + + it('should return null when metadata exists but has no PR info', async () => { + const branch = 'no-pr-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + const result = await getWorktreePRInfo(testProjectPath, branch); + expect(result).toBeNull(); + }); + + it('should return PR info when it exists', async () => { + const branch = 'has-pr-branch'; + const prInfo: WorktreePRInfo = { + number: 222, + url: 'https://github.com/owner/repo/pull/222', + title: 'Has PR', + state: 'OPEN', + createdAt: new Date().toISOString(), + }; + + await updateWorktreePRInfo(testProjectPath, branch, prInfo); + const result = await getWorktreePRInfo(testProjectPath, branch); + expect(result).toEqual(prInfo); + }); + }); + + describe('readAllWorktreeMetadata', () => { + it("should return empty map when worktrees directory doesn't exist", async () => { + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(0); + }); + + it('should return empty map when worktrees directory is empty', async () => { + const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees'); + await fs.mkdir(worktreesDir, { recursive: true }); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(0); + }); + + it('should read all worktree metadata', async () => { + const branch1 = 'branch-1'; + const branch2 = 'branch-2'; + const metadata1: WorktreeMetadata = { + branch: branch1, + createdAt: new Date().toISOString(), + }; + const metadata2: WorktreeMetadata = { + branch: branch2, + createdAt: new Date().toISOString(), + pr: { + number: 333, + url: 'https://github.com/owner/repo/pull/333', + title: 'PR 3', + state: 'OPEN', + createdAt: new Date().toISOString(), + }, + }; + + await writeWorktreeMetadata(testProjectPath, branch1, metadata1); + await writeWorktreeMetadata(testProjectPath, branch2, metadata2); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(2); + expect(result.get(branch1)).toEqual(metadata1); + expect(result.get(branch2)).toEqual(metadata2); + }); + + it('should skip directories without worktree.json', async () => { + const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees'); + const emptyDir = path.join(worktreesDir, 'empty-dir'); + await fs.mkdir(emptyDir, { recursive: true }); + + const branch = 'valid-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + + it('should skip files in worktrees directory', async () => { + const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees'); + await fs.mkdir(worktreesDir, { recursive: true }); + const filePath = path.join(worktreesDir, 'not-a-dir.txt'); + await fs.writeFile(filePath, 'content'); + + const branch = 'valid-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + + it('should skip directories with malformed JSON', async () => { + const worktreesDir = path.join(testProjectPath, '.automaker', 'worktrees'); + const badDir = path.join(worktreesDir, 'bad-dir'); + await fs.mkdir(badDir, { recursive: true }); + const badJsonPath = path.join(badDir, 'worktree.json'); + await fs.writeFile(badJsonPath, 'not valid json'); + + const branch = 'valid-branch'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + await writeWorktreeMetadata(testProjectPath, branch, metadata); + + const result = await readAllWorktreeMetadata(testProjectPath); + expect(result.size).toBe(1); + expect(result.get(branch)).toEqual(metadata); + }); + }); + + describe('deleteWorktreeMetadata', () => { + it('should delete worktree metadata directory', async () => { + const branch = 'to-delete'; + const metadata: WorktreeMetadata = { + branch, + createdAt: new Date().toISOString(), + }; + + await writeWorktreeMetadata(testProjectPath, branch, metadata); + let result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).not.toBeNull(); + + await deleteWorktreeMetadata(testProjectPath, branch); + result = await readWorktreeMetadata(testProjectPath, branch); + expect(result).toBeNull(); + }); + + it("should handle deletion when metadata doesn't exist", async () => { + // Should not throw + await expect(deleteWorktreeMetadata(testProjectPath, 'nonexistent')).resolves.toBeUndefined(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/lib/xml-extractor.test.ts b/jules_branch/apps/server/tests/unit/lib/xml-extractor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..750a5f33d1933f660dadbe23724dcb5c0f96786c --- /dev/null +++ b/jules_branch/apps/server/tests/unit/lib/xml-extractor.test.ts @@ -0,0 +1,1027 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + escapeXml, + unescapeXml, + extractXmlSection, + extractXmlElements, + extractImplementedFeatures, + extractImplementedFeatureNames, + featureToXml, + featuresToXml, + updateImplementedFeaturesSection, + addImplementedFeature, + removeImplementedFeature, + updateImplementedFeature, + hasImplementedFeature, + toSpecOutputFeatures, + fromSpecOutputFeatures, + type ImplementedFeature, + type XmlExtractorLogger, +} from '@/lib/xml-extractor.js'; + +describe('xml-extractor.ts', () => { + // Mock logger for testing custom logger functionality + const createMockLogger = (): XmlExtractorLogger & { calls: string[] } => { + const calls: string[] = []; + return { + calls, + debug: vi.fn((msg: string) => calls.push(`debug: ${msg}`)), + warn: vi.fn((msg: string) => calls.push(`warn: ${msg}`)), + }; + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('escapeXml', () => { + it('should escape ampersand', () => { + expect(escapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should escape less than', () => { + expect(escapeXml('a < b')).toBe('a < b'); + }); + + it('should escape greater than', () => { + expect(escapeXml('a > b')).toBe('a > b'); + }); + + it('should escape double quotes', () => { + expect(escapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should escape single quotes', () => { + expect(escapeXml("it's" + ' fine')).toBe('it's fine'); + }); + + it('should handle null', () => { + expect(escapeXml(null)).toBe(''); + }); + + it('should handle undefined', () => { + expect(escapeXml(undefined)).toBe(''); + }); + + it('should handle empty string', () => { + expect(escapeXml('')).toBe(''); + }); + + it('should escape multiple special characters', () => { + expect(escapeXml('a < b & c > d "e" \'f\'')).toBe( + 'a < b & c > d "e" 'f'' + ); + }); + }); + + describe('unescapeXml', () => { + it('should unescape ampersand', () => { + expect(unescapeXml('foo & bar')).toBe('foo & bar'); + }); + + it('should unescape less than', () => { + expect(unescapeXml('a < b')).toBe('a < b'); + }); + + it('should unescape greater than', () => { + expect(unescapeXml('a > b')).toBe('a > b'); + }); + + it('should unescape double quotes', () => { + expect(unescapeXml('say "hello"')).toBe('say "hello"'); + }); + + it('should unescape single quotes', () => { + expect(unescapeXml('it's fine')).toBe("it's fine"); + }); + + it('should handle empty string', () => { + expect(unescapeXml('')).toBe(''); + }); + + it('should roundtrip with escapeXml', () => { + const original = 'Test & "quoted" \'apostrophe\''; + expect(unescapeXml(escapeXml(original))).toBe(original); + }); + }); + + describe('extractXmlSection', () => { + it('should extract section content', () => { + const xml = '
content here
'; + expect(extractXmlSection(xml, 'section')).toBe('content here'); + }); + + it('should extract multiline section content', () => { + const xml = ` +
+ line 1 + line 2 +
+
`; + expect(extractXmlSection(xml, 'section')).toContain('line 1'); + expect(extractXmlSection(xml, 'section')).toContain('line 2'); + }); + + it('should return null for non-existent section', () => { + const xml = 'content'; + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should be case-insensitive', () => { + const xml = '
content
'; + expect(extractXmlSection(xml, 'section')).toBe('content'); + }); + + it('should handle empty section', () => { + const xml = '
'; + expect(extractXmlSection(xml, 'section')).toBe(''); + }); + }); + + describe('extractXmlElements', () => { + it('should extract all element values', () => { + const xml = 'onetwothree'; + expect(extractXmlElements(xml, 'item')).toEqual(['one', 'two', 'three']); + }); + + it('should return empty array for non-existent elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual([]); + }); + + it('should trim whitespace', () => { + const xml = ' spaced '; + expect(extractXmlElements(xml, 'item')).toEqual(['spaced']); + }); + + it('should unescape XML entities', () => { + const xml = 'foo & bar'; + expect(extractXmlElements(xml, 'item')).toEqual(['foo & bar']); + }); + + it('should handle empty elements', () => { + const xml = 'value'; + expect(extractXmlElements(xml, 'item')).toEqual(['', 'value']); + }); + }); + + describe('extractImplementedFeatures', () => { + const sampleSpec = ` + + Test Project + + + Feature One + First feature description + + + Feature Two + Second feature description + + src/feature-two.ts + src/utils/helper.ts + + + +`; + + it('should extract all features', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features).toHaveLength(2); + }); + + it('should extract feature names', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].name).toBe('Feature One'); + expect(features[1].name).toBe('Feature Two'); + }); + + it('should extract feature descriptions', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].description).toBe('First feature description'); + expect(features[1].description).toBe('Second feature description'); + }); + + it('should extract file_locations when present', () => { + const features = extractImplementedFeatures(sampleSpec); + expect(features[0].file_locations).toBeUndefined(); + expect(features[1].file_locations).toEqual(['src/feature-two.ts', 'src/utils/helper.ts']); + }); + + it('should return empty array for missing section', () => { + const xml = + 'Test'; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should return empty array for empty section', () => { + const xml = ` + + + `; + expect(extractImplementedFeatures(xml)).toEqual([]); + }); + + it('should handle escaped content', () => { + const xml = ` + + Test & Feature + Uses <brackets> + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses '); + }); + }); + + describe('extractImplementedFeatureNames', () => { + it('should return only feature names', () => { + const xml = ` + + Feature A + Description A + + + Feature B + Description B + + `; + expect(extractImplementedFeatureNames(xml)).toEqual(['Feature A', 'Feature B']); + }); + + it('should return empty array for no features', () => { + const xml = ''; + expect(extractImplementedFeatureNames(xml)).toEqual([]); + }); + }); + + describe('featureToXml', () => { + it('should generate XML for feature without file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + }; + const xml = featureToXml(feature); + expect(xml).toContain('My Feature'); + expect(xml).toContain('Feature description'); + expect(xml).not.toContain(''); + }); + + it('should generate XML for feature with file_locations', () => { + const feature: ImplementedFeature = { + name: 'My Feature', + description: 'Feature description', + file_locations: ['src/index.ts', 'src/utils.ts'], + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + expect(xml).toContain('src/index.ts'); + expect(xml).toContain('src/utils.ts'); + }); + + it('should escape special characters', () => { + const feature: ImplementedFeature = { + name: 'Test & Feature', + description: 'Has ', + }; + const xml = featureToXml(feature); + expect(xml).toContain('Test & Feature'); + expect(xml).toContain('Has <tags>'); + }); + + it('should not include empty file_locations array', () => { + const feature: ImplementedFeature = { + name: 'Feature', + description: 'Desc', + file_locations: [], + }; + const xml = featureToXml(feature); + expect(xml).not.toContain(''); + }); + }); + + describe('featuresToXml', () => { + it('should generate XML for multiple features', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2' }, + ]; + const xml = featuresToXml(features); + expect(xml).toContain('Feature 1'); + expect(xml).toContain('Feature 2'); + }); + + it('should handle empty array', () => { + expect(featuresToXml([])).toBe(''); + }); + }); + + describe('updateImplementedFeaturesSection', () => { + const baseSpec = ` + + Test + + Testing + + + + Old Feature + Old description + + +`; + + it('should replace existing section', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('New Feature'); + expect(result).not.toContain('Old Feature'); + }); + + it('should insert section after core_capabilities if missing', () => { + const specWithoutSection = ` + + Test + + Testing + +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutSection, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + }); + + it('should handle multiple features', () => { + const newFeatures: ImplementedFeature[] = [ + { name: 'Feature A', description: 'Desc A' }, + { name: 'Feature B', description: 'Desc B', file_locations: ['src/b.ts'] }, + ]; + const result = updateImplementedFeaturesSection(baseSpec, newFeatures); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + expect(result).toContain('src/b.ts'); + }); + }); + + describe('addImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Existing description + + `; + + it('should add new feature', () => { + const newFeature: ImplementedFeature = { + name: 'New Feature', + description: 'New description', + }; + const result = addImplementedFeature(baseSpec, newFeature); + expect(result).toContain('Existing Feature'); + expect(result).toContain('New Feature'); + }); + + it('should not add duplicate feature', () => { + const duplicate: ImplementedFeature = { + name: 'Existing Feature', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + // Should still have only one instance + const matches = result.match(/Existing Feature/g); + expect(matches).toHaveLength(1); + }); + + it('should be case-insensitive for duplicates', () => { + const duplicate: ImplementedFeature = { + name: 'EXISTING FEATURE', + description: 'Different description', + }; + const result = addImplementedFeature(baseSpec, duplicate); + expect(result).not.toContain('EXISTING FEATURE'); + }); + }); + + describe('removeImplementedFeature', () => { + const baseSpec = ` + + Feature A + Description A + + + Feature B + Description B + + `; + + it('should remove feature by name', () => { + const result = removeImplementedFeature(baseSpec, 'Feature A'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should be case-insensitive', () => { + const result = removeImplementedFeature(baseSpec, 'feature a'); + expect(result).not.toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + + it('should return unchanged content if feature not found', () => { + const result = removeImplementedFeature(baseSpec, 'Nonexistent'); + expect(result).toContain('Feature A'); + expect(result).toContain('Feature B'); + }); + }); + + describe('updateImplementedFeature', () => { + const baseSpec = ` + + My Feature + Original description + + `; + + it('should update feature description', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'Updated description', + }); + expect(result).toContain('Updated description'); + expect(result).not.toContain('Original description'); + }); + + it('should add file_locations', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + file_locations: ['src/new.ts'], + }); + expect(result).toContain(''); + expect(result).toContain('src/new.ts'); + }); + + it('should preserve feature name if not updated', () => { + const result = updateImplementedFeature(baseSpec, 'My Feature', { + description: 'New desc', + }); + expect(result).toContain('My Feature'); + }); + + it('should be case-insensitive', () => { + const result = updateImplementedFeature(baseSpec, 'my feature', { + description: 'Updated', + }); + expect(result).toContain('Updated'); + }); + + it('should return unchanged content if feature not found', () => { + const result = updateImplementedFeature(baseSpec, 'Nonexistent', { + description: 'New', + }); + expect(result).toContain('Original description'); + }); + }); + + describe('hasImplementedFeature', () => { + const baseSpec = ` + + Existing Feature + Description + + `; + + it('should return true for existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Existing Feature')).toBe(true); + }); + + it('should return false for non-existing feature', () => { + expect(hasImplementedFeature(baseSpec, 'Nonexistent')).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(hasImplementedFeature(baseSpec, 'existing feature')).toBe(true); + expect(hasImplementedFeature(baseSpec, 'EXISTING FEATURE')).toBe(true); + }); + }); + + describe('toSpecOutputFeatures', () => { + it('should convert to SpecOutput format', () => { + const features: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = toSpecOutputFeatures(features); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(toSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('fromSpecOutputFeatures', () => { + it('should convert from SpecOutput format', () => { + const specFeatures = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]; + const result = fromSpecOutputFeatures(specFeatures); + expect(result).toEqual([ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] }, + ]); + }); + + it('should handle empty array', () => { + expect(fromSpecOutputFeatures([])).toEqual([]); + }); + }); + + describe('roundtrip', () => { + it('should maintain data integrity through extract -> update cycle', () => { + const originalSpec = ` + + Test + + Testing + + + + Test & Feature + Uses <special> chars + + src/test.ts + + + +`; + + // Extract features + const features = extractImplementedFeatures(originalSpec); + expect(features[0].name).toBe('Test & Feature'); + expect(features[0].description).toBe('Uses chars'); + + // Update with same features + const result = updateImplementedFeaturesSection(originalSpec, features); + + // Re-extract and verify + const reExtracted = extractImplementedFeatures(result); + expect(reExtracted[0].name).toBe('Test & Feature'); + expect(reExtracted[0].description).toBe('Uses chars'); + expect(reExtracted[0].file_locations).toEqual(['src/test.ts']); + }); + }); + + describe('custom logger', () => { + it('should use custom logger for extractXmlSection', () => { + const mockLogger = createMockLogger(); + const xml = '
content
'; + extractXmlSection(xml, 'section', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted
section'); + }); + + it('should log when section is not found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractXmlSection(xml, 'missing', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Section not found'); + }); + + it('should use custom logger for extractXmlElements', () => { + const mockLogger = createMockLogger(); + const xml = 'onetwo'; + extractXmlElements(xml, 'item', { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 2 elements'); + }); + + it('should use custom logger for extractImplementedFeatures', () => { + const mockLogger = createMockLogger(); + const xml = ` + + Test + Desc + + `; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 1 implemented features'); + }); + + it('should log when no implemented_features section found', () => { + const mockLogger = createMockLogger(); + const xml = 'content'; + extractImplementedFeatures(xml, { logger: mockLogger }); + expect(mockLogger.debug).toHaveBeenCalledWith('No implemented_features section found'); + }); + + it('should use custom logger warn for missing insertion point', () => { + const mockLogger = createMockLogger(); + // XML without project_specification, core_capabilities, or implemented_features + const xml = 'content'; + const features: ImplementedFeature[] = [{ name: 'Test', description: 'Desc' }]; + updateImplementedFeaturesSection(xml, features, { logger: mockLogger }); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Could not find appropriate insertion point for implemented_features' + ); + }); + }); + + describe('edge cases', () => { + describe('escapeXml edge cases', () => { + it('should handle strings with only special characters', () => { + expect(escapeXml('<>&"\'')).toBe('<>&"''); + }); + + it('should handle very long strings', () => { + const longString = 'a'.repeat(10000) + '&' + 'b'.repeat(10000); + const escaped = escapeXml(longString); + expect(escaped).toContain('&'); + expect(escaped.length).toBe(20005); // +4 for & minus & + }); + + it('should handle unicode characters without escaping', () => { + const unicode = '日本語 emoji: 🚀 symbols: ∞ ≠ ≤'; + expect(escapeXml(unicode)).toBe(unicode); + }); + }); + + describe('unescapeXml edge cases', () => { + it('should handle strings with only entities', () => { + expect(unescapeXml('<>&"'')).toBe('<>&"\''); + }); + + it('should not double-unescape', () => { + // &lt; should become < (not <) + expect(unescapeXml('&lt;')).toBe('<'); + }); + + it('should handle partial/invalid entities gracefully', () => { + // Invalid entities should pass through unchanged + expect(unescapeXml('&unknown;')).toBe('&unknown;'); + expect(unescapeXml('&')).toBe('&'); // Missing semicolon + }); + }); + + describe('extractXmlSection edge cases', () => { + it('should handle nested tags with same name', () => { + // Note: regex-based parsing with non-greedy matching will match + // from first opening tag to first closing tag + const xml = 'inner'; + // Non-greedy [\s\S]*? matches from first to first + expect(extractXmlSection(xml, 'outer')).toBe('inner'); + }); + + it('should handle self-closing tags (returns null)', () => { + const xml = '
'; + // Regex expects content between tags, self-closing won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle tags with attributes', () => { + const xml = '
content
'; + // The regex matches exact tag names, so this won't match + expect(extractXmlSection(xml, 'section')).toBeNull(); + }); + + it('should handle whitespace in tag content', () => { + const xml = '
\n\t
'; + expect(extractXmlSection(xml, 'section')).toBe(' \n\t '); + }); + }); + + describe('extractXmlElements edge cases', () => { + it('should handle elements across multiple lines', () => { + const xml = ` + + first + + second + `; + // Multiline content is now captured with [\s\S]*? pattern + const result = extractXmlElements(xml, 'item'); + expect(result).toHaveLength(2); + expect(result[0]).toBe('first'); + expect(result[1]).toBe('second'); + }); + + it('should handle consecutive elements without whitespace', () => { + const xml = 'abc'; + expect(extractXmlElements(xml, 'item')).toEqual(['a', 'b', 'c']); + }); + }); + + describe('extractImplementedFeatures edge cases', () => { + it('should skip features without names', () => { + const xml = ` + + Orphan description + + + Valid Feature + Has name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(1); + expect(features[0].name).toBe('Valid Feature'); + }); + + it('should handle features with empty names', () => { + const xml = ` + + + Empty name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Empty name is falsy + }); + + it('should handle features with whitespace-only names', () => { + const xml = ` + + + Whitespace name + + `; + const features = extractImplementedFeatures(xml); + expect(features).toHaveLength(0); // Trimmed whitespace is empty + }); + + it('should handle empty file_locations section', () => { + const xml = ` + + Test + Desc + + + + `; + const features = extractImplementedFeatures(xml); + expect(features[0].file_locations).toBeUndefined(); + }); + }); + + describe('featureToXml edge cases', () => { + it('should handle custom indentation', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + }; + const xml = featureToXml(feature, '\t'); + expect(xml).toContain('\t\t'); + expect(xml).toContain('\t\t\tTest'); + }); + + it('should handle empty description', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: '', + }; + const xml = featureToXml(feature); + expect(xml).toContain(''); + }); + + it('should handle undefined file_locations', () => { + const feature: ImplementedFeature = { + name: 'Test', + description: 'Desc', + file_locations: undefined, + }; + const xml = featureToXml(feature); + expect(xml).not.toContain('file_locations'); + }); + }); + + describe('updateImplementedFeaturesSection edge cases', () => { + it('should insert before as fallback', () => { + const specWithoutCoreCapabilities = ` + + Test +`; + const newFeatures: ImplementedFeature[] = [ + { name: 'New Feature', description: 'New description' }, + ]; + const result = updateImplementedFeaturesSection(specWithoutCoreCapabilities, newFeatures); + expect(result).toContain(''); + expect(result).toContain('New Feature'); + expect(result.indexOf('')).toBeLessThan( + result.indexOf('') + ); + }); + + it('should return unchanged content when no insertion point found', () => { + const invalidSpec = 'content'; + const newFeatures: ImplementedFeature[] = [{ name: 'Feature', description: 'Desc' }]; + const result = updateImplementedFeaturesSection(invalidSpec, newFeatures); + expect(result).toBe(invalidSpec); + }); + + it('should handle empty features array', () => { + const spec = ` + + Old + Old desc + + `; + const result = updateImplementedFeaturesSection(spec, []); + expect(result).toContain(''); + expect(result).not.toContain('Old'); + }); + }); + + describe('addImplementedFeature edge cases', () => { + it('should create section when adding to spec without implemented_features', () => { + const specWithoutSection = ` + + Testing + +`; + const newFeature: ImplementedFeature = { + name: 'First Feature', + description: 'First description', + }; + const result = addImplementedFeature(specWithoutSection, newFeature); + expect(result).toContain(''); + expect(result).toContain('First Feature'); + }); + + it('should handle feature with all fields populated', () => { + const spec = ``; + const newFeature: ImplementedFeature = { + name: 'Complete Feature', + description: 'Full description', + file_locations: ['src/a.ts', 'src/b.ts', 'src/c.ts'], + }; + const result = addImplementedFeature(spec, newFeature); + expect(result).toContain('Complete Feature'); + expect(result).toContain('src/a.ts'); + expect(result).toContain('src/b.ts'); + expect(result).toContain('src/c.ts'); + }); + }); + + describe('updateImplementedFeature edge cases', () => { + it('should allow updating feature name', () => { + const spec = ` + + Old Name + Desc + + `; + const result = updateImplementedFeature(spec, 'Old Name', { + name: 'New Name', + }); + expect(result).toContain('New Name'); + expect(result).not.toContain('Old Name'); + }); + + it('should allow clearing file_locations', () => { + const spec = ` + + Test + Desc + + src/old.ts + + + `; + const result = updateImplementedFeature(spec, 'Test', { + file_locations: [], + }); + expect(result).not.toContain('file_locations'); + expect(result).not.toContain('src/old.ts'); + }); + + it('should handle updating multiple fields at once', () => { + const spec = ` + + Original + Original desc + + `; + const result = updateImplementedFeature(spec, 'Original', { + name: 'Updated', + description: 'Updated desc', + file_locations: ['new/path.ts'], + }); + expect(result).toContain('Updated'); + expect(result).toContain('Updated desc'); + expect(result).toContain('new/path.ts'); + }); + }); + + describe('toSpecOutputFeatures and fromSpecOutputFeatures edge cases', () => { + it('should handle features with empty file_locations array', () => { + const features: ImplementedFeature[] = [ + { name: 'Test', description: 'Desc', file_locations: [] }, + ]; + const specOutput = toSpecOutputFeatures(features); + expect(specOutput[0].file_locations).toBeUndefined(); + }); + + it('should handle round-trip conversion', () => { + const original: ImplementedFeature[] = [ + { name: 'Feature 1', description: 'Desc 1' }, + { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f.ts'] }, + ]; + const specOutput = toSpecOutputFeatures(original); + const restored = fromSpecOutputFeatures(specOutput); + expect(restored).toEqual(original); + }); + }); + }); + + describe('integration scenarios', () => { + it('should handle a complete spec file workflow', () => { + // Start with a minimal spec + let spec = ` + + My App + + User management + +`; + + // Add first feature + spec = addImplementedFeature(spec, { + name: 'User Authentication', + description: 'Login and logout functionality', + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts'], + }); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + + // Add second feature + spec = addImplementedFeature(spec, { + name: 'User Profile', + description: 'View and edit user profile', + }); + expect(extractImplementedFeatureNames(spec)).toEqual(['User Authentication', 'User Profile']); + + // Update first feature + spec = updateImplementedFeature(spec, 'User Authentication', { + file_locations: ['src/auth/login.ts', 'src/auth/logout.ts', 'src/auth/session.ts'], + }); + const features = extractImplementedFeatures(spec); + expect(features[0].file_locations).toContain('src/auth/session.ts'); + + // Remove a feature + spec = removeImplementedFeature(spec, 'User Profile'); + expect(hasImplementedFeature(spec, 'User Profile')).toBe(false); + expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true); + }); + + it('should handle special characters throughout workflow', () => { + const spec = ` + +`; + + const result = addImplementedFeature(spec, { + name: 'Search & Filter', + description: 'Supports syntax with "quoted" terms', + file_locations: ["src/search/parser's.ts"], + }); + + const features = extractImplementedFeatures(result); + expect(features[0].name).toBe('Search & Filter'); + expect(features[0].description).toBe('Supports syntax with "quoted" terms'); + expect(features[0].file_locations?.[0]).toBe("src/search/parser's.ts"); + }); + + it('should preserve other XML content when modifying features', () => { + const spec = ` + + Preserved Name + This should be preserved + + Capability 1 + Capability 2 + + + + Old Feature + Will be replaced + + + Keep this too +`; + + const result = updateImplementedFeaturesSection(spec, [ + { name: 'New Feature', description: 'New desc' }, + ]); + + expect(result).toContain('Preserved Name'); + expect(result).toContain('This should be preserved'); + expect(result).toContain('Capability 1'); + expect(result).toContain('Capability 2'); + expect(result).toContain('Keep this too'); + expect(result).not.toContain('Old Feature'); + expect(result).toContain('New Feature'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/base-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/base-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f355fec9900cac9c26a00257a214741b52dd5e8a --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/base-provider.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect } from 'vitest'; +import { BaseProvider } from '@/providers/base-provider.js'; +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from '@automaker/types'; + +// Concrete implementation for testing the abstract class +class TestProvider extends BaseProvider { + getName(): string { + return 'test-provider'; + } + + async *executeQuery(_options: ExecuteOptions): AsyncGenerator { + yield { type: 'text', text: 'test response' }; + } + + async detectInstallation(): Promise { + return { installed: true }; + } + + getAvailableModels(): ModelDefinition[] { + return [{ id: 'test-model-1', name: 'Test Model 1', description: 'A test model' }]; + } +} + +describe('base-provider.ts', () => { + describe('constructor', () => { + it('should initialize with empty config when none provided', () => { + const provider = new TestProvider(); + expect(provider.getConfig()).toEqual({}); + }); + + it('should initialize with provided config', () => { + const config: ProviderConfig = { + apiKey: 'test-key', + baseUrl: 'https://test.com', + }; + const provider = new TestProvider(config); + expect(provider.getConfig()).toEqual(config); + }); + + it('should call getName() during initialization', () => { + const provider = new TestProvider(); + expect(provider.getName()).toBe('test-provider'); + }); + }); + + describe('validateConfig', () => { + it('should return valid when config exists', () => { + const provider = new TestProvider({ apiKey: 'test' }); + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it('should return invalid when config is undefined', () => { + // Create provider without config + const provider = new TestProvider(); + // Manually set config to undefined to test edge case + (provider as any).config = undefined; + + const result = provider.validateConfig(); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('Provider config is missing'); + }); + + it('should return valid for empty config object', () => { + const provider = new TestProvider({}); + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should include warnings array in result', () => { + const provider = new TestProvider(); + const result = provider.validateConfig(); + + expect(result).toHaveProperty('warnings'); + expect(Array.isArray(result.warnings)).toBe(true); + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + const provider = new TestProvider(); + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + const provider = new TestProvider(); + expect(provider.supportsFeature('text')).toBe(true); + }); + + it('should not support unknown features', () => { + const provider = new TestProvider(); + expect(provider.supportsFeature('vision')).toBe(false); + expect(provider.supportsFeature('mcp')).toBe(false); + expect(provider.supportsFeature('unknown')).toBe(false); + }); + + it('should be case-sensitive', () => { + const provider = new TestProvider(); + expect(provider.supportsFeature('TOOLS')).toBe(false); + expect(provider.supportsFeature('Text')).toBe(false); + }); + }); + + describe('getConfig', () => { + it('should return current config', () => { + const config: ProviderConfig = { + apiKey: 'test-key', + model: 'test-model', + }; + const provider = new TestProvider(config); + + expect(provider.getConfig()).toEqual(config); + }); + + it('should return same reference', () => { + const config: ProviderConfig = { apiKey: 'test' }; + const provider = new TestProvider(config); + + const retrieved1 = provider.getConfig(); + const retrieved2 = provider.getConfig(); + + expect(retrieved1).toBe(retrieved2); + }); + }); + + describe('setConfig', () => { + it('should merge partial config with existing config', () => { + const provider = new TestProvider({ apiKey: 'original-key' }); + + provider.setConfig({ model: 'new-model' }); + + expect(provider.getConfig()).toEqual({ + apiKey: 'original-key', + model: 'new-model', + }); + }); + + it('should override existing fields', () => { + const provider = new TestProvider({ apiKey: 'old-key', model: 'old-model' }); + + provider.setConfig({ apiKey: 'new-key' }); + + expect(provider.getConfig()).toEqual({ + apiKey: 'new-key', + model: 'old-model', + }); + }); + + it('should accept empty object', () => { + const provider = new TestProvider({ apiKey: 'test' }); + const originalConfig = provider.getConfig(); + + provider.setConfig({}); + + expect(provider.getConfig()).toEqual(originalConfig); + }); + + it('should handle multiple updates', () => { + const provider = new TestProvider(); + + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ model: 'model1' }); + provider.setConfig({ baseUrl: 'https://test.com' }); + + expect(provider.getConfig()).toEqual({ + apiKey: 'key1', + model: 'model1', + baseUrl: 'https://test.com', + }); + }); + + it('should preserve other fields when updating one field', () => { + const provider = new TestProvider({ + apiKey: 'key', + model: 'model', + baseUrl: 'https://test.com', + }); + + provider.setConfig({ model: 'new-model' }); + + expect(provider.getConfig()).toEqual({ + apiKey: 'key', + model: 'new-model', + baseUrl: 'https://test.com', + }); + }); + }); + + describe('abstract methods', () => { + it('should require getName implementation', () => { + const provider = new TestProvider(); + expect(typeof provider.getName).toBe('function'); + expect(provider.getName()).toBe('test-provider'); + }); + + it('should require executeQuery implementation', async () => { + const provider = new TestProvider(); + expect(typeof provider.executeQuery).toBe('function'); + + const generator = provider.executeQuery({ + prompt: 'test', + projectDirectory: '/test', + }); + const result = await generator.next(); + + expect(result.value).toEqual({ type: 'text', text: 'test response' }); + }); + + it('should require detectInstallation implementation', async () => { + const provider = new TestProvider(); + expect(typeof provider.detectInstallation).toBe('function'); + + const status = await provider.detectInstallation(); + expect(status).toHaveProperty('installed'); + }); + + it('should require getAvailableModels implementation', () => { + const provider = new TestProvider(); + expect(typeof provider.getAvailableModels).toBe('function'); + + const models = provider.getAvailableModels(); + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/claude-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/claude-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a3850a60bcec2829354b3e45877564af5f3bf9c --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/claude-provider.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudeProvider } from '@/providers/claude-provider.js'; +import * as sdk from '@anthropic-ai/claude-agent-sdk'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; + +vi.mock('@anthropic-ai/claude-agent-sdk'); + +vi.mock('@automaker/platform', () => ({ + getClaudeAuthIndicators: vi.fn().mockResolvedValue({ + hasCredentialsFile: false, + hasSettingsFile: false, + hasStatsCacheWithActivity: false, + hasProjectsSessions: false, + credentials: null, + checks: {}, + }), +})); + +describe('claude-provider.ts', () => { + let provider: ClaudeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new ClaudeProvider(); + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + describe('getName', () => { + it("should return 'claude' as provider name", () => { + expect(provider.getName()).toBe('claude'); + }); + }); + + describe('executeQuery', () => { + it('should execute simple text query', async () => { + const mockMessages = [ + { type: 'text', text: 'Response 1' }, + { type: 'text', text: 'Response 2' }, + ]; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + for (const msg of mockMessages) { + yield msg; + } + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Hello', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + const results = await collectAsyncGenerator(generator); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ type: 'text', text: 'Response 1' }); + expect(results[1]).toEqual({ type: 'text', text: 'Response 2' }); + }); + + it('should pass correct options to SDK', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test prompt', + model: 'claude-opus-4-6', + cwd: '/test/dir', + systemPrompt: 'You are helpful', + maxTurns: 10, + allowedTools: ['Read', 'Write'], + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test prompt', + options: expect.objectContaining({ + model: 'claude-opus-4-6', + systemPrompt: 'You are helpful', + maxTurns: 10, + cwd: '/test/dir', + allowedTools: ['Read', 'Write'], + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + }), + }); + }); + + it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.not.objectContaining({ + allowedTools: expect.anything(), + }), + }); + }); + + it('should pass abortController if provided', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const abortController = new AbortController(); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + abortController, + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + abortController, + }), + }); + }); + + it('should handle conversation history with sdkSessionId using resume option', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const conversationHistory = [ + { role: 'user' as const, content: 'Previous message' }, + { role: 'assistant' as const, content: 'Previous response' }, + ]; + + const generator = provider.executeQuery({ + prompt: 'Current message', + model: 'claude-opus-4-6', + cwd: '/test', + conversationHistory, + sdkSessionId: 'test-session-id', + }); + + await collectAsyncGenerator(generator); + + // Should use resume option when sdkSessionId is provided with history + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Current message', + options: expect.objectContaining({ + resume: 'test-session-id', + }), + }); + }); + + it('should handle array prompt (with images)', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const arrayPrompt = [ + { type: 'text', text: 'Describe this' }, + { type: 'image', source: { type: 'base64', data: '...' } }, + ]; + + const generator = provider.executeQuery({ + prompt: arrayPrompt as any, + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + // Should pass an async generator as prompt for array inputs + const callArgs = vi.mocked(sdk.query).mock.calls[0][0]; + expect(typeof callArgs.prompt).not.toBe('string'); + }); + + it('should use maxTurns default of 1000', async () => { + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + maxTurns: 1000, + }), + }); + }); + + it('should handle errors during execution and rethrow', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const testError = new Error('SDK execution failed'); + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + throw testError; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await expect(collectAsyncGenerator(generator)).rejects.toThrow('SDK execution failed'); + + // Should log error with classification info (via logger) + // Logger format: 'ERROR [Context]' message, data + const errorCall = consoleErrorSpy.mock.calls[0]; + expect(errorCall[0]).toMatch(/ERROR.*\[ClaudeProvider\]/); + expect(errorCall[1]).toBe('executeQuery() error during execution:'); + expect(errorCall[2]).toMatchObject({ + type: expect.any(String), + message: 'SDK execution failed', + isRateLimit: false, + stack: expect.stringContaining('Error: SDK execution failed'), + }); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('detectInstallation', () => { + it('should return installed with SDK method', async () => { + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(true); + expect(result.method).toBe('sdk'); + }); + + it('should detect ANTHROPIC_API_KEY', async () => { + process.env.ANTHROPIC_API_KEY = 'test-key'; + + const result = await provider.detectInstallation(); + + expect(result.hasApiKey).toBe(true); + expect(result.authenticated).toBe(true); + }); + + it('should return hasApiKey false when no keys present', async () => { + const result = await provider.detectInstallation(); + + expect(result.hasApiKey).toBe(false); + expect(result.authenticated).toBe(false); + }); + }); + + describe('environment variable passthrough', () => { + afterEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', + }), + }), + }); + }); + + it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { + process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', + }), + }), + }); + }); + + it('should pass both custom endpoint vars together', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; + process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + model: 'claude-opus-4-6', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }), + }), + }); + }); + }); + + describe('getAvailableModels', () => { + it('should return 5 Claude models', () => { + const models = provider.getAvailableModels(); + + expect(models).toHaveLength(5); + }); + + it('should include Claude Opus 4.6', () => { + const models = provider.getAvailableModels(); + + const opus = models.find((m) => m.id === 'claude-opus-4-6'); + expect(opus).toBeDefined(); + expect(opus?.name).toBe('Claude Opus 4.6'); + expect(opus?.provider).toBe('anthropic'); + }); + + it('should include Claude Sonnet 4.6', () => { + const models = provider.getAvailableModels(); + + const sonnet = models.find((m) => m.id === 'claude-sonnet-4-6'); + expect(sonnet).toBeDefined(); + expect(sonnet?.name).toBe('Claude Sonnet 4.6'); + }); + + it('should include Claude 3.5 Sonnet', () => { + const models = provider.getAvailableModels(); + + const sonnet35 = models.find((m) => m.id === 'claude-3-5-sonnet-20241022'); + expect(sonnet35).toBeDefined(); + }); + + it('should include Claude Haiku 4.5', () => { + const models = provider.getAvailableModels(); + + const haiku = models.find((m) => m.id === 'claude-haiku-4-5-20251001'); + expect(haiku).toBeDefined(); + }); + + it('should mark Opus as default', () => { + const models = provider.getAvailableModels(); + + const opus = models.find((m) => m.id === 'claude-opus-4-6'); + expect(opus?.default).toBe(true); + }); + + it('should all support vision and tools', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.supportsVision).toBe(true); + expect(model.supportsTools).toBe(true); + }); + }); + + it('should have correct context windows', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.contextWindow).toBe(200000); + }); + }); + + it('should have modelString field matching id', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.modelString).toBe(model.id); + }); + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it("should support 'vision' feature", () => { + expect(provider.supportsFeature('vision')).toBe(true); + }); + + it("should support 'thinking' feature", () => { + expect(provider.supportsFeature('thinking')).toBe(true); + }); + + it("should not support 'mcp' feature", () => { + expect(provider.supportsFeature('mcp')).toBe(false); + }); + + it("should not support 'cli' feature", () => { + expect(provider.supportsFeature('cli')).toBe(false); + }); + + it('should not support unknown features', () => { + expect(provider.supportsFeature('unknown')).toBe(false); + }); + }); + + describe('validateConfig', () => { + it('should validate config from base class', () => { + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('config management', () => { + it('should get and set config', () => { + provider.setConfig({ apiKey: 'test-key' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('test-key'); + }); + + it('should merge config updates', () => { + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ model: 'model1' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('key1'); + expect(config.model).toBe('model1'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/codex-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/codex-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0448a705a8e1c6b0b7b9c3d02abf74ab401dfa8 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/codex-provider.test.ts @@ -0,0 +1,474 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { CodexProvider } from '../../../src/providers/codex-provider.js'; +import type { ProviderMessage } from '../../../src/providers/types.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { + spawnJSONLProcess, + findCodexCliPath, + secureFs, + getCodexConfigDir, + getCodexAuthIndicators, +} from '@automaker/platform'; +import { + calculateReasoningTimeout, + REASONING_TIMEOUT_MULTIPLIERS, + DEFAULT_TIMEOUT_MS, + validateBareModelId, +} from '@automaker/types'; + +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV]; + +const codexRunMock = vi.fn(); + +vi.mock('@openai/codex-sdk', () => ({ + Codex: class { + constructor(_opts: { apiKey: string }) {} + startThread() { + return { + id: 'thread-123', + run: codexRunMock, + }; + } + resumeThread() { + return { + id: 'thread-123', + run: codexRunMock, + }; + } + }, +})); + +const EXEC_SUBCOMMAND = 'exec'; + +vi.mock('@automaker/platform', () => ({ + spawnJSONLProcess: vi.fn(), + spawnProcess: vi.fn(), + findCodexCliPath: vi.fn(), + getCodexAuthIndicators: vi.fn().mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }), + getCodexConfigDir: vi.fn().mockReturnValue('/home/test/.codex'), + secureFs: { + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, + getDataDirectory: vi.fn(), +})); + +vi.mock('@/services/settings-service.js', () => ({ + SettingsService: class { + async getGlobalSettings() { + return { + codexAutoLoadAgents: false, + codexSandboxMode: 'workspace-write', + codexApprovalPolicy: 'on-request', + }; + } + }, +})); + +describe('codex-provider.ts', () => { + let provider: CodexProvider; + + afterAll(() => { + if (originalOpenAIKey !== undefined) { + process.env[OPENAI_API_KEY_ENV] = originalOpenAIKey; + } else { + delete process.env[OPENAI_API_KEY_ENV]; + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCodexConfigDir).mockReturnValue('/home/test/.codex'); + vi.mocked(findCodexCliPath).mockResolvedValue('/usr/bin/codex'); + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasAuthFile: true, + hasOAuthToken: true, + hasApiKey: false, + }); + delete process.env[OPENAI_API_KEY_ENV]; + provider = new CodexProvider(); + }); + + describe('executeQuery', () => { + it('emits tool_use and tool_result with shared tool_use_id for command execution', async () => { + const mockEvents = [ + { + type: 'item.started', + item: { + type: 'command_execution', + id: 'cmd-1', + command: 'ls', + }, + }, + { + type: 'item.completed', + item: { + type: 'command_execution', + id: 'cmd-1', + output: 'file1\nfile2', + }, + }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'List files', + model: 'gpt-5.2', + cwd: '/tmp', + }) + ); + + expect(results).toHaveLength(2); + const toolUse = results[0]; + const toolResult = results[1]; + + expect(toolUse.type).toBe('assistant'); + expect(toolUse.message?.content[0].type).toBe('tool_use'); + const toolUseId = toolUse.message?.content[0].tool_use_id; + expect(toolUseId).toBeDefined(); + + expect(toolResult.type).toBe('assistant'); + expect(toolResult.message?.content[0].type).toBe('tool_result'); + expect(toolResult.message?.content[0].tool_use_id).toBe(toolUseId); + expect(toolResult.message?.content[0].content).toBe('file1\nfile2'); + }); + + it('adds output schema and max turn overrides when configured', async () => { + // Note: With full-permissions always on, these flags are no longer used + // This test now only verifies the basic CLI structure + // Using gpt-5.1-codex-max which should route to Codex (not Cursor) + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Test config', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + allowedTools: ['Read', 'Write'], + maxTurns: 5, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args).toContain('exec'); // Should have exec subcommand + expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // Should have YOLO flag + expect(call.args).toContain('--model'); + expect(call.args).toContain('--json'); + }); + + it('uses exec resume when sdkSessionId is provided', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Continue', + model: 'gpt-5.2', + cwd: '/tmp', + sdkSessionId: 'codex-session-123', + outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } }, + codexSettings: { additionalDirs: ['/extra/dir'] }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args[0]).toBe('exec'); + expect(call.args[1]).toBe('resume'); + expect(call.args).toContain('codex-session-123'); + expect(call.args).toContain('--json'); + // Resume queries must not include --output-schema or --add-dir + expect(call.args).not.toContain('--output-schema'); + expect(call.args).not.toContain('--add-dir'); + }); + + it('overrides approval policy when MCP auto-approval is enabled', async () => { + // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), + // approval policy is bypassed, not configured via --config + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Test approvals', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + mcpServers: { mock: { type: 'stdio', command: 'node' } }, + mcpAutoApproveTools: true, + codexSettings: { approvalPolicy: 'untrusted' }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + const execIndex = call.args.indexOf(EXEC_SUBCOMMAND); + expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // YOLO flag bypasses approval + expect(call.args).toContain('--model'); + expect(call.args).toContain('--json'); + }); + + it('injects user and project instructions when auto-load is enabled', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const userPath = path.join('/home/test/.codex', 'AGENTS.md'); + const projectPath = path.join('/tmp/project', '.codex', 'AGENTS.md'); + vi.mocked(secureFs.readFile).mockImplementation(async (filePath: string) => { + if (filePath === userPath) { + return 'User rules'; + } + if (filePath === projectPath) { + return 'Project rules'; + } + throw new Error('missing'); + }); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp/project', + codexSettings: { autoLoadAgents: true }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + const promptText = call.stdinData; + expect(promptText).toContain('User rules'); + expect(promptText).toContain('Project rules'); + }); + + it('disables sandbox mode when running in cloud storage paths', async () => { + // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), + // sandbox mode is bypassed, not configured via --sandbox flag + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const cloudPath = path.join(os.homedir(), 'Dropbox', 'project'); + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.1-codex-max', + cwd: cloudPath, + codexSettings: { sandboxMode: 'workspace-write' }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // YOLO flag bypasses sandbox entirely + expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); + expect(call.args).toContain('--model'); + expect(call.args).toContain('--json'); + }); + + it('uses the SDK when no tools are requested and an API key is present', async () => { + process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + // Override auth indicators so CLI-native auth doesn't take priority over API key + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); + codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' }); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: [], + }) + ); + + expect(results[0].message?.content[0].text).toBe('Hello from SDK'); + expect(results[1].result).toBe('Hello from SDK'); + }); + + it('uses the SDK when API key is present, even for tool requests (to avoid OAuth issues)', async () => { + process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + // Override auth indicators so CLI-native auth doesn't take priority over API key + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Read files', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: ['Read'], + }) + ); + + expect(codexRunMock).toHaveBeenCalled(); + expect(spawnJSONLProcess).not.toHaveBeenCalled(); + }); + + it('falls back to CLI when no tools are requested and no API key is available', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: [], + }) + ); + + expect(codexRunMock).not.toHaveBeenCalled(); + expect(spawnJSONLProcess).toHaveBeenCalled(); + }); + + it('passes extended timeout for high reasoning effort', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Complex reasoning task', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + reasoningEffort: 'high', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // High reasoning effort should have 3x the CLI base timeout (120000ms) + // CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + }); + + it('passes extended timeout for xhigh reasoning effort', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Very complex reasoning task', + model: 'gpt-5.1-codex-max', + cwd: '/tmp', + reasoningEffort: 'xhigh', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation + // then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes) + const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; + expect(call.timeout).toBe( + CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh + ); + }); + + it('uses default timeout when no reasoning effort is specified', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Simple task', + model: 'gpt-5.2', + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // No reasoning effort should use the CLI base timeout (2 minutes) + // CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied + const CODEX_CLI_TIMEOUT_MS = 120000; + expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS); + }); + }); + + describe('calculateReasoningTimeout', () => { + it('returns default timeout when no reasoning effort is specified', () => { + expect(calculateReasoningTimeout()).toBe(DEFAULT_TIMEOUT_MS); + expect(calculateReasoningTimeout(undefined)).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('returns default timeout for none reasoning effort', () => { + expect(calculateReasoningTimeout('none')).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('applies correct multiplier for minimal reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.minimal); + expect(calculateReasoningTimeout('minimal')).toBe(expected); + }); + + it('applies correct multiplier for low reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.low); + expect(calculateReasoningTimeout('low')).toBe(expected); + }); + + it('applies correct multiplier for medium reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.medium); + expect(calculateReasoningTimeout('medium')).toBe(expected); + }); + + it('applies correct multiplier for high reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high); + expect(calculateReasoningTimeout('high')).toBe(expected); + }); + + it('applies correct multiplier for xhigh reasoning effort', () => { + const expected = Math.round(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh); + expect(calculateReasoningTimeout('xhigh')).toBe(expected); + }); + + it('uses custom base timeout when provided', () => { + const customBase = 60000; + expect(calculateReasoningTimeout('high', customBase)).toBe( + Math.round(customBase * REASONING_TIMEOUT_MULTIPLIERS.high) + ); + }); + + it('falls back to 1.0 multiplier for invalid reasoning effort', () => { + // Test that invalid values fallback gracefully to default multiplier + // This tests the defensive ?? 1.0 in calculateReasoningTimeout + const invalidEffort = 'invalid_effort' as never; + expect(calculateReasoningTimeout(invalidEffort)).toBe(DEFAULT_TIMEOUT_MS); + }); + + it('produces expected absolute timeout values', () => { + // Verify the actual timeout values that will be used: + // none: 30000ms (30s) + // minimal: 36000ms (36s) + // low: 45000ms (45s) + // medium: 60000ms (1m) + // high: 90000ms (1m 30s) + // xhigh: 120000ms (2m) + expect(calculateReasoningTimeout('none')).toBe(30000); + expect(calculateReasoningTimeout('minimal')).toBe(36000); + expect(calculateReasoningTimeout('low')).toBe(45000); + expect(calculateReasoningTimeout('medium')).toBe(60000); + expect(calculateReasoningTimeout('high')).toBe(90000); + expect(calculateReasoningTimeout('xhigh')).toBe(120000); + }); + }); + + describe('validateBareModelId integration', () => { + it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => { + expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow(); + expect(() => + validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex') + ).not.toThrow(); + }); + + it('should reject other provider prefixes for Codex provider', () => { + expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow(); + expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/copilot-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/copilot-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..54cdb49c60dbc7d440d22fb60de3c41b95108695 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -0,0 +1,689 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { CopilotClient } from '@github/copilot-sdk'; + +const createSessionMock = vi.fn(); +const resumeSessionMock = vi.fn(); + +function createMockSession(sessionId = 'test-session') { + let eventHandler: ((event: any) => void) | null = null; + return { + sessionId, + send: vi.fn().mockImplementation(async () => { + if (eventHandler) { + eventHandler({ type: 'assistant.message', data: { content: 'hello' } }); + eventHandler({ type: 'session.idle' }); + } + }), + destroy: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockImplementation((handler: (event: any) => void) => { + eventHandler = handler; + }), + }; +} + +// Mock the Copilot SDK +vi.mock('@github/copilot-sdk', () => ({ + CopilotClient: vi.fn().mockImplementation(() => ({ + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: createSessionMock, + resumeSession: resumeSessionMock, + })), +})); + +// Mock child_process with all needed exports +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execSync: vi.fn(), + }; +}); + +// Mock fs (synchronous) for CLI detection (existsSync) +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + }; +}); + +// Mock fs/promises +vi.mock('fs/promises', () => ({ + access: vi.fn().mockRejectedValue(new Error('Not found')), + readFile: vi.fn().mockRejectedValue(new Error('Not found')), + mkdir: vi.fn().mockResolvedValue(undefined), +})); + +// Import execSync after mocking +import { execSync } from 'child_process'; +import * as fs from 'fs'; + +describe('copilot-provider.ts', () => { + let provider: CopilotProvider; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(CopilotClient).mockImplementation(function () { + return { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: createSessionMock, + resumeSession: resumeSessionMock, + } as any; + }); + createSessionMock.mockResolvedValue(createMockSession()); + resumeSessionMock.mockResolvedValue(createMockSession('resumed-session')); + + // Mock fs.existsSync for CLI path validation + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock CLI detection to find the CLI + // The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows) + // to find the CLI path, then validates with fs.existsSync + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + if (cmd.includes('models list')) { + return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]); + } + return ''; + }); + + provider = new CopilotProvider(); + delete process.env.GITHUB_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getName', () => { + it("should return 'copilot' as provider name", () => { + expect(provider.getName()).toBe('copilot'); + }); + }); + + describe('getCliName', () => { + it("should return 'copilot' as CLI name", () => { + expect(provider.getCliName()).toBe('copilot'); + }); + }); + + describe('supportsFeature', () => { + it('should support tools feature', () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it('should support text feature', () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it('should support streaming feature', () => { + expect(provider.supportsFeature('streaming')).toBe(true); + }); + + it('should NOT support vision feature (not implemented yet)', () => { + expect(provider.supportsFeature('vision')).toBe(false); + }); + + it('should not support unknown feature', () => { + expect(provider.supportsFeature('unknown')).toBe(false); + }); + }); + + describe('getAvailableModels', () => { + it('should return static model definitions', () => { + const models = provider.getAvailableModels(); + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + + // All models should have required fields + models.forEach((model) => { + expect(model.id).toBeDefined(); + expect(model.name).toBeDefined(); + expect(model.provider).toBe('copilot'); + }); + }); + + it('should include copilot- prefix in model IDs', () => { + const models = provider.getAvailableModels(); + models.forEach((model) => { + expect(model.id).toMatch(/^copilot-/); + }); + }); + }); + + describe('checkAuth', () => { + it('should return authenticated status when gh CLI is logged in', async () => { + // Set up mocks BEFORE creating provider to ensure CLI detection succeeds + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(true); + expect(status.method).toBe('oauth'); + expect(status.login).toBe('testuser'); + }); + + it('should return unauthenticated when gh auth fails', async () => { + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + throw new Error('Not logged in'); + } + if (cmd.includes('copilot auth status')) { + throw new Error('Not logged in'); + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(false); + expect(status.method).toBe('none'); + }); + + it('should detect GITHUB_TOKEN environment variable', async () => { + process.env.GITHUB_TOKEN = 'test-token'; + + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.0.0'; + } + if (cmd.includes('gh auth status')) { + throw new Error('Not logged in'); + } + if (cmd.includes('copilot auth status')) { + throw new Error('Not logged in'); + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.checkAuth(); + expect(status.authenticated).toBe(true); + expect(status.method).toBe('oauth'); + + delete process.env.GITHUB_TOKEN; + }); + }); + + describe('detectInstallation', () => { + it('should detect installed CLI', async () => { + // Set up mocks BEFORE creating provider + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(execSync).mockImplementation((cmd: string) => { + // CLI path detection (which/where command) + if (cmd.startsWith('which ') || cmd.startsWith('where ')) { + return '/usr/local/bin/copilot'; + } + if (cmd.includes('--version')) { + return '1.2.3'; + } + if (cmd.includes('gh auth status')) { + return 'Logged in to github.com account testuser'; + } + return ''; + }); + + // Create fresh provider with the mock in place + const freshProvider = new CopilotProvider(); + const status = await freshProvider.detectInstallation(); + expect(status.installed).toBe(true); + expect(status.version).toBe('1.2.3'); + expect(status.authenticated).toBe(true); + }); + }); + + describe('normalizeEvent', () => { + it('should normalize assistant.message event', () => { + const event = { + type: 'assistant.message', + data: { content: 'Hello, world!' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello, world!' }], + }, + }); + }); + + it('should skip assistant.message_delta event', () => { + const event = { + type: 'assistant.message_delta', + data: { delta: 'partial' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toBeNull(); + }); + + it('should normalize tool.execution_start event', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'read_file', + toolCallId: 'call-123', + input: { path: '/test/file.txt' }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', // Normalized from read_file + tool_use_id: 'call-123', + input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized + }, + ], + }, + }); + }); + + it('should normalize tool.execution_complete event', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-123', + success: true, + result: { + content: 'file content', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-123', + content: 'file content', + }, + ], + }, + }); + }); + + it('should handle tool.execution_complete with error', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-456', + success: false, + error: { + message: 'Command failed', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-456', + content: '[ERROR] Command failed', + }); + }); + + it('should handle tool.execution_complete with empty result', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-789', + success: true, + result: { + content: '', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-789', + content: '', + }); + }); + + it('should handle tool.execution_complete with missing result', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-999', + success: true, + // No result field + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-999', + content: '', + }); + }); + + it('should handle tool.execution_complete with error code', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-567', + success: false, + error: { + message: 'Permission denied', + code: 'EACCES', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-567', + content: '[ERROR] Permission denied (EACCES)', + }); + }); + + it('should normalize session.idle to success result', () => { + const event = { type: 'session.idle' }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'result', + subtype: 'success', + }); + }); + + it('should normalize session.error to error event', () => { + const event = { + type: 'session.error', + data: { message: 'Something went wrong' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).toEqual({ + type: 'error', + error: 'Something went wrong', + }); + }); + + it('should use error code in fallback when session.error message is empty', () => { + const event = { + type: 'session.error', + data: { message: '', code: 'RATE_LIMIT_EXCEEDED' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toContain('RATE_LIMIT_EXCEEDED'); + expect(result!.error).not.toBe('Unknown error'); + }); + + it('should return generic "Copilot agent error" fallback when both message and code are empty', () => { + const event = { + type: 'session.error', + data: { message: '', code: '' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toBe('Copilot agent error'); + // Must NOT be the old opaque 'Unknown error' + expect(result!.error).not.toBe('Unknown error'); + }); + + it('should return generic "Copilot agent error" fallback when data has no code field', () => { + const event = { + type: 'session.error', + data: { message: '' }, + }; + + const result = provider.normalizeEvent(event); + expect(result).not.toBeNull(); + expect(result!.type).toBe('error'); + expect(result!.error).toBe('Copilot agent error'); + }); + + it('should return null for unknown event types', () => { + const event = { type: 'unknown.event' }; + + const result = provider.normalizeEvent(event); + expect(result).toBeNull(); + }); + }); + + describe('mapError', () => { + it('should map authentication errors', () => { + const errorInfo = (provider as any).mapError('not authenticated', null); + expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map rate limit errors', () => { + const errorInfo = (provider as any).mapError('rate limit exceeded', null); + expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map model unavailable errors', () => { + const errorInfo = (provider as any).mapError('model not available', null); + expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map network errors', () => { + const errorInfo = (provider as any).mapError('connection refused', null); + expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should map process crash (exit code 137)', () => { + const errorInfo = (provider as any).mapError('', 137); + expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED); + expect(errorInfo.recoverable).toBe(true); + }); + + it('should return unknown error for unrecognized errors', () => { + const errorInfo = (provider as any).mapError('some random error', 1); + expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN); + expect(errorInfo.recoverable).toBe(false); + }); + }); + + describe('model cache', () => { + it('should indicate when cache is empty', () => { + expect(provider.hasCachedModels()).toBe(false); + }); + + it('should clear model cache', () => { + provider.clearModelCache(); + expect(provider.hasCachedModels()).toBe(false); + }); + }); + + describe('tool name normalization', () => { + it('should normalize read_file to Read', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'read_file', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' }); + }); + + it('should normalize write_file to Write', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'write_file', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' }); + }); + + it('should normalize run_shell to Bash', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'run_shell', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' }); + }); + + it('should normalize search to Grep', () => { + const event = { + type: 'tool.execution_start', + data: { toolName: 'search', toolCallId: 'id', input: {} }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' }); + }); + + it('should normalize todo_write to TodoWrite', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Test task', status: 'pending' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' }); + }); + + it('should normalize todo content from description', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Test task', status: 'pending' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + const todoInput = (result?.message?.content?.[0] as any)?.input; + expect(todoInput.todos[0]).toMatchObject({ + content: 'Test task', + status: 'pending', + activeForm: 'Test task', + }); + }); + + it('should map cancelled status to completed', () => { + const event = { + type: 'tool.execution_start', + data: { + toolName: 'todo_write', + toolCallId: 'id', + input: { + todos: [{ description: 'Cancelled task', status: 'cancelled' }], + }, + }, + }; + const result = provider.normalizeEvent(event); + const todoInput = (result?.message?.content?.[0] as any)?.input; + expect(todoInput.todos[0].status).toBe('completed'); + }); + }); + + describe('executeQuery resume behavior', () => { + it('uses resumeSession when sdkSessionId is provided', async () => { + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'session-123', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true); + }); + + it('falls back to createSession when resumeSession fails', async () => { + resumeSessionMock.mockRejectedValueOnce(new Error('session not found')); + createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session')); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'stale-session', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'stale-session', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/cursor-config-manager.test.ts b/jules_branch/apps/server/tests/unit/providers/cursor-config-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..114854098ac06a420bcce66960e836234677a78c --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/cursor-config-manager.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import os from 'os'; +import { CursorConfigManager } from '@/providers/cursor-config-manager.js'; + +vi.mock('fs'); +vi.mock('@automaker/platform', () => ({ + getAutomakerDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker')), +})); + +describe('cursor-config-manager.ts', () => { + // Use platform-agnostic paths + const testProjectPath = path.join(os.tmpdir(), 'test-project'); + const expectedConfigPath = path.join(testProjectPath, '.automaker', 'cursor-config.json'); + let manager: CursorConfigManager; + + beforeEach(() => { + vi.clearAllMocks(); + // Default: no existing config file + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdirSync).mockReturnValue(undefined); + vi.mocked(fs.writeFileSync).mockReturnValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('constructor', () => { + it('should load existing config from disk', () => { + const existingConfig = { + defaultModel: 'claude-3-5-sonnet', + models: ['auto', 'claude-3-5-sonnet'], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingConfig)); + + manager = new CursorConfigManager(testProjectPath); + + expect(fs.existsSync).toHaveBeenCalledWith(expectedConfigPath); + expect(fs.readFileSync).toHaveBeenCalledWith(expectedConfigPath, 'utf8'); + expect(manager.getConfig()).toEqual(existingConfig); + }); + + it('should use default config if file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + manager = new CursorConfigManager(testProjectPath); + + const config = manager.getConfig(); + expect(config.defaultModel).toBe('cursor-auto'); + expect(config.models).toContain('cursor-auto'); + }); + + it('should use default config if file read fails', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('Read error'); + }); + + manager = new CursorConfigManager(testProjectPath); + + expect(manager.getDefaultModel()).toBe('cursor-auto'); + }); + + it('should use default config if JSON parse fails', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('invalid json'); + + manager = new CursorConfigManager(testProjectPath); + + expect(manager.getDefaultModel()).toBe('cursor-auto'); + }); + }); + + describe('getConfig', () => { + it('should return a copy of the config', () => { + manager = new CursorConfigManager(testProjectPath); + + const config1 = manager.getConfig(); + const config2 = manager.getConfig(); + + expect(config1).toEqual(config2); + expect(config1).not.toBe(config2); // Different objects + }); + }); + + describe('getDefaultModel / setDefaultModel', () => { + beforeEach(() => { + manager = new CursorConfigManager(testProjectPath); + }); + + it('should return default model', () => { + expect(manager.getDefaultModel()).toBe('cursor-auto'); + }); + + it('should set and persist default model', () => { + manager.setDefaultModel('claude-3-5-sonnet'); + + expect(manager.getDefaultModel()).toBe('claude-3-5-sonnet'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should return cursor-auto if defaultModel is undefined', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ models: ['cursor-auto'] })); + + manager = new CursorConfigManager(testProjectPath); + + expect(manager.getDefaultModel()).toBe('cursor-auto'); + }); + }); + + describe('getEnabledModels / setEnabledModels', () => { + beforeEach(() => { + manager = new CursorConfigManager(testProjectPath); + }); + + it('should return enabled models', () => { + const models = manager.getEnabledModels(); + expect(Array.isArray(models)).toBe(true); + expect(models).toContain('cursor-auto'); + }); + + it('should set enabled models', () => { + manager.setEnabledModels(['claude-3-5-sonnet', 'gpt-4o']); + + expect(manager.getEnabledModels()).toEqual(['claude-3-5-sonnet', 'gpt-4o']); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should return [cursor-auto] if models is undefined', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' })); + + manager = new CursorConfigManager(testProjectPath); + + expect(manager.getEnabledModels()).toEqual(['cursor-auto']); + }); + }); + + describe('addModel', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + defaultModel: 'cursor-auto', + models: ['cursor-auto'], + }) + ); + manager = new CursorConfigManager(testProjectPath); + }); + + it('should add a new model', () => { + manager.addModel('claude-3-5-sonnet'); + + expect(manager.getEnabledModels()).toContain('claude-3-5-sonnet'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should not add duplicate models', () => { + manager.addModel('cursor-auto'); + + // Should not save if model already exists + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should initialize models array if undefined', () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'cursor-auto' })); + manager = new CursorConfigManager(testProjectPath); + + manager.addModel('claude-3-5-sonnet'); + + expect(manager.getEnabledModels()).toContain('claude-3-5-sonnet'); + }); + }); + + describe('removeModel', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + defaultModel: 'auto', + models: ['auto', 'claude-3-5-sonnet', 'gpt-4o'], + }) + ); + manager = new CursorConfigManager(testProjectPath); + }); + + it('should remove a model', () => { + manager.removeModel('gpt-4o'); + + expect(manager.getEnabledModels()).not.toContain('gpt-4o'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should handle removing non-existent model', () => { + manager.removeModel('non-existent' as any); + + // Should still save (filtering happens regardless) + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should do nothing if models array is undefined', () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' })); + manager = new CursorConfigManager(testProjectPath); + + manager.removeModel('auto'); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); + + describe('isModelEnabled', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + defaultModel: 'auto', + models: ['auto', 'claude-3-5-sonnet'], + }) + ); + manager = new CursorConfigManager(testProjectPath); + }); + + it('should return true for enabled model', () => { + expect(manager.isModelEnabled('auto')).toBe(true); + expect(manager.isModelEnabled('claude-3-5-sonnet')).toBe(true); + }); + + it('should return false for disabled model', () => { + expect(manager.isModelEnabled('gpt-4o')).toBe(false); + }); + + it('should return false if models is undefined', () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ defaultModel: 'auto' })); + manager = new CursorConfigManager(testProjectPath); + + expect(manager.isModelEnabled('auto')).toBe(false); + }); + }); + + describe('getMcpServers / setMcpServers', () => { + beforeEach(() => { + manager = new CursorConfigManager(testProjectPath); + }); + + it('should return empty array by default', () => { + expect(manager.getMcpServers()).toEqual([]); + }); + + it('should set and get MCP servers', () => { + manager.setMcpServers(['server1', 'server2']); + + expect(manager.getMcpServers()).toEqual(['server1', 'server2']); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('getRules / setRules', () => { + beforeEach(() => { + manager = new CursorConfigManager(testProjectPath); + }); + + it('should return empty array by default', () => { + expect(manager.getRules()).toEqual([]); + }); + + it('should set and get rules', () => { + manager.setRules(['.cursorrules', 'rules.md']); + + expect(manager.getRules()).toEqual(['.cursorrules', 'rules.md']); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + defaultModel: 'claude-3-5-sonnet', + models: ['claude-3-5-sonnet'], + mcpServers: ['server1'], + rules: ['rules.md'], + }) + ); + manager = new CursorConfigManager(testProjectPath); + }); + + it('should reset to default values', () => { + manager.reset(); + + expect(manager.getDefaultModel()).toBe('cursor-auto'); + expect(manager.getMcpServers()).toEqual([]); + expect(manager.getRules()).toEqual([]); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe('exists', () => { + it('should return true if config file exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + manager = new CursorConfigManager(testProjectPath); + + vi.mocked(fs.existsSync).mockReturnValue(true); + expect(manager.exists()).toBe(true); + }); + + it('should return false if config file does not exist', () => { + manager = new CursorConfigManager(testProjectPath); + + vi.mocked(fs.existsSync).mockReturnValue(false); + expect(manager.exists()).toBe(false); + }); + }); + + describe('getConfigPath', () => { + it('should return the config file path', () => { + manager = new CursorConfigManager(testProjectPath); + + expect(manager.getConfigPath()).toBe(expectedConfigPath); + }); + }); + + describe('saveConfig', () => { + it('should create directory if it does not exist', () => { + vi.mocked(fs.existsSync) + .mockReturnValueOnce(false) // For loadConfig + .mockReturnValueOnce(false); // For directory check in saveConfig + + manager = new CursorConfigManager(testProjectPath); + manager.setDefaultModel('claude-3-5-sonnet'); + + expect(fs.mkdirSync).toHaveBeenCalledWith(path.dirname(expectedConfigPath), { + recursive: true, + }); + }); + + it('should throw error on write failure', () => { + manager = new CursorConfigManager(testProjectPath); + + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Write failed'); + }); + + expect(() => manager.setDefaultModel('claude-3-5-sonnet')).toThrow('Write failed'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/cursor-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/cursor-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..846ac69be2593fb325f9be1d86bbadf80c0fd5da --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CursorProvider } from '@/providers/cursor-provider.js'; +import { validateBareModelId } from '@automaker/types'; + +describe('cursor-provider.ts', () => { + describe('buildCliArgs', () => { + it('adds --resume when sdkSessionId is provided', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Continue the task', + model: 'gpt-5', + cwd: '/tmp/project', + sdkSessionId: 'cursor-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-session-123'); + }); + + it('does not add --resume when sdkSessionId is omitted', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Start a new task', + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); + + describe('normalizeEvent - result error handling', () => { + let provider: CursorProvider; + + beforeEach(() => { + provider = Object.create(CursorProvider.prototype) as CursorProvider; + }); + + it('returns error message from resultEvent.error when is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: 'Rate limit exceeded', + result: '', + subtype: 'error', + duration_ms: 3000, + session_id: 'sess-123', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toBe('Rate limit exceeded'); + }); + + it('falls back to resultEvent.result when error field is empty and is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: 'Process terminated unexpectedly', + subtype: 'error', + duration_ms: 5000, + session_id: 'sess-456', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toBe('Process terminated unexpectedly'); + }); + + it('builds diagnostic fallback when both error and result are empty and is_error=true', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: '', + subtype: 'error', + duration_ms: 5000, + session_id: 'sess-789', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + // Should contain diagnostic info rather than 'Unknown error' + expect(msg!.error).toContain('5000ms'); + expect(msg!.error).toContain('sess-789'); + expect(msg!.error).not.toBe('Unknown error'); + }); + + it('preserves session_id in error message', () => { + const event = { + type: 'result', + is_error: true, + error: 'Timeout occurred', + result: '', + subtype: 'error', + duration_ms: 30000, + session_id: 'my-session-id', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg!.session_id).toBe('my-session-id'); + }); + + it('uses "none" when session_id is missing from diagnostic fallback', () => { + const event = { + type: 'result', + is_error: true, + error: '', + result: '', + subtype: 'error', + duration_ms: 5000, + // session_id intentionally omitted + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('error'); + expect(msg!.error).toContain('none'); + expect(msg!.error).not.toContain('undefined'); + }); + + it('returns success result when is_error=false', () => { + const event = { + type: 'result', + is_error: false, + error: '', + result: 'Completed successfully', + subtype: 'success', + duration_ms: 2000, + session_id: 'sess-ok', + }; + + const msg = provider.normalizeEvent(event); + + expect(msg).not.toBeNull(); + expect(msg!.type).toBe('result'); + expect(msg!.subtype).toBe('success'); + }); + }); + + describe('Cursor Gemini models support', () => { + let provider: CursorProvider; + + beforeEach(() => { + provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + }); + + describe('buildCliArgs with Cursor Gemini models', () => { + it('should handle cursor-gemini-3-pro model', () => { + const args = provider.buildCliArgs({ + prompt: 'Write a function', + model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-3-pro'); + }); + + it('should handle cursor-gemini-3-flash model', () => { + const args = provider.buildCliArgs({ + prompt: 'Quick task', + model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-3-flash'); + }); + + it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Continue task', + model: 'gemini-3-pro', + cwd: '/tmp/project', + sdkSessionId: 'cursor-gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123'); + }); + }); + + describe('validateBareModelId with Cursor Gemini models', () => { + it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => { + // This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro" + expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow(); + expect(() => + validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor') + ).not.toThrow(); + }); + + it('should still reject other provider prefixes for Cursor provider', () => { + expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + }); + + it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => { + // Note: When expectedProvider="cursor", we skip the cursor- prefix check + // This is intentional because the validation happens AFTER prefix stripping + // So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor", + // it means the prefix was NOT properly stripped, but we skip it anyway + // since we're checking if the Cursor provider itself can receive cursor- prefixed models + expect(() => + validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor') + ).not.toThrow(); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/gemini-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/gemini-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dffc56882f59b2f0699f4ae659538768cb90e2e --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; +import type { ProviderMessage } from '@automaker/types'; +import { validateBareModelId } from '@automaker/types'; + +describe('gemini-provider.ts', () => { + let provider: GeminiProvider; + + beforeEach(() => { + provider = new GeminiProvider(); + }); + + describe('buildCliArgs', () => { + it('should include --prompt with empty string to force headless mode', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello from Gemini', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const promptIndex = args.indexOf('--prompt'); + expect(promptIndex).toBeGreaterThan(-1); + expect(args[promptIndex + 1]).toBe(''); + }); + + it('should include --resume when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + sdkSessionId: 'gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('gemini-session-123'); + }); + + it('should not include --resume when sdkSessionId is missing', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + + it('should include --sandbox false for faster execution', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const sandboxIndex = args.indexOf('--sandbox'); + expect(sandboxIndex).toBeGreaterThan(-1); + expect(args[sandboxIndex + 1]).toBe('false'); + }); + + it('should include --approval-mode yolo for non-interactive use', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const approvalIndex = args.indexOf('--approval-mode'); + expect(approvalIndex).toBeGreaterThan(-1); + expect(args[approvalIndex + 1]).toBe('yolo'); + }); + + it('should include --output-format stream-json', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const formatIndex = args.indexOf('--output-format'); + expect(formatIndex).toBeGreaterThan(-1); + expect(args[formatIndex + 1]).toBe('stream-json'); + }); + + it('should include --include-directories with cwd', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/my-project', + }); + + const dirIndex = args.indexOf('--include-directories'); + expect(dirIndex).toBeGreaterThan(-1); + expect(args[dirIndex + 1]).toBe('/tmp/my-project'); + }); + + it('should add gemini- prefix to bare model names', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-2.5-flash'); + }); + + it('should not double-prefix model names that already have gemini-', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'gemini-2.5-pro', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-2.5-pro'); + }); + }); + + describe('normalizeEvent - error handling', () => { + it('returns error from result event when status=error and error field is set', () => { + const event = { + type: 'result', + status: 'error', + error: 'Model overloaded', + session_id: 'sess-gemini-1', + stats: { duration_ms: 4000, total_tokens: 0 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toBe('Model overloaded'); + expect(msg.session_id).toBe('sess-gemini-1'); + }); + + it('builds diagnostic fallback when result event has status=error but empty error field', () => { + const event = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-gemini-2', + stats: { duration_ms: 7500, total_tokens: 0 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + // Diagnostic info should be present instead of 'Unknown error' + expect(msg.error).toContain('7500ms'); + expect(msg.error).toContain('sess-gemini-2'); + expect(msg.error).not.toBe('Unknown error'); + }); + + it('builds fallback with "unknown" duration when stats are missing', () => { + const event = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-gemini-nostats', + // no stats field + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toContain('unknown'); + }); + + it('returns error from standalone error event with error field set', () => { + const event = { + type: 'error', + error: 'API key invalid', + session_id: 'sess-gemini-3', + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toBe('API key invalid'); + }); + + it('builds diagnostic fallback when standalone error event has empty error field', () => { + const event = { + type: 'error', + error: '', + session_id: 'sess-gemini-empty', + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + // Should include session_id, not just 'Unknown error' + expect(msg.error).toContain('sess-gemini-empty'); + expect(msg.error).not.toBe('Unknown error'); + }); + + it('builds fallback mentioning "none" when session_id is missing from error event', () => { + const event = { + type: 'error', + error: '', + // no session_id + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('error'); + expect(msg.error).toContain('none'); + }); + + it('uses consistent "Gemini agent failed" label for both result and error event fallbacks', () => { + const resultEvent = { + type: 'result', + status: 'error', + error: '', + session_id: 'sess-r', + stats: { duration_ms: 1000 }, + }; + const errorEvent = { + type: 'error', + error: '', + session_id: 'sess-e', + }; + + const resultMsg = provider.normalizeEvent(resultEvent) as ProviderMessage; + const errorMsg = provider.normalizeEvent(errorEvent) as ProviderMessage; + + // Both fallback messages should use the same "Gemini agent failed" prefix + expect(resultMsg.error).toContain('Gemini agent failed'); + expect(errorMsg.error).toContain('Gemini agent failed'); + }); + + it('returns success result when result event has status=success', () => { + const event = { + type: 'result', + status: 'success', + error: '', + session_id: 'sess-gemini-ok', + stats: { duration_ms: 1200, total_tokens: 500 }, + }; + + const msg = provider.normalizeEvent(event) as ProviderMessage; + + expect(msg).not.toBeNull(); + expect(msg.type).toBe('result'); + expect(msg.subtype).toBe('success'); + }); + }); + + describe('validateBareModelId integration', () => { + it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => { + expect(() => + validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini') + ).not.toThrow(); + expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow(); + }); + + it('should reject other provider prefixes for Gemini provider', () => { + expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/opencode-provider.test.ts b/jules_branch/apps/server/tests/unit/providers/opencode-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3a0d7269d28b5f6600c9f5b27ededa3477d8a09 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -0,0 +1,1627 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + OpencodeProvider, + resetToolUseIdCounter, +} from '../../../src/providers/opencode-provider.js'; +import type { ProviderMessage, ModelDefinition } from '@automaker/types'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform'; + +vi.mock('@automaker/platform', () => ({ + spawnJSONLProcess: vi.fn(), + isWslAvailable: vi.fn().mockReturnValue(false), + findCliInWsl: vi.fn().mockReturnValue(null), + createWslCommand: vi.fn(), + windowsToWslPath: vi.fn(), + getOpenCodeAuthIndicators: vi.fn().mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }), +})); + +describe('opencode-provider.ts', () => { + let provider: OpencodeProvider; + + beforeEach(() => { + vi.clearAllMocks(); + resetToolUseIdCounter(); + provider = new OpencodeProvider(); + }); + + afterEach(() => { + // Note: Don't use vi.restoreAllMocks() here as it would undo the module-level + // mock implementations (like getOpenCodeAuthIndicators) set up with vi.mock() + }); + + // ========================================================================== + // Basic Provider Tests + // ========================================================================== + + describe('getName', () => { + it("should return 'opencode' as provider name", () => { + expect(provider.getName()).toBe('opencode'); + }); + }); + + describe('getCliName', () => { + it("should return 'opencode' as CLI name", () => { + expect(provider.getCliName()).toBe('opencode'); + }); + }); + + describe('getAvailableModels', () => { + it('should return 5 models', () => { + const models = provider.getAvailableModels(); + expect(models).toHaveLength(5); + }); + + it('should include Big Pickle as default', () => { + const models = provider.getAvailableModels(); + const bigPickle = models.find((m) => m.id === 'opencode/big-pickle'); + + expect(bigPickle).toBeDefined(); + expect(bigPickle?.name).toBe('Big Pickle (Free)'); + expect(bigPickle?.provider).toBe('opencode'); + expect(bigPickle?.default).toBe(true); + expect(bigPickle?.modelString).toBe('opencode/big-pickle'); + }); + + it('should include free tier GLM model', () => { + const models = provider.getAvailableModels(); + const glm = models.find((m) => m.id === 'opencode/glm-5-free'); + + expect(glm).toBeDefined(); + expect(glm?.name).toBe('GLM 5 Free'); + expect(glm?.tier).toBe('basic'); + }); + + it('should include free tier MiniMax model', () => { + const models = provider.getAvailableModels(); + const minimax = models.find((m) => m.id === 'opencode/minimax-m2.5-free'); + + expect(minimax).toBeDefined(); + expect(minimax?.name).toBe('MiniMax M2.5 Free'); + expect(minimax?.tier).toBe('basic'); + }); + + it('should have all models support tools', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.supportsTools).toBe(true); + }); + }); + + it('should have models with modelString property', () => { + const models = provider.getAvailableModels(); + + for (const model of models) { + expect(model).toHaveProperty('modelString'); + expect(typeof model.modelString).toBe('string'); + } + }); + }); + + describe('parseModelsOutput', () => { + it('should parse nested provider model IDs', () => { + const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n'); + + const parseModelsOutput = ( + provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } + ).parseModelsOutput.bind(provider); + const models = parseModelsOutput(output); + + expect(models).toHaveLength(2); + const openrouterModel = models.find((model) => model.id.startsWith('openrouter/')); + + expect(openrouterModel).toBeDefined(); + expect(openrouterModel?.provider).toBe('openrouter'); + expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet'); + }); + }); + + describe('supportsFeature', () => { + it("should support 'tools' feature", () => { + expect(provider.supportsFeature('tools')).toBe(true); + }); + + it("should support 'text' feature", () => { + expect(provider.supportsFeature('text')).toBe(true); + }); + + it("should support 'vision' feature", () => { + expect(provider.supportsFeature('vision')).toBe(true); + }); + + it("should not support 'thinking' feature", () => { + expect(provider.supportsFeature('thinking')).toBe(false); + }); + + it("should not support 'mcp' feature", () => { + expect(provider.supportsFeature('mcp')).toBe(false); + }); + + it("should not support 'cli' feature", () => { + expect(provider.supportsFeature('cli')).toBe(false); + }); + + it('should return false for unknown features', () => { + expect(provider.supportsFeature('unknown-feature')).toBe(false); + expect(provider.supportsFeature('nonexistent')).toBe(false); + expect(provider.supportsFeature('')).toBe(false); + }); + }); + + // ========================================================================== + // buildCliArgs Tests + // ========================================================================== + + describe('buildCliArgs', () => { + it('should build correct args with run subcommand', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode/big-pickle', + cwd: '/tmp/project', + }); + + expect(args[0]).toBe('run'); + }); + + it('should include --format json for streaming output', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode/big-pickle', + cwd: '/tmp/project', + }); + + const formatIndex = args.indexOf('--format'); + expect(formatIndex).toBeGreaterThan(-1); + expect(args[formatIndex + 1]).toBe('json'); + }); + + it('should include model with --model flag', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'anthropic/claude-sonnet-4-5', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('should strip opencode- prefix from model', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode-anthropic/claude-sonnet-4-5', + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toBe('anthropic/claude-sonnet-4-5'); + }); + + it('should handle missing cwd', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode/big-pickle', + }); + + expect(args).not.toContain('-c'); + }); + + it('should handle model from opencode provider', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: 'opencode/big-pickle', + cwd: '/tmp/project', + }); + + expect(args).toContain('--model'); + expect(args).toContain('opencode/big-pickle'); + }); + }); + + // ========================================================================== + // normalizeEvent Tests + // ========================================================================== + + describe('normalizeEvent', () => { + describe('text events (new OpenCode format)', () => { + it('should convert text to assistant message with text content', () => { + const event = { + type: 'text', + part: { + type: 'text', + text: 'Hello, world!', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello, world!', + }, + ], + }, + }); + }); + + it('should return null for empty text', () => { + const event = { + type: 'text', + part: { + type: 'text', + text: '', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + + it('should return null for text with undefined text', () => { + const event = { + type: 'text', + part: {}, + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + }); + + describe('tool_call events', () => { + it('should convert tool_call to assistant message with tool_use content', () => { + const event = { + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', + tool_use_id: 'call-123', + input: { file_path: '/tmp/test.txt' }, + }, + ], + }, + }); + }); + + it('should generate tool_use_id when call_id is missing', () => { + const event = { + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Write', + args: { content: 'test' }, + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].tool_use_id).toBe('opencode-tool-1'); + + // Second call should increment + const result2 = provider.normalizeEvent({ + type: 'tool_call', + part: { + type: 'tool-call', + name: 'Edit', + args: {}, + }, + }); + expect(result2?.message?.content[0].tool_use_id).toBe('opencode-tool-2'); + }); + }); + + describe('tool_result events', () => { + it('should convert tool_result to assistant message with tool_result content', () => { + const event = { + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: 'File contents here', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'assistant', + session_id: 'test-session', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-123', + content: 'File contents here', + }, + ], + }, + }); + }); + + it('should handle tool_result without call_id', () => { + const event = { + type: 'tool_result', + part: { + type: 'tool-result', + output: 'Result without ID', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_result'); + expect(result?.message?.content[0].tool_use_id).toBeUndefined(); + }); + }); + + describe('tool_error events', () => { + it('should convert tool_error to error message', () => { + const event = { + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + error: 'File not found', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'error', + session_id: 'test-session', + error: 'File not found', + }); + }); + + it('should provide default error message when error is missing', () => { + const event = { + type: 'tool_error', + part: { + type: 'tool-error', + call_id: 'call-123', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Tool execution failed'); + }); + }); + + describe('step_start events', () => { + it('should return null for step_start events (informational)', () => { + const event = { + type: 'step_start', + part: { + type: 'step-start', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + }); + + describe('step_finish events', () => { + it('should convert successful step_finish to result message', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Task completed successfully', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'result', + subtype: 'success', + session_id: 'test-session', + result: 'Task completed successfully', + }); + }); + + it('should convert step_finish with error to error message', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + error: 'Something went wrong', + }, + sessionID: 'test-session', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toEqual({ + type: 'error', + session_id: 'test-session', + error: 'Something went wrong', + }); + }); + + it('should convert step_finish with error property to error message', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + error: 'Process failed', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Process failed'); + }); + + it('should provide default error message for failed step without error text', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'error', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('Step execution failed'); + }); + + it('should treat step_finish with reason=stop as success', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Done', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('result'); + expect(result?.subtype).toBe('success'); + }); + }); + + describe('unknown events', () => { + it('should return null for unknown event types', () => { + const event = { + type: 'unknown-event', + data: 'some data', + }; + + const result = provider.normalizeEvent(event); + + expect(result).toBeNull(); + }); + + it('should return null for null input', () => { + const result = provider.normalizeEvent(null); + expect(result).toBeNull(); + }); + + it('should return null for undefined input', () => { + const result = provider.normalizeEvent(undefined); + expect(result).toBeNull(); + }); + + it('should return null for non-object input', () => { + expect(provider.normalizeEvent('string')).toBeNull(); + expect(provider.normalizeEvent(123)).toBeNull(); + expect(provider.normalizeEvent(true)).toBeNull(); + }); + + it('should return null for events without type', () => { + expect(provider.normalizeEvent({})).toBeNull(); + expect(provider.normalizeEvent({ data: 'no type' })).toBeNull(); + }); + }); + }); + + // ========================================================================== + // executeQuery Tests + // ========================================================================== + + describe('executeQuery', () => { + /** + * Helper to set up the provider with a mocked CLI path + * This bypasses CLI detection for testing + */ + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + // Access protected property to simulate CLI detection + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should stream text events as assistant messages', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { type: 'text', part: { type: 'text', text: 'Hello ' } }, + { type: 'text', part: { type: 'text', text: 'World!' } }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Say hello', + model: 'anthropic/claude-sonnet-4-5', + cwd: '/tmp', + }) + ); + + expect(results).toHaveLength(2); + expect(results[0].type).toBe('assistant'); + expect(results[0].message?.content[0].text).toBe('Hello '); + expect(results[1].message?.content[0].text).toBe('World!'); + }); + + it('should emit tool_use and tool_result with matching IDs', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'tool-1', + name: 'Read', + args: { file_path: '/tmp/test.txt' }, + }, + }, + { + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'tool-1', + output: 'File contents', + }, + }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Read a file', + cwd: '/tmp', + }) + ); + + expect(results).toHaveLength(2); + + const toolUse = results[0]; + const toolResult = results[1]; + + expect(toolUse.type).toBe('assistant'); + expect(toolUse.message?.content[0].type).toBe('tool_use'); + expect(toolUse.message?.content[0].tool_use_id).toBe('tool-1'); + + expect(toolResult.type).toBe('assistant'); + expect(toolResult.message?.content[0].type).toBe('tool_result'); + expect(toolResult.message?.content[0].tool_use_id).toBe('tool-1'); + }); + + it('should pass stdinData containing the prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'My test prompt', + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('My test prompt'); + }); + + it('should extract text from array prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const arrayPrompt = [ + { type: 'text', text: 'First part' }, + { type: 'image', source: { type: 'base64', data: '...' } }, + { type: 'text', text: 'Second part' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: arrayPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('First part\nSecond part'); + }); + + it('should include correct CLI args in subprocess options', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + model: 'opencode-anthropic/claude-opus-4-5', + cwd: '/tmp/workspace', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args).toContain('run'); + expect(call.args).toContain('--format'); + expect(call.args).toContain('json'); + expect(call.args).toContain('--model'); + expect(call.args).toContain('anthropic/claude-opus-4-5'); + }); + + it('should skip null-normalized events', async () => { + const mockedProvider = setupMockedProvider(); + + const mockEvents = [ + { type: 'unknown-internal-event', data: 'ignored' }, + { type: 'text', part: { type: 'text', text: 'Valid text' } }, + { type: 'another-unknown', foo: 'bar' }, + { type: 'step_finish', part: { type: 'step-finish', reason: 'stop', result: 'Done' } }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + model: 'opencode/big-pickle', + cwd: '/test', + }) + ); + + // Should only have valid events (text and result), not the unknown ones + expect(results.length).toBe(2); + }); + + it('should throw error when CLI is not installed', async () => { + // Create provider and explicitly set cliPath to null to simulate not installed + // Set detectedStrategy to 'npx' to prevent ensureCliDetected from re-running detection + const unmockedProvider = new OpencodeProvider(); + (unmockedProvider as unknown as { cliPath: string | null }).cliPath = null; + (unmockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + await expect( + collectAsyncGenerator( + unmockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }) + ) + ).rejects.toThrow(/CLI not found/); + }); + }); + + // ========================================================================== + // getSpawnConfig Tests + // ========================================================================== + + describe('getSpawnConfig', () => { + it('should return npx as Windows strategy', () => { + const config = provider.getSpawnConfig(); + expect(config.windowsStrategy).toBe('npx'); + }); + + it('should specify opencode-ai@latest as npx package', () => { + const config = provider.getSpawnConfig(); + expect(config.npxPackage).toBe('opencode-ai@latest'); + }); + + it('should include common paths for Linux', () => { + const config = provider.getSpawnConfig(); + const linuxPaths = config.commonPaths['linux']; + + expect(linuxPaths).toBeDefined(); + expect(linuxPaths.length).toBeGreaterThan(0); + expect(linuxPaths.some((p) => p.includes('opencode'))).toBe(true); + }); + + it('should include common paths for macOS', () => { + const config = provider.getSpawnConfig(); + const darwinPaths = config.commonPaths['darwin']; + + expect(darwinPaths).toBeDefined(); + expect(darwinPaths.length).toBeGreaterThan(0); + expect(darwinPaths.some((p) => p.includes('homebrew'))).toBe(true); + }); + + it('should include common paths for Windows', () => { + const config = provider.getSpawnConfig(); + const win32Paths = config.commonPaths['win32']; + + expect(win32Paths).toBeDefined(); + expect(win32Paths.length).toBeGreaterThan(0); + expect(win32Paths.some((p) => p.includes('npm'))).toBe(true); + }); + }); + + // ========================================================================== + // detectInstallation Tests + // ========================================================================== + + describe('detectInstallation', () => { + beforeEach(() => { + // Ensure the mock implementation is set up for each test + vi.mocked(getOpenCodeAuthIndicators).mockResolvedValue({ + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }); + }); + + it('should return installed true when CLI is found', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(true); + expect(result.path).toBe('/usr/local/bin/opencode'); + }); + + it('should return installed false when CLI is not found', async () => { + // Set both cliPath to null and detectedStrategy to something other than 'native' + // to prevent ensureCliDetected from re-detecting + (provider as unknown as { cliPath: string | null }).cliPath = null; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.detectInstallation(); + + expect(result.installed).toBe(false); + }); + + it('should return method as npm when using npx strategy', async () => { + (provider as unknown as { cliPath: string }).cliPath = 'npx'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.detectInstallation(); + + expect(result.method).toBe('npm'); + }); + + it('should return method as cli when using native strategy', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/local/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.detectInstallation(); + + expect(result.method).toBe('cli'); + }); + }); + + // ========================================================================== + // Config Management Tests (inherited from BaseProvider) + // ========================================================================== + + describe('config management', () => { + it('should get and set config', () => { + provider.setConfig({ apiKey: 'test-api-key' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('test-api-key'); + }); + + it('should merge config updates', () => { + provider.setConfig({ apiKey: 'key1' }); + provider.setConfig({ model: 'model1' }); + + const config = provider.getConfig(); + expect(config.apiKey).toBe('key1'); + expect(config.model).toBe('model1'); + }); + }); + + describe('validateConfig', () => { + it('should validate config from base class', () => { + const result = provider.validateConfig(); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + // ========================================================================== + // Additional Edge Case Tests + // ========================================================================== + + describe('extractPromptText edge cases', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should handle empty array prompt', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: [] as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe(''); + }); + + it('should handle array prompt with only image blocks (no text)', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const imageOnlyPrompt = [ + { type: 'image', source: { type: 'base64', data: 'abc123' } }, + { type: 'image', source: { type: 'base64', data: 'def456' } }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: imageOnlyPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe(''); + }); + + it('should handle array prompt with mixed content types', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const mixedPrompt = [ + { type: 'text', text: 'Analyze this image' }, + { type: 'image', source: { type: 'base64', data: 'abc123' } }, + { type: 'text', text: 'And this one' }, + { type: 'image', source: { type: 'base64', data: 'def456' } }, + { type: 'text', text: 'What differences do you see?' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: mixedPrompt as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.stdinData).toBe('Analyze this image\nAnd this one\nWhat differences do you see?'); + }); + + it('should handle text blocks with empty text property', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const promptWithEmptyText = [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: '' }, + { type: 'text', text: 'World' }, + ]; + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: promptWithEmptyText as unknown as string, + cwd: '/tmp', + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + // Empty text blocks should be filtered out + expect(call.stdinData).toBe('Hello\nWorld'); + }); + }); + + describe('abort handling', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should pass abortController to subprocess options', async () => { + const mockedProvider = setupMockedProvider(); + + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const abortController = new AbortController(); + + await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + cwd: '/tmp', + abortController, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.abortController).toBe(abortController); + }); + }); + + describe('session_id preservation', () => { + function setupMockedProvider(): OpencodeProvider { + const mockedProvider = new OpencodeProvider(); + (mockedProvider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (mockedProvider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + return mockedProvider; + } + + it('should preserve session_id through the full executeQuery flow', async () => { + const mockedProvider = setupMockedProvider(); + const sessionId = 'test-session-123'; + + const mockEvents = [ + { type: 'text', part: { type: 'text', text: 'Hello ' }, sessionID: sessionId }, + { + type: 'tool_call', + part: { type: 'tool-call', name: 'Read', args: {}, call_id: 'c1' }, + sessionID: sessionId, + }, + { + type: 'tool_result', + part: { type: 'tool-result', call_id: 'c1', output: 'file content' }, + sessionID: sessionId, + }, + { + type: 'step_finish', + part: { type: 'step-finish', reason: 'stop', result: 'Done' }, + sessionID: sessionId, + }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + + const results = await collectAsyncGenerator( + mockedProvider.executeQuery({ + prompt: 'Test', + model: 'opencode/big-pickle', + cwd: '/tmp', + }) + ); + + // All emitted messages should have the session_id + expect(results).toHaveLength(4); + results.forEach((result) => { + expect(result.session_id).toBe(sessionId); + }); + }); + }); + + describe('normalizeEvent additional edge cases', () => { + it('should handle tool_call with empty args object', () => { + const event = { + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: {}, + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toEqual({}); + }); + + it('should handle tool_call with null args', () => { + const event = { + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Glob', + args: null, + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toBeNull(); + }); + + it('should handle tool_call with complex nested args', () => { + const event = { + type: 'tool_call', + part: { + type: 'tool-call', + call_id: 'call-123', + name: 'Edit', + args: { + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }, + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_use'); + expect(result?.message?.content[0].input).toEqual({ + file_path: '/tmp/test.ts', + changes: [ + { line: 10, old: 'foo', new: 'bar' }, + { line: 20, old: 'baz', new: 'qux' }, + ], + options: { replace_all: true }, + }); + }); + + it('should handle tool_result with empty output', () => { + const event = { + type: 'tool_result', + part: { + type: 'tool-result', + call_id: 'call-123', + output: '', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].type).toBe('tool_result'); + expect(result?.message?.content[0].content).toBe(''); + }); + + it('should handle text with whitespace-only text', () => { + const event = { + type: 'text', + part: { + type: 'text', + text: ' ', + }, + }; + + const result = provider.normalizeEvent(event); + + // Whitespace should be preserved (not filtered like empty string) + expect(result).not.toBeNull(); + expect(result?.message?.content[0].text).toBe(' '); + }); + + it('should handle text with newlines', () => { + const event = { + type: 'text', + part: { + type: 'text', + text: 'Line 1\nLine 2\nLine 3', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.message?.content[0].text).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should handle step_finish with both result and error (error takes precedence)', () => { + const event = { + type: 'step_finish', + part: { + type: 'step-finish', + reason: 'stop', + result: 'Some result', + error: 'But also an error', + }, + }; + + const result = provider.normalizeEvent(event); + + expect(result?.type).toBe('error'); + expect(result?.error).toBe('But also an error'); + }); + }); + + describe('isInstalled', () => { + it('should return true when CLI path is set', async () => { + (provider as unknown as { cliPath: string }).cliPath = '/usr/bin/opencode'; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'native'; + + const result = await provider.isInstalled(); + + expect(result).toBe(true); + }); + + it('should return false when CLI path is null', async () => { + (provider as unknown as { cliPath: string | null }).cliPath = null; + (provider as unknown as { detectedStrategy: string }).detectedStrategy = 'npx'; + + const result = await provider.isInstalled(); + + expect(result).toBe(false); + }); + }); + + describe('model tier validation', () => { + it('should have exactly one default model', () => { + const models = provider.getAvailableModels(); + const defaultModels = models.filter((m) => m.default === true); + + expect(defaultModels).toHaveLength(1); + expect(defaultModels[0].id).toBe('opencode/big-pickle'); + }); + + it('should have valid tier values for all models', () => { + const models = provider.getAvailableModels(); + const validTiers = ['basic', 'standard', 'premium']; + + models.forEach((model) => { + expect(validTiers).toContain(model.tier); + }); + }); + + it('should have descriptions for all models', () => { + const models = provider.getAvailableModels(); + + models.forEach((model) => { + expect(model.description).toBeDefined(); + expect(typeof model.description).toBe('string'); + expect(model.description!.length).toBeGreaterThan(0); + }); + }); + }); + + describe('buildCliArgs edge cases', () => { + it('should handle very long prompts', () => { + const longPrompt = 'a'.repeat(10000); + const args = provider.buildCliArgs({ + prompt: longPrompt, + model: 'opencode/big-pickle', + cwd: '/tmp', + }); + + // The prompt is NOT in args (it's passed via stdin) + // Just verify the args structure is correct + expect(args).toContain('run'); + expect(args).not.toContain('-'); + expect(args.join(' ')).not.toContain(longPrompt); + }); + + it('should handle prompts with special characters', () => { + const specialPrompt = 'Test $HOME $(rm -rf /) `command` "quotes" \'single\''; + const args = provider.buildCliArgs({ + prompt: specialPrompt, + model: 'opencode/big-pickle', + cwd: '/tmp', + }); + + // Special chars in prompt should not affect args (prompt is via stdin) + expect(args).toContain('run'); + expect(args).not.toContain('-'); + }); + + it('should handle cwd with spaces', () => { + const args = provider.buildCliArgs({ + prompt: 'Test', + model: 'opencode/big-pickle', + cwd: '/tmp/path with spaces/project', + }); + + // cwd is set at subprocess level, not via CLI args + expect(args).not.toContain('-c'); + expect(args).not.toContain('/tmp/path with spaces/project'); + }); + + it('should handle model with unusual characters', () => { + const args = provider.buildCliArgs({ + prompt: 'Test', + model: 'opencode-provider/model-v1.2.3-beta', + cwd: '/tmp', + }); + + const modelIndex = args.indexOf('--model'); + expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta'); + }); + }); + + // ========================================================================== + // parseProvidersOutput Tests + // ========================================================================== + + describe('parseProvidersOutput', () => { + // Helper function to access private method + function parseProviders(output: string) { + return ( + provider as unknown as { + parseProvidersOutput: (output: string) => Array<{ + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; + }>; + } + ).parseProvidersOutput(output); + } + + // ======================================================================= + // Critical Fix Validation + // ======================================================================= + + describe('Critical Fix Validation', () => { + it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => { + const output = '● z.ai coding plan oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('zai-coding-plan'); + expect(result[0].name).toBe('z.ai coding plan'); + expect(result[0].authMethod).toBe('oauth'); + }); + + it('should map "z.ai" to "z-ai" (different from coding plan)', () => { + const output = '● z.ai api'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('z-ai'); + expect(result[0].name).toBe('z.ai'); + expect(result[0].authMethod).toBe('api_key'); + }); + + it('should distinguish between "z.ai coding plan" and "z.ai"', () => { + const output = '● z.ai coding plan oauth\n● z.ai api'; + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('zai-coding-plan'); + expect(result[0].name).toBe('z.ai coding plan'); + expect(result[1].id).toBe('z-ai'); + expect(result[1].name).toBe('z.ai'); + }); + }); + + // ======================================================================= + // Provider Name Mapping + // ======================================================================= + + describe('Provider Name Mapping', () => { + it('should map all 12 providers correctly', () => { + const output = `● anthropic oauth +● github copilot oauth +● google api +● openai api +● openrouter api +● azure api +● amazon bedrock oauth +● ollama api +● lm studio api +● opencode oauth +● z.ai coding plan oauth +● z.ai api`; + + const result = parseProviders(output); + + expect(result).toHaveLength(12); + expect(result.map((p) => p.id)).toEqual([ + 'anthropic', + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'azure', + 'amazon-bedrock', + 'ollama', + 'lmstudio', + 'opencode', + 'zai-coding-plan', + 'z-ai', + ]); + }); + + it('should handle case-insensitive provider names and preserve original casing', () => { + const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('Anthropic'); // Preserves casing + expect(result[1].id).toBe('openai'); + expect(result[1].name).toBe('OPENAI'); // Preserves casing + expect(result[2].id).toBe('github-copilot'); + expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing + }); + + it('should handle multi-word provider names with spaces', () => { + const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('amazon-bedrock'); + expect(result[0].name).toBe('Amazon Bedrock'); + expect(result[1].id).toBe('lmstudio'); + expect(result[1].name).toBe('LM Studio'); + expect(result[2].id).toBe('github-copilot'); + expect(result[2].name).toBe('GitHub Copilot'); + }); + }); + + // ======================================================================= + // Duplicate Aliases + // ======================================================================= + + describe('Duplicate Aliases', () => { + it('should map provider aliases to the same ID', () => { + // Test copilot variants + const copilot1 = parseProviders('● copilot oauth'); + const copilot2 = parseProviders('● github copilot oauth'); + expect(copilot1[0].id).toBe('github-copilot'); + expect(copilot2[0].id).toBe('github-copilot'); + + // Test bedrock variants + const bedrock1 = parseProviders('● bedrock oauth'); + const bedrock2 = parseProviders('● amazon bedrock oauth'); + expect(bedrock1[0].id).toBe('amazon-bedrock'); + expect(bedrock2[0].id).toBe('amazon-bedrock'); + + // Test lmstudio variants + const lm1 = parseProviders('● lmstudio api'); + const lm2 = parseProviders('● lm studio api'); + expect(lm1[0].id).toBe('lmstudio'); + expect(lm2[0].id).toBe('lmstudio'); + }); + }); + + // ======================================================================= + // Authentication Methods + // ======================================================================= + + describe('Authentication Methods', () => { + it('should detect oauth and api_key auth methods', () => { + const output = '● anthropic oauth\n● openai api\n● google api_key'; + const result = parseProviders(output); + + expect(result[0].authMethod).toBe('oauth'); + expect(result[1].authMethod).toBe('api_key'); + expect(result[2].authMethod).toBe('api_key'); + }); + + it('should set authenticated to true and handle case-insensitive auth methods', () => { + const output = '● anthropic OAuth\n● openai API'; + const result = parseProviders(output); + + expect(result[0].authenticated).toBe(true); + expect(result[0].authMethod).toBe('oauth'); + expect(result[1].authenticated).toBe(true); + expect(result[1].authMethod).toBe('api_key'); + }); + + it('should return undefined authMethod for unknown auth types', () => { + const output = '● anthropic unknown-auth'; + const result = parseProviders(output); + + expect(result[0].authenticated).toBe(true); + expect(result[0].authMethod).toBeUndefined(); + }); + }); + + // ======================================================================= + // ANSI Escape Sequences + // ======================================================================= + + describe('ANSI Escape Sequences', () => { + it('should strip ANSI color codes from output', () => { + const output = '\x1b[32m● anthropic oauth\x1b[0m'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('anthropic'); + }); + + it('should handle complex ANSI sequences and codes in provider names', () => { + const output = + '\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('github-copilot'); + }); + }); + + // ======================================================================= + // Edge Cases + // ======================================================================= + + describe('Edge Cases', () => { + it('should return empty array for empty output or no ● symbols', () => { + expect(parseProviders('')).toEqual([]); + expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]); + expect(parseProviders('No authenticated providers')).toEqual([]); + }); + + it('should skip malformed lines with ● but insufficient content', () => { + const output = '●\n● \n● anthropic\n● openai api'; + const result = parseProviders(output); + + // Only the last line has both provider name and auth method + expect(result).toHaveLength(1); + expect(result[0].id).toBe('openai'); + }); + + it('should use fallback for unknown providers (spaces to hyphens)', () => { + const output = '● unknown provider name oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('unknown-provider-name'); + expect(result[0].name).toBe('unknown provider name'); + }); + + it('should handle extra whitespace and mixed case', () => { + const output = '● AnThRoPiC oauth'; + const result = parseProviders(output); + + expect(result[0].id).toBe('anthropic'); + expect(result[0].name).toBe('AnThRoPiC'); + }); + + it('should handle multiple ● symbols on same line', () => { + const output = '● ● anthropic oauth'; + const result = parseProviders(output); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('anthropic'); + }); + + it('should handle different newline formats and trailing newlines', () => { + const outputUnix = '● anthropic oauth\n● openai api'; + const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n'; + + const resultUnix = parseProviders(outputUnix); + const resultWindows = parseProviders(outputWindows); + + expect(resultUnix).toHaveLength(2); + expect(resultWindows).toHaveLength(2); + }); + + it('should handle provider names with numbers and special characters', () => { + const output = '● gpt-4o api'; + const result = parseProviders(output); + + expect(result[0].id).toBe('gpt-4o'); + expect(result[0].name).toBe('gpt-4o'); + }); + }); + + // ======================================================================= + // Real-world CLI Output + // ======================================================================= + + describe('Real-world CLI Output', () => { + it('should parse CLI output with box drawing characters and decorations', () => { + const output = `┌─────────────────────────────────────────────────┐ +│ Authenticated Providers │ +├─────────────────────────────────────────────────┤ +● anthropic oauth +● openai api +└─────────────────────────────────────────────────┘`; + + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('anthropic'); + expect(result[1].id).toBe('openai'); + }); + + it('should parse output with ANSI colors and box characters', () => { + const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m +\x1b[1m│ Authenticated Providers │\x1b[0m +\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m +\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m +\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m +\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`; + + const result = parseProviders(output); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('anthropic'); + expect(result[1].id).toBe('google'); + }); + + it('should handle "no authenticated providers" message', () => { + const output = `┌─────────────────────────────────────────────────┐ +│ No authenticated providers found │ +└─────────────────────────────────────────────────┘`; + + const result = parseProviders(output); + expect(result).toEqual([]); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/providers/provider-factory.test.ts b/jules_branch/apps/server/tests/unit/providers/provider-factory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f92c7256bc2cf63d99031992414779cd06d55b83 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/providers/provider-factory.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ProviderFactory } from '@/providers/provider-factory.js'; +import { ClaudeProvider } from '@/providers/claude-provider.js'; +import { CursorProvider } from '@/providers/cursor-provider.js'; +import { CodexProvider } from '@/providers/codex-provider.js'; +import { OpencodeProvider } from '@/providers/opencode-provider.js'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; +import { CopilotProvider } from '@/providers/copilot-provider.js'; + +describe('provider-factory.ts', () => { + let consoleSpy: any; + let detectClaudeSpy: any; + let detectCursorSpy: any; + let detectCodexSpy: any; + let detectOpencodeSpy: any; + let detectGeminiSpy: any; + let detectCopilotSpy: any; + + beforeEach(() => { + consoleSpy = { + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + }; + + // Avoid hitting real CLI / filesystem checks during unit tests + detectClaudeSpy = vi + .spyOn(ClaudeProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + detectCursorSpy = vi + .spyOn(CursorProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + detectCodexSpy = vi + .spyOn(CodexProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + detectOpencodeSpy = vi + .spyOn(OpencodeProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + detectGeminiSpy = vi + .spyOn(GeminiProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + detectCopilotSpy = vi + .spyOn(CopilotProvider.prototype, 'detectInstallation') + .mockResolvedValue({ installed: true }); + }); + + afterEach(() => { + consoleSpy.warn.mockRestore(); + detectClaudeSpy.mockRestore(); + detectCursorSpy.mockRestore(); + detectCodexSpy.mockRestore(); + detectOpencodeSpy.mockRestore(); + detectGeminiSpy.mockRestore(); + detectCopilotSpy.mockRestore(); + }); + + describe('getProviderForModel', () => { + describe('Claude models (claude-* prefix)', () => { + it('should return ClaudeProvider for claude-opus-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-opus-4-6'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should return ClaudeProvider for claude-sonnet-4-6', () => { + const provider = ProviderFactory.getProviderForModel('claude-sonnet-4-6'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should return ClaudeProvider for claude-haiku-4-5', () => { + const provider = ProviderFactory.getProviderForModel('claude-haiku-4-5'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should be case-insensitive for claude models', () => { + const provider = ProviderFactory.getProviderForModel('CLAUDE-OPUS-4-6'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + }); + + describe('Claude aliases', () => { + it("should return ClaudeProvider for 'haiku'", () => { + const provider = ProviderFactory.getProviderForModel('haiku'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it("should return ClaudeProvider for 'sonnet'", () => { + const provider = ProviderFactory.getProviderForModel('sonnet'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it("should return ClaudeProvider for 'opus'", () => { + const provider = ProviderFactory.getProviderForModel('opus'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should be case-insensitive for aliases', () => { + const provider1 = ProviderFactory.getProviderForModel('HAIKU'); + const provider2 = ProviderFactory.getProviderForModel('Sonnet'); + const provider3 = ProviderFactory.getProviderForModel('Opus'); + + expect(provider1).toBeInstanceOf(ClaudeProvider); + expect(provider2).toBeInstanceOf(ClaudeProvider); + expect(provider3).toBeInstanceOf(ClaudeProvider); + }); + }); + + describe('Cursor models (cursor-* prefix)', () => { + it('should return CursorProvider for cursor-auto', () => { + const provider = ProviderFactory.getProviderForModel('cursor-auto'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for cursor-sonnet-4.5', () => { + const provider = ProviderFactory.getProviderForModel('cursor-sonnet-4.5'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for cursor-gpt-5.2', () => { + const provider = ProviderFactory.getProviderForModel('cursor-gpt-5.2'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should be case-insensitive for cursor models', () => { + const provider = ProviderFactory.getProviderForModel('CURSOR-AUTO'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for known cursor model ID without prefix', () => { + const provider = ProviderFactory.getProviderForModel('auto'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + }); + + describe('Unknown models', () => { + it('should default to ClaudeProvider for unknown model', () => { + const provider = ProviderFactory.getProviderForModel('unknown-model-123'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should handle empty string by defaulting to ClaudeProvider', () => { + const provider = ProviderFactory.getProviderForModel(''); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it('should default to ClaudeProvider for completely unknown prefixes', () => { + const provider = ProviderFactory.getProviderForModel('random-xyz-model'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + }); + + describe('Cursor models via model ID lookup', () => { + it('should return CodexProvider for gpt-5.2 (Codex model, not Cursor)', () => { + // gpt-5.2 is in both CURSOR_MODEL_MAP and CODEX_MODEL_CONFIG_MAP + // It should route to Codex since Codex models take priority + const provider = ProviderFactory.getProviderForModel('gpt-5.2'); + expect(provider).toBeInstanceOf(CodexProvider); + }); + + it('should return CursorProvider for grok (valid Cursor model)', () => { + const provider = ProviderFactory.getProviderForModel('grok'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should return CursorProvider for gemini-3-pro (valid Cursor model)', () => { + const provider = ProviderFactory.getProviderForModel('gemini-3-pro'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + }); + }); + + describe('getAllProviders', () => { + it('should return array of all providers', () => { + const providers = ProviderFactory.getAllProviders(); + expect(Array.isArray(providers)).toBe(true); + }); + + it('should include ClaudeProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasClaudeProvider = providers.some((p) => p instanceof ClaudeProvider); + expect(hasClaudeProvider).toBe(true); + }); + + it('should return exactly 6 providers', () => { + const providers = ProviderFactory.getAllProviders(); + expect(providers).toHaveLength(6); + }); + + it('should include CopilotProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider); + expect(hasCopilotProvider).toBe(true); + }); + + it('should include GeminiProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider); + expect(hasGeminiProvider).toBe(true); + }); + + it('should include CursorProvider', () => { + const providers = ProviderFactory.getAllProviders(); + const hasCursorProvider = providers.some((p) => p instanceof CursorProvider); + expect(hasCursorProvider).toBe(true); + }); + + it('should create new instances each time', () => { + const providers1 = ProviderFactory.getAllProviders(); + const providers2 = ProviderFactory.getAllProviders(); + + expect(providers1[0]).not.toBe(providers2[0]); + }); + }); + + describe('checkAllProviders', () => { + it('should return installation status for all providers', async () => { + const statuses = await ProviderFactory.checkAllProviders(); + + expect(statuses).toHaveProperty('claude'); + }); + + it('should call detectInstallation on each provider', async () => { + const statuses = await ProviderFactory.checkAllProviders(); + + expect(statuses.claude).toHaveProperty('installed'); + }); + + it('should return correct provider names as keys', async () => { + const statuses = await ProviderFactory.checkAllProviders(); + const keys = Object.keys(statuses); + + expect(keys).toContain('claude'); + expect(keys).toContain('cursor'); + expect(keys).toContain('codex'); + expect(keys).toContain('opencode'); + expect(keys).toContain('gemini'); + expect(keys).toContain('copilot'); + expect(keys).toHaveLength(6); + }); + + it('should include cursor status', async () => { + const statuses = await ProviderFactory.checkAllProviders(); + + expect(statuses.cursor).toHaveProperty('installed'); + }); + }); + + describe('getProviderByName', () => { + it("should return ClaudeProvider for 'claude'", () => { + const provider = ProviderFactory.getProviderByName('claude'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it("should return ClaudeProvider for 'anthropic'", () => { + const provider = ProviderFactory.getProviderByName('anthropic'); + expect(provider).toBeInstanceOf(ClaudeProvider); + }); + + it("should return CursorProvider for 'cursor'", () => { + const provider = ProviderFactory.getProviderByName('cursor'); + expect(provider).toBeInstanceOf(CursorProvider); + }); + + it('should be case-insensitive', () => { + const provider1 = ProviderFactory.getProviderByName('CLAUDE'); + const provider2 = ProviderFactory.getProviderByName('ANTHROPIC'); + const provider3 = ProviderFactory.getProviderByName('CURSOR'); + + expect(provider1).toBeInstanceOf(ClaudeProvider); + expect(provider2).toBeInstanceOf(ClaudeProvider); + expect(provider3).toBeInstanceOf(CursorProvider); + }); + + it('should return null for unknown provider', () => { + const provider = ProviderFactory.getProviderByName('unknown'); + expect(provider).toBeNull(); + }); + + it('should return null for empty string', () => { + const provider = ProviderFactory.getProviderByName(''); + expect(provider).toBeNull(); + }); + + it('should create new instance each time', () => { + const provider1 = ProviderFactory.getProviderByName('claude'); + const provider2 = ProviderFactory.getProviderByName('claude'); + + expect(provider1).not.toBe(provider2); + expect(provider1).toBeInstanceOf(ClaudeProvider); + expect(provider2).toBeInstanceOf(ClaudeProvider); + }); + }); + + describe('getAllAvailableModels', () => { + it('should return array of models', () => { + const models = ProviderFactory.getAllAvailableModels(); + expect(Array.isArray(models)).toBe(true); + }); + + it('should include models from all providers', () => { + const models = ProviderFactory.getAllAvailableModels(); + expect(models.length).toBeGreaterThan(0); + }); + + it('should return models with required fields', () => { + const models = ProviderFactory.getAllAvailableModels(); + + models.forEach((model) => { + expect(model).toHaveProperty('id'); + expect(model).toHaveProperty('name'); + expect(typeof model.id).toBe('string'); + expect(typeof model.name).toBe('string'); + }); + }); + + it('should include Claude models', () => { + const models = ProviderFactory.getAllAvailableModels(); + + // Claude models should include claude-* in their IDs + const hasClaudeModels = models.some((m) => m.id.toLowerCase().includes('claude')); + + expect(hasClaudeModels).toBe(true); + }); + + it('should include Cursor models', () => { + const models = ProviderFactory.getAllAvailableModels(); + + // Cursor models should include cursor provider + const hasCursorModels = models.some((m) => m.provider === 'cursor'); + + expect(hasCursorModels).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/app-spec/common.test.ts b/jules_branch/apps/server/tests/unit/routes/app-spec/common.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a348a2aa484cb8e06121bf666b58e72d2769239a --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/app-spec/common.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + setRunningState, + getErrorMessage, + getSpecRegenerationStatus, +} from '@/routes/app-spec/common.js'; + +const TEST_PROJECT_PATH = '/tmp/automaker-test-project'; + +describe('app-spec/common.ts', () => { + beforeEach(() => { + // Reset state before each test + setRunningState(TEST_PROJECT_PATH, false, null); + }); + + describe('setRunningState', () => { + it('should set isRunning to true when running is true', () => { + setRunningState(TEST_PROJECT_PATH, true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); + }); + + it('should set isRunning to false when running is false', () => { + setRunningState(TEST_PROJECT_PATH, true); + setRunningState(TEST_PROJECT_PATH, false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false); + }); + + it('should set currentAbortController when provided', () => { + const controller = new AbortController(); + setRunningState(TEST_PROJECT_PATH, true, controller); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller); + }); + + it('should set currentAbortController to null when not provided', () => { + const controller = new AbortController(); + setRunningState(TEST_PROJECT_PATH, true, controller); + setRunningState(TEST_PROJECT_PATH, false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null); + }); + + it('should keep currentAbortController when explicitly passed null while running', () => { + const controller = new AbortController(); + setRunningState(TEST_PROJECT_PATH, true, controller); + setRunningState(TEST_PROJECT_PATH, true, null); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller); + }); + + it('should update state multiple times correctly', () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + setRunningState(TEST_PROJECT_PATH, true, controller1); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller1); + + setRunningState(TEST_PROJECT_PATH, true, controller2); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(true); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(controller2); + + setRunningState(TEST_PROJECT_PATH, false, null); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).isRunning).toBe(false); + expect(getSpecRegenerationStatus(TEST_PROJECT_PATH).currentAbortController).toBe(null); + }); + }); + + describe('getErrorMessage', () => { + it('should return message from Error instance', () => { + const error = new Error('Test error message'); + expect(getErrorMessage(error)).toBe('Test error message'); + }); + + it("should return 'Unknown error' for non-Error objects", () => { + expect(getErrorMessage('string error')).toBe('Unknown error'); + expect(getErrorMessage(123)).toBe('Unknown error'); + expect(getErrorMessage(null)).toBe('Unknown error'); + expect(getErrorMessage(undefined)).toBe('Unknown error'); + expect(getErrorMessage({})).toBe('Unknown error'); + expect(getErrorMessage([])).toBe('Unknown error'); + }); + + it('should return message from Error with empty message', () => { + const error = new Error(''); + expect(getErrorMessage(error)).toBe(''); + }); + + it('should handle Error objects with custom properties', () => { + const error = new Error('Base message'); + (error as any).customProp = 'custom value'; + expect(getErrorMessage(error)).toBe('Base message'); + }); + + it('should handle Error objects created with different constructors', () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = 'CustomError'; + } + } + + const customError = new CustomError('Custom error message'); + expect(getErrorMessage(customError)).toBe('Custom error message'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts b/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f1080ac7316d8d0ec74970e99e391fd7069ef1b --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for default fields applied to features created by parseAndCreateFeatures + * + * Verifies that auto-created features include planningMode: 'skip', + * requirePlanApproval: false, and dependencies: []. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories +const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } = + vi.hoisted(() => ({ + mockMkdir: vi.fn().mockResolvedValue(undefined), + mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined), + mockExtractJsonWithArray: vi.fn(), + mockCreateNotification: vi.fn().mockResolvedValue(undefined), + })); + +vi.mock('@/lib/secure-fs.js', () => ({ + mkdir: mockMkdir, +})); + +vi.mock('@automaker/utils', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + atomicWriteJson: mockAtomicWriteJson, + DEFAULT_BACKUP_COUNT: 3, +})); + +vi.mock('@automaker/platform', () => ({ + getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')), +})); + +vi.mock('@/lib/json-extractor.js', () => ({ + extractJsonWithArray: mockExtractJsonWithArray, +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: mockCreateNotification, + })), +})); + +// Import after mocks are set up +import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js'; + +describe('parseAndCreateFeatures - default fields', () => { + const mockEvents = { + emit: vi.fn(), + } as any; + + const projectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should set planningMode to "skip" on created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + priority: 1, + complexity: 'simple', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1); + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.planningMode).toBe('skip'); + }); + + it('should set requirePlanApproval to false on created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.requirePlanApproval).toBe(false); + }); + + it('should set dependencies to empty array when not provided', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.dependencies).toEqual([]); + }); + + it('should preserve dependencies when provided by the parser', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + dependencies: ['feature-0'], + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.dependencies).toEqual(['feature-0']); + }); + + it('should apply all default fields consistently across multiple features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Feature 1', + description: 'First feature', + }, + { + id: 'feature-2', + title: 'Feature 2', + description: 'Second feature', + dependencies: ['feature-1'], + }, + { + id: 'feature-3', + title: 'Feature 3', + description: 'Third feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3); + + for (let i = 0; i < 3; i++) { + const writtenData = mockAtomicWriteJson.mock.calls[i][1]; + expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip'); + expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false); + expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true); + } + + // Feature 2 should have its explicit dependency preserved + expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']); + // Features 1 and 3 should have empty arrays + expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]); + expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]); + }); + + it('should set status to "backlog" on all created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.status).toBe('backlog'); + }); + + it('should include createdAt and updatedAt timestamps', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.createdAt).toBeDefined(); + expect(writtenData.updatedAt).toBeDefined(); + // Should be valid ISO date strings + expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt); + expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt); + }); + + it('should use default values for optional fields not provided', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-minimal', + title: 'Minimal Feature', + description: 'Only required fields', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.category).toBe('Uncategorized'); + expect(writtenData.priority).toBe(2); + expect(writtenData.complexity).toBe('moderate'); + expect(writtenData.dependencies).toEqual([]); + expect(writtenData.planningMode).toBe('skip'); + expect(writtenData.requirePlanApproval).toBe(false); + }); + + it('should emit success event after creating features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Feature 1', + description: 'First', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'spec-regeneration:event', + expect.objectContaining({ + type: 'spec_regeneration_complete', + projectPath, + }) + ); + }); + + it('should emit error event when no valid JSON is found', async () => { + mockExtractJsonWithArray.mockReturnValue(null); + + await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'spec-regeneration:event', + expect.objectContaining({ + type: 'spec_regeneration_error', + projectPath, + }) + ); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts b/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bb5c1200f41ae01eb95d4d5358b5927d7da30c8 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/app-spec/parse-and-create-features.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest'; + +describe('app-spec/parse-and-create-features.ts - JSON extraction', () => { + // Test the JSON extraction regex pattern used in parseAndCreateFeatures + const jsonExtractionPattern = /\{[\s\S]*"features"[\s\S]*\}/; + + describe('JSON extraction regex', () => { + it('should extract JSON with features array', () => { + const content = `Here is the response: +{ + "features": [ + { + "id": "feature-1", + "title": "Test Feature", + "description": "A test feature", + "priority": 1, + "complexity": "simple", + "dependencies": [] + } + ] +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + expect(match![0]).toContain('"id": "feature-1"'); + }); + + it('should extract JSON with multiple features', () => { + const content = `Some text before +{ + "features": [ + { + "id": "feature-1", + "title": "Feature 1" + }, + { + "id": "feature-2", + "title": "Feature 2" + } + ] +} +Some text after`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + expect(match![0]).toContain('"feature-1"'); + expect(match![0]).toContain('"feature-2"'); + }); + + it('should extract JSON with nested objects and arrays', () => { + const content = `Response: +{ + "features": [ + { + "id": "feature-1", + "dependencies": ["dep-1", "dep-2"], + "metadata": { + "tags": ["tag1", "tag2"] + } + } + ] +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"dependencies"'); + expect(match![0]).toContain('"dep-1"'); + }); + + it('should handle JSON with whitespace and newlines', () => { + const content = `Text before +{ + "features": [ + { + "id": "feature-1", + "title": "Feature", + "description": "A feature\nwith newlines" + } + ] +} +Text after`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + }); + + it('should extract JSON when features array is empty', () => { + const content = `Response: +{ + "features": [] +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + expect(match![0]).toContain('[]'); + }); + + it('should not match content without features key', () => { + const content = `{ + "otherKey": "value" +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).toBeNull(); + }); + + it('should not match content without JSON structure', () => { + const content = 'Just plain text with features mentioned'; + const match = content.match(jsonExtractionPattern); + expect(match).toBeNull(); + }); + + it('should extract JSON when features key appears multiple times', () => { + const content = `Before: +{ + "features": [ + { + "id": "feature-1", + "title": "Feature" + } + ] +} +After: The word "features" appears again`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + // Should match from first { to last } + expect(match![0]).toContain('"features"'); + }); + + it('should handle JSON with escaped quotes', () => { + const content = `{ + "features": [ + { + "id": "feature-1", + "description": "A feature with \\"quotes\\"" + } + ] +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + }); + + it('should extract JSON with complex nested structure', () => { + const content = `Response: +{ + "features": [ + { + "id": "feature-1", + "dependencies": [ + { + "id": "dep-1", + "type": "required" + } + ], + "metadata": { + "tags": ["tag1"], + "notes": "Some notes" + } + } + ], + "metadata": { + "version": "1.0" + } +}`; + + const match = content.match(jsonExtractionPattern); + expect(match).not.toBeNull(); + expect(match![0]).toContain('"features"'); + expect(match![0]).toContain('"metadata"'); + }); + }); + + describe('JSON parsing validation', () => { + it('should parse valid feature JSON structure', () => { + const validJson = `{ + "features": [ + { + "id": "feature-1", + "title": "Test Feature", + "description": "A test feature", + "priority": 1, + "complexity": "simple", + "dependencies": [] + } + ] +}`; + + const parsed = JSON.parse(validJson); + expect(parsed.features).toBeDefined(); + expect(Array.isArray(parsed.features)).toBe(true); + expect(parsed.features.length).toBe(1); + expect(parsed.features[0].id).toBe('feature-1'); + expect(parsed.features[0].title).toBe('Test Feature'); + }); + + it('should handle features with optional fields', () => { + const jsonWithOptionalFields = `{ + "features": [ + { + "id": "feature-1", + "title": "Feature", + "priority": 2, + "complexity": "moderate" + } + ] +}`; + + const parsed = JSON.parse(jsonWithOptionalFields); + expect(parsed.features[0].id).toBe('feature-1'); + expect(parsed.features[0].priority).toBe(2); + // description and dependencies are optional + expect(parsed.features[0].description).toBeUndefined(); + expect(parsed.features[0].dependencies).toBeUndefined(); + }); + + it('should handle features with dependencies', () => { + const jsonWithDeps = `{ + "features": [ + { + "id": "feature-1", + "title": "Feature 1", + "dependencies": [] + }, + { + "id": "feature-2", + "title": "Feature 2", + "dependencies": ["feature-1"] + } + ] +}`; + + const parsed = JSON.parse(jsonWithDeps); + expect(parsed.features[0].dependencies).toEqual([]); + expect(parsed.features[1].dependencies).toEqual(['feature-1']); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/backlog-plan/apply.test.ts b/jules_branch/apps/server/tests/unit/routes/backlog-plan/apply.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..44b5f2709cb91fdb8ffb41f0ea134376f3fbe597 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/backlog-plan/apply.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockDelete: vi.fn(), + mockClearBacklogPlan: vi.fn(), +})); + +vi.mock('@/services/feature-loader.js', () => ({ + FeatureLoader: class { + getAll = mockGetAll; + create = mockCreate; + update = mockUpdate; + delete = mockDelete; + }, +})); + +vi.mock('@/routes/backlog-plan/common.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + clearBacklogPlan: mockClearBacklogPlan, + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + logError: vi.fn(), +})); + +import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js'; + +function createMockRes() { + const res: { + status: ReturnType; + json: ReturnType; + } = { + status: vi.fn(), + json: vi.fn(), + }; + res.status.mockReturnValue(res); + return res; +} + +describe('createApplyHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAll.mockResolvedValue([]); + mockCreate.mockResolvedValue({ id: 'feature-created' }); + mockUpdate.mockResolvedValue({}); + mockDelete.mockResolvedValue(true); + mockClearBacklogPlan.mockResolvedValue(undefined); + }); + + it('applies default feature model and planning settings when backlog plan additions omit them', async () => { + const settingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' }, + defaultPlanningMode: 'spec', + defaultRequirePlanApproval: true, + }), + getProjectSettings: vi.fn().mockResolvedValue({}), + } as any; + + const req = { + body: { + projectPath: '/tmp/project', + plan: { + changes: [ + { + type: 'add', + feature: { + id: 'feature-from-plan', + title: 'Created from plan', + description: 'desc', + }, + }, + ], + }, + }, + } as any; + const res = createMockRes(); + + await createApplyHandler(settingsService)(req, res as any); + + expect(mockCreate).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + model: 'codex-gpt-5.2-codex', + reasoningEffort: 'high', + planningMode: 'spec', + requirePlanApproval: true, + }) + ); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + }) + ); + }); + + it('uses project default feature model override and enforces no approval for skip mode', async () => { + const settingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { model: 'claude-opus' }, + defaultPlanningMode: 'skip', + defaultRequirePlanApproval: true, + }), + getProjectSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { + model: 'GLM-4.7', + providerId: 'provider-glm', + thinkingLevel: 'adaptive', + }, + }), + } as any; + + const req = { + body: { + projectPath: '/tmp/project', + plan: { + changes: [ + { + type: 'add', + feature: { + id: 'feature-from-plan', + title: 'Created from plan', + }, + }, + ], + }, + }, + } as any; + const res = createMockRes(); + + await createApplyHandler(settingsService)(req, res as any); + + expect(mockCreate).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + model: 'GLM-4.7', + providerId: 'provider-glm', + thinkingLevel: 'adaptive', + planningMode: 'skip', + requirePlanApproval: false, + }) + ); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts b/jules_branch/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..92556a35d3ce47bd80522b06ff7ef4633ba6e6c3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BacklogPlanResult, ProviderMessage } from '@automaker/types'; + +const { + mockGetAll, + mockExecuteQuery, + mockSaveBacklogPlan, + mockSetRunningState, + mockSetRunningDetails, + mockGetPromptCustomization, + mockGetAutoLoadClaudeMdSetting, + mockGetUseClaudeCodeSystemPromptSetting, +} = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockExecuteQuery: vi.fn(), + mockSaveBacklogPlan: vi.fn(), + mockSetRunningState: vi.fn(), + mockSetRunningDetails: vi.fn(), + mockGetPromptCustomization: vi.fn(), + mockGetAutoLoadClaudeMdSetting: vi.fn(), + mockGetUseClaudeCodeSystemPromptSetting: vi.fn(), +})); + +vi.mock('@/services/feature-loader.js', () => ({ + FeatureLoader: class { + getAll = mockGetAll; + }, +})); + +vi.mock('@/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderForModel: vi.fn(() => ({ + executeQuery: mockExecuteQuery, + })), + }, +})); + +vi.mock('@/routes/backlog-plan/common.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + setRunningState: mockSetRunningState, + setRunningDetails: mockSetRunningDetails, + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + saveBacklogPlan: mockSaveBacklogPlan, +})); + +vi.mock('@/lib/settings-helpers.js', () => ({ + getPromptCustomization: mockGetPromptCustomization, + getAutoLoadClaudeMdSetting: mockGetAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting: mockGetUseClaudeCodeSystemPromptSetting, + getPhaseModelWithOverrides: vi.fn(), +})); + +import { generateBacklogPlan } from '@/routes/backlog-plan/generate-plan.js'; + +function createMockEvents() { + return { + emit: vi.fn(), + }; +} + +describe('generateBacklogPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockGetAll.mockResolvedValue([]); + mockGetPromptCustomization.mockResolvedValue({ + backlogPlan: { + systemPrompt: 'System instructions', + userPromptTemplate: + 'Current features:\n{{currentFeatures}}\n\nUser request:\n{{userRequest}}', + }, + }); + mockGetAutoLoadClaudeMdSetting.mockResolvedValue(false); + mockGetUseClaudeCodeSystemPromptSetting.mockResolvedValue(true); + }); + + it('salvages valid streamed JSON when Claude process exits with code 1', async () => { + const partialResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add signup form', + description: 'Create signup UI and validation', + category: 'frontend', + }, + reason: 'Required for user onboarding', + }, + ], + summary: 'Adds signup feature to the backlog', + dependencyUpdates: [], + }; + + const responseJson = JSON.stringify(partialResult); + + async function* streamWithExitError(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: responseJson }], + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithExitError()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Please add a signup feature', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(mockExecuteQuery).toHaveBeenCalledTimes(1); + expect(result).toEqual(partialResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + prompt: 'Please add a signup feature', + model: 'claude-opus-4-6', + result: partialResult, + }) + ); + expect(events.emit).toHaveBeenCalledWith('backlog-plan:event', { + type: 'backlog_plan_complete', + result: partialResult, + }); + expect(mockSetRunningState).toHaveBeenCalledWith(false, null); + expect(mockSetRunningDetails).toHaveBeenCalledWith(null); + }); + + it('prefers parseable provider result over longer non-JSON accumulated text on exit', async () => { + const recoveredResult: BacklogPlanResult = { + changes: [ + { + type: 'add', + feature: { + title: 'Add reset password flow', + description: 'Implement reset password request and token validation UI', + category: 'frontend', + }, + reason: 'Supports account recovery', + }, + ], + summary: 'Adds password reset capability', + dependencyUpdates: [], + }; + + const validProviderResult = JSON.stringify(recoveredResult); + const invalidAccumulatedText = `${validProviderResult}\n\nAdditional commentary that breaks raw JSON parsing.`; + + async function* streamWithResultThenExit(): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: invalidAccumulatedText }], + }, + }; + yield { + type: 'result', + subtype: 'success', + duration_ms: 10, + duration_api_ms: 10, + is_error: false, + num_turns: 1, + result: validProviderResult, + session_id: 'session-1', + total_cost_usd: 0, + usage: { + input_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 10, + server_tool_use: { + web_search_requests: 0, + }, + service_tier: 'standard', + }, + }; + throw new Error('Claude Code process exited with code 1'); + } + + mockExecuteQuery.mockReturnValueOnce(streamWithResultThenExit()); + + const events = createMockEvents(); + const abortController = new AbortController(); + + const result = await generateBacklogPlan( + '/tmp/project', + 'Add password reset support', + events as any, + abortController, + undefined, + 'claude-opus' + ); + + expect(result).toEqual(recoveredResult); + expect(mockSaveBacklogPlan).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + result: recoveredResult, + }) + ); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/pipeline.test.ts b/jules_branch/apps/server/tests/unit/routes/pipeline.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..299fdca82f0b8805a10f51514e1501aa97ce8dde --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/pipeline.test.ts @@ -0,0 +1,499 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { createGetConfigHandler } from '@/routes/pipeline/routes/get-config.js'; +import { createSaveConfigHandler } from '@/routes/pipeline/routes/save-config.js'; +import { createAddStepHandler } from '@/routes/pipeline/routes/add-step.js'; +import { createUpdateStepHandler } from '@/routes/pipeline/routes/update-step.js'; +import { createDeleteStepHandler } from '@/routes/pipeline/routes/delete-step.js'; +import { createReorderStepsHandler } from '@/routes/pipeline/routes/reorder-steps.js'; +import type { PipelineService } from '@/services/pipeline-service.js'; +import type { PipelineConfig, PipelineStep } from '@automaker/types'; +import { createMockExpressContext } from '../../utils/mocks.js'; + +describe('pipeline routes', () => { + let mockPipelineService: PipelineService; + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPipelineService = { + getPipelineConfig: vi.fn(), + savePipelineConfig: vi.fn(), + addStep: vi.fn(), + updateStep: vi.fn(), + deleteStep: vi.fn(), + reorderSteps: vi.fn(), + } as any; + + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + describe('get-config', () => { + it('should return pipeline config successfully', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(mockPipelineService.getPipelineConfig).mockResolvedValue(config); + req.body = { projectPath: '/test/project' }; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.getPipelineConfig).toHaveBeenCalledWith('/test/project'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + config, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = {}; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + expect(mockPipelineService.getPipelineConfig).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Read failed'); + vi.mocked(mockPipelineService.getPipelineConfig).mockRejectedValue(error); + req.body = { projectPath: '/test/project' }; + + const handler = createGetConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Read failed', + }); + }); + }); + + describe('save-config', () => { + it('should save pipeline config successfully', async () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(mockPipelineService.savePipelineConfig).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', config }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.savePipelineConfig).toHaveBeenCalledWith('/test/project', config); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { config: { version: 1, steps: [] } }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if config is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'config is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Save failed'); + vi.mocked(mockPipelineService.savePipelineConfig).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + config: { version: 1, steps: [] }, + }; + + const handler = createSaveConfigHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Save failed', + }); + }); + }); + + describe('add-step', () => { + it('should add step successfully', async () => { + const stepData = { + name: 'New Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + }; + + const newStep: PipelineStep = { + ...stepData, + id: 'step1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(mockPipelineService.addStep).mockResolvedValue(newStep); + req.body = { projectPath: '/test/project', step: stepData }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.addStep).toHaveBeenCalledWith('/test/project', stepData); + expect(res.json).toHaveBeenCalledWith({ + success: true, + step: newStep, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' } }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if step is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step is required', + }); + }); + + it('should return 400 if step.name is missing', async () => { + req.body = { + projectPath: '/test/project', + step: { order: 0, instructions: 'Do', colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step.name is required', + }); + }); + + it('should return 400 if step.instructions is missing', async () => { + req.body = { + projectPath: '/test/project', + step: { name: 'Step', order: 0, colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'step.instructions is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Add failed'); + vi.mocked(mockPipelineService.addStep).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + step: { name: 'Step', order: 0, instructions: 'Do', colorClass: 'blue' }, + }; + + const handler = createAddStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Add failed', + }); + }); + }); + + describe('update-step', () => { + it('should update step successfully', async () => { + const updates = { + name: 'Updated Name', + instructions: 'Updated instructions', + }; + + const updatedStep: PipelineStep = { + id: 'step1', + name: 'Updated Name', + order: 0, + instructions: 'Updated instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(mockPipelineService.updateStep).mockResolvedValue(updatedStep); + req.body = { projectPath: '/test/project', stepId: 'step1', updates }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.updateStep).toHaveBeenCalledWith( + '/test/project', + 'step1', + updates + ); + expect(res.json).toHaveBeenCalledWith({ + success: true, + step: updatedStep, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepId: 'step1', updates: { name: 'New' } }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepId is missing', async () => { + req.body = { projectPath: '/test/project', updates: { name: 'New' } }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepId is required', + }); + }); + + it('should return 400 if updates is missing', async () => { + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'updates is required', + }); + }); + + it('should return 400 if updates is empty object', async () => { + req.body = { projectPath: '/test/project', stepId: 'step1', updates: {} }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'updates is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Update failed'); + vi.mocked(mockPipelineService.updateStep).mockRejectedValue(error); + req.body = { + projectPath: '/test/project', + stepId: 'step1', + updates: { name: 'New' }, + }; + + const handler = createUpdateStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Update failed', + }); + }); + }); + + describe('delete-step', () => { + it('should delete step successfully', async () => { + vi.mocked(mockPipelineService.deleteStep).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.deleteStep).toHaveBeenCalledWith('/test/project', 'step1'); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepId is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepId is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Delete failed'); + vi.mocked(mockPipelineService.deleteStep).mockRejectedValue(error); + req.body = { projectPath: '/test/project', stepId: 'step1' }; + + const handler = createDeleteStepHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Delete failed', + }); + }); + }); + + describe('reorder-steps', () => { + it('should reorder steps successfully', async () => { + vi.mocked(mockPipelineService.reorderSteps).mockResolvedValue(undefined); + req.body = { projectPath: '/test/project', stepIds: ['step2', 'step1', 'step3'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(mockPipelineService.reorderSteps).toHaveBeenCalledWith('/test/project', [ + 'step2', + 'step1', + 'step3', + ]); + expect(res.json).toHaveBeenCalledWith({ + success: true, + }); + }); + + it('should return 400 if projectPath is missing', async () => { + req.body = { stepIds: ['step1', 'step2'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'projectPath is required', + }); + }); + + it('should return 400 if stepIds is missing', async () => { + req.body = { projectPath: '/test/project' }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepIds array is required', + }); + }); + + it('should return 400 if stepIds is not an array', async () => { + req.body = { projectPath: '/test/project', stepIds: 'not-an-array' }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'stepIds array is required', + }); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Reorder failed'); + vi.mocked(mockPipelineService.reorderSteps).mockRejectedValue(error); + req.body = { projectPath: '/test/project', stepIds: ['step1', 'step2'] }; + + const handler = createReorderStepsHandler(mockPipelineService); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Reorder failed', + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/running-agents.test.ts b/jules_branch/apps/server/tests/unit/routes/running-agents.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd2e2ab60bfa6b98b50f3fabea7d768f390d3ba --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/running-agents.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { createIndexHandler } from '@/routes/running-agents/routes/index.js'; +import type { AutoModeService } from '@/services/auto-mode-service.js'; +import { createMockExpressContext } from '../../utils/mocks.js'; + +describe('running-agents routes', () => { + let mockAutoModeService: Partial; + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAutoModeService = { + getRunningAgents: vi.fn(), + }; + + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + describe('GET / (index handler)', () => { + it('should return empty array when no agents are running', async () => { + // Arrange + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue([]); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(mockAutoModeService.getRunningAgents).toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + success: true, + runningAgents: [], + totalCount: 0, + }); + }); + + it('should return running agents with all properties', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'feature-123', + projectPath: '/home/user/project', + projectName: 'project', + isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', + title: 'Implement login feature', + description: 'Add user authentication with OAuth', + }, + { + featureId: 'feature-456', + projectPath: '/home/user/other-project', + projectName: 'other-project', + isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', + title: 'Fix navigation bug', + description: undefined, + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(res.json).toHaveBeenCalledWith({ + success: true, + runningAgents, + totalCount: 2, + }); + }); + + it('should return agents without title/description (backward compatibility)', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'legacy-feature', + projectPath: '/project', + projectName: 'project', + isAutoMode: true, + model: undefined, + provider: undefined, + title: undefined, + description: undefined, + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(res.json).toHaveBeenCalledWith({ + success: true, + runningAgents, + totalCount: 1, + }); + }); + + it('should handle errors gracefully and return 500', async () => { + // Arrange + const error = new Error('Database connection failed'); + vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue(error); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Database connection failed', + }); + }); + + it('should handle non-Error exceptions', async () => { + // Arrange + vi.mocked(mockAutoModeService.getRunningAgents!).mockRejectedValue('String error'); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: expect.any(String), + }); + }); + + it('should correctly count multiple running agents', async () => { + // Arrange + const runningAgents = Array.from({ length: 10 }, (_, i) => ({ + featureId: `feature-${i}`, + projectPath: `/project-${i}`, + projectName: `project-${i}`, + isAutoMode: i % 2 === 0, + model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5', + provider: 'claude', + title: `Feature ${i}`, + description: `Description ${i}`, + })); + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + expect(res.json).toHaveBeenCalledWith({ + success: true, + runningAgents, + totalCount: 10, + }); + }); + + it('should include agents from different projects', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'feature-a', + projectPath: '/workspace/project-alpha', + projectName: 'project-alpha', + isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', + title: 'Feature A', + description: 'In project alpha', + }, + { + featureId: 'feature-b', + projectPath: '/workspace/project-beta', + projectName: 'project-beta', + isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', + title: 'Feature B', + description: 'In project beta', + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + const response = vi.mocked(res.json).mock.calls[0][0]; + expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha'); + expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta'); + }); + + it('should include model and provider information for running agents', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'feature-claude', + projectPath: '/project', + projectName: 'project', + isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', + title: 'Claude Feature', + description: 'Using Claude model', + }, + { + featureId: 'feature-codex', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', + title: 'Codex Feature', + description: 'Using Codex model', + }, + { + featureId: 'feature-cursor', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'cursor-auto', + provider: 'cursor', + title: 'Cursor Feature', + description: 'Using Cursor model', + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + const response = vi.mocked(res.json).mock.calls[0][0]; + expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514'); + expect(response.runningAgents[0].provider).toBe('claude'); + expect(response.runningAgents[1].model).toBe('codex-gpt-5.1'); + expect(response.runningAgents[1].provider).toBe('codex'); + expect(response.runningAgents[2].model).toBe('cursor-auto'); + expect(response.runningAgents[2].provider).toBe('cursor'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/worktree/add-remote.test.ts b/jules_branch/apps/server/tests/unit/routes/worktree/add-remote.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9eb3e828c1d15626b801e9adfabb2a0daa1b87bf --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/worktree/add-remote.test.ts @@ -0,0 +1,565 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { Request, Response } from 'express'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +// Mock child_process with importOriginal to keep other exports +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +// Mock util.promisify to return the function as-is so we can mock execFile +vi.mock('util', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + promisify: (fn: unknown) => fn, + }; +}); + +// Import handler after mocks are set up +import { createAddRemoteHandler } from '@/routes/worktree/routes/add-remote.js'; +import { execFile } from 'child_process'; + +// Get the mocked execFile +const mockExecFile = execFile as Mock; + +/** + * Helper to create a standard mock implementation for git commands + */ +function createGitMock(options: { + existingRemotes?: string[]; + addRemoteFails?: boolean; + addRemoteError?: string; + fetchFails?: boolean; +}): (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> { + const { + existingRemotes = [], + addRemoteFails = false, + addRemoteError = 'git remote add failed', + fetchFails = false, + } = options; + + return (command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: existingRemotes.join('\n'), stderr: '' }); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + if (addRemoteFails) { + return Promise.reject(new Error(addRemoteError)); + } + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'fetch') { + if (fetchFails) { + return Promise.reject(new Error('fetch failed')); + } + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }; +} + +describe('add-remote route', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + describe('input validation', () => { + it('should return 400 if worktreePath is missing', async () => { + req.body = { remoteName: 'origin', remoteUrl: 'https://github.com/user/repo.git' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'worktreePath required', + }); + }); + + it('should return 400 if remoteName is missing', async () => { + req.body = { worktreePath: '/test/path', remoteUrl: 'https://github.com/user/repo.git' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteName required', + }); + }); + + it('should return 400 if remoteUrl is missing', async () => { + req.body = { worktreePath: '/test/path', remoteName: 'origin' }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteUrl required', + }); + }); + }); + + describe('remote name validation', () => { + it('should return 400 for empty remote name', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteName required', + }); + }); + + it('should return 400 for remote name starting with dash', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '-invalid', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name starting with period', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: '.invalid', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name with invalid characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'invalid name', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should return 400 for remote name exceeding 250 characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'a'.repeat(251), + remoteUrl: 'https://github.com/user/repo.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: + 'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.', + }); + }); + + it('should accept valid remote names with alphanumeric, dashes, underscores, and periods', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'my-remote_name.1', + remoteUrl: 'https://github.com/user/repo.git', + }; + + // Mock git remote to return empty list (no existing remotes) + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should not return 400 for invalid name + expect(res.status).not.toHaveBeenCalledWith(400); + }); + }); + + describe('remote URL validation', () => { + it('should return 400 for empty remote URL', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: '', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'remoteUrl required', + }); + }); + + it('should return 400 for invalid remote URL', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'not-a-valid-url', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + }); + + it('should return 400 for URL exceeding 2048 characters', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/' + 'a'.repeat(2049) + '.git', + }; + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).', + }); + }); + + it('should accept HTTPS URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept HTTP URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'http://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept SSH URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'git@github.com:user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept git:// protocol URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'git://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + + it('should accept ssh:// protocol URLs', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'ssh://git@github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).not.toHaveBeenCalledWith(400); + }); + }); + + describe('remote already exists check', () => { + it('should return 400 with REMOTE_EXISTS code when remote already exists', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin', 'upstream'] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: "Remote 'origin' already exists", + code: 'REMOTE_EXISTS', + }); + }); + + it('should proceed if remote does not exist', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'new-remote', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin'] })); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should call git remote add with array arguments + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['remote', 'add', 'new-remote', 'https://github.com/user/repo.git'], + expect.any(Object) + ); + }); + }); + + describe('successful remote addition', () => { + it('should add remote successfully with successful fetch', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ existingRemotes: ['origin'], fetchFails: false }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + fetched: true, + message: "Successfully added remote 'upstream' and fetched its branches", + }, + }); + }); + + it('should add remote successfully even if fetch fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ existingRemotes: ['origin'], fetchFails: true }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + remoteName: 'upstream', + remoteUrl: 'https://github.com/other/repo.git', + fetched: false, + message: + "Successfully added remote 'upstream' (fetch failed - you may need to fetch manually)", + }, + }); + }); + + it('should pass correct cwd option to git commands', async () => { + req.body = { + worktreePath: '/custom/worktree/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + const execCalls: { command: string; args: string[]; options: unknown }[] = []; + mockExecFile.mockImplementation((command: string, args: string[], options: unknown) => { + execCalls.push({ command, args, options }); + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Check that git remote was called with correct cwd + expect((execCalls[0].options as { cwd: string }).cwd).toBe('/custom/worktree/path'); + // Check that git remote add was called with correct cwd + expect((execCalls[1].options as { cwd: string }).cwd).toBe('/custom/worktree/path'); + }); + }); + + describe('error handling', () => { + it('should return 500 when git remote add fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation( + createGitMock({ + existingRemotes: [], + addRemoteFails: true, + addRemoteError: 'git remote add failed', + }) + ); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'git remote add failed', + }); + }); + + it('should continue adding remote if git remote check fails', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation((command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.reject(new Error('not a git repo')); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'fetch') { + return Promise.resolve({ stdout: '', stderr: '' }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + // Should still try to add remote with array arguments + expect(mockExecFile).toHaveBeenCalledWith( + 'git', + ['remote', 'add', 'origin', 'https://github.com/user/repo.git'], + expect.any(Object) + ); + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: expect.objectContaining({ + remoteName: 'origin', + }), + }); + }); + + it('should handle non-Error exceptions', async () => { + req.body = { + worktreePath: '/test/path', + remoteName: 'origin', + remoteUrl: 'https://github.com/user/repo.git', + }; + + mockExecFile.mockImplementation((command: string, args: string[]) => { + if (command === 'git' && args[0] === 'remote' && args.length === 1) { + return Promise.resolve({ stdout: '', stderr: '' }); + } + if (command === 'git' && args[0] === 'remote' && args[1] === 'add') { + return Promise.reject('String error'); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const handler = createAddRemoteHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: expect.any(String), + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts b/jules_branch/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7163603bd2682e4ac6a11f1ba9d4ba32cba42595 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts @@ -0,0 +1,930 @@ +/** + * Tests for worktree list endpoint handling of detached HEAD state. + * + * When a worktree is in detached HEAD state (e.g., during a rebase), + * `git worktree list --porcelain` outputs "detached" instead of + * "branch refs/heads/...". Previously, these worktrees were silently + * dropped from the response because the parser required both path AND branch. + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +// Mock all external dependencies before importing the module under test +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +vi.mock('@/lib/git.js', () => ({ + execGitCommand: vi.fn(), +})); + +vi.mock('@automaker/git-utils', () => ({ + isGitRepo: vi.fn(async () => true), +})); + +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock('@automaker/types', () => ({ + validatePRState: vi.fn((state: string) => state), +})); + +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + stat: vi.fn(), +})); + +vi.mock('@/lib/worktree-metadata.js', () => ({ + readAllWorktreeMetadata: vi.fn(async () => new Map()), + updateWorktreePRInfo: vi.fn(async () => undefined), +})); + +vi.mock('@/routes/worktree/common.js', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'), + logError: vi.fn(), + normalizePath: vi.fn((p: string) => p), + execEnv: {}, + isGhCliAvailable: vi.fn().mockResolvedValue(false), + }; +}); + +vi.mock('@/routes/github/routes/check-github-remote.js', () => ({ + checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }), +})); + +import { createListHandler } from '@/routes/worktree/routes/list.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import { execGitCommand } from '@/lib/git.js'; +import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js'; +import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js'; + +/** + * Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec). + */ +function setupExecGitCommandMock(options: { + porcelainOutput: string; + projectBranch?: string; + gitDirs?: Record; + worktreeBranches?: Record; +}) { + const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') { + return porcelainOutput; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + if (worktreeBranches[cwd] !== undefined) { + return worktreeBranches[cwd] + '\n'; + } + return projectBranch + '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + if (cwd && gitDirs[cwd]) { + return gitDirs[cwd] + '\n'; + } + throw new Error('not a git directory'); + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { + return 'HEAD\n'; + } + if (args[0] === 'worktree' && args[1] === 'prune') { + return ''; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') { + return ''; + } + return ''; + }); +} + +describe('worktree list - detached HEAD handling', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + const context = createMockExpressContext(); + req = context.req; + res = context.res; + + // Re-establish mock implementations cleared by mockReset/clearAllMocks + vi.mocked(isGitRepo).mockResolvedValue(true); + vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map()); + vi.mocked(isGhCliAvailable).mockResolvedValue(false); + vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false }); + vi.mocked(normalizePath).mockImplementation((p: string) => p); + vi.mocked(getErrorMessage).mockImplementation( + (e: unknown) => (e as Error)?.message || 'Unknown error' + ); + + // Default: all paths exist + vi.mocked(secureFs.access).mockResolvedValue(undefined); + // Default: .worktrees directory doesn't exist (no scan via readdir) + vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT')); + // Default: readFile fails + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + // Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations) + setupExecGitCommandMock({ + porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n', + projectBranch: 'main', + }); + }); + + /** + * Helper: set up execGitCommand mock for the list handler. + * Worktree-specific behavior can be customized via the options parameter. + */ + function setupStandardExec(options: { + porcelainOutput: string; + projectBranch?: string; + /** Map of worktree path -> git-dir path */ + gitDirs?: Record; + /** Map of worktree cwd -> branch for `git branch --show-current` */ + worktreeBranches?: Record; + }) { + setupExecGitCommandMock(options); + } + + /** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */ + function disableWorktreesScan() { + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + // Block only the .worktrees dir access check in scanWorktreesDirectory + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + // All other paths exist + return undefined; + }); + } + + describe('porcelain parser', () => { + it('should include normal worktrees with branch lines', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'), + }); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + success: boolean; + worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>; + }; + + expect(response.success).toBe(true); + expect(response.worktrees).toHaveLength(2); + expect(response.worktrees[0]).toEqual( + expect.objectContaining({ + path: '/project', + branch: 'main', + isMain: true, + hasWorktree: true, + }) + ); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/feature-a', + branch: 'feature-a', + isMain: false, + hasWorktree: true, + }) + ); + }); + + it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + // rebase-merge/head-name returns the branch being rebased + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/my-rebasing-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>; + }; + expect(response.worktrees).toHaveLength(2); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/rebasing-wt', + branch: 'feature/my-rebasing-branch', + isMain: false, + isCurrent: false, + hasWorktree: true, + }) + ); + }); + + it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/apply-wt', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git', + }, + }); + disableWorktreesScan(); + + // rebase-merge doesn't exist, but rebase-apply does + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-apply/head-name')) { + return 'refs/heads/feature/apply-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt'); + expect(detachedWt).toBeDefined(); + expect(detachedWt!.branch).toBe('feature/apply-branch'); + }); + + it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => { + // During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain` + // still outputs `branch refs/heads/...`. This test verifies merge conflicts don't + // trigger the detached HEAD recovery path. + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/merge-wt', + 'branch refs/heads/feature/merge-branch', + '', + ].join('\n'), + }); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt'); + expect(mergeWt).toBeDefined(); + expect(mergeWt!.branch).toBe('feature/merge-branch'); + }); + + it('should fall back to (detached) when all branch recovery methods fail', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/unknown-wt', + 'detached', + '', + ].join('\n'), + worktreeBranches: { + '/project/.worktrees/unknown-wt': '', // empty = no branch + }, + }); + disableWorktreesScan(); + + // All readFile calls fail (no gitDirs so rev-parse --git-dir will throw) + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const detachedWt = response.worktrees.find( + (w) => w.path === '/project/.worktrees/unknown-wt' + ); + expect(detachedWt).toBeDefined(); + expect(detachedWt!.branch).toBe('(detached)'); + }); + + it('should not include detached worktree when directory does not exist on disk', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/deleted-wt', + 'detached', + '', + ].join('\n'), + }); + + // The deleted worktree doesn't exist on disk + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if (pathStr.includes('deleted-wt')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + return undefined; + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + // Only the main worktree should be present + expect(response.worktrees).toHaveLength(1); + expect(response.worktrees[0].path).toBe('/project'); + }); + + it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + ].join('\n'), + // currentBranch for project is 'feature/my-branch' + projectBranch: 'feature/my-branch', + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + // Recovery returns the same branch as currentBranch + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/my-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>; + }; + const detachedWt = response.worktrees.find( + (w) => w.path === '/project/.worktrees/rebasing-wt' + ); + expect(detachedWt).toBeDefined(); + // Detached worktrees should always have isCurrent=false + expect(detachedWt!.isCurrent).toBe(false); + }); + + it('should handle mixed normal and detached worktrees', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/normal-wt', + 'branch refs/heads/feature-normal', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + 'worktree /project/.worktrees/another-normal', + 'branch refs/heads/feature-other', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/rebasing\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string; isMain: boolean }>; + }; + expect(response.worktrees).toHaveLength(4); + expect(response.worktrees[0]).toEqual( + expect.objectContaining({ path: '/project', branch: 'main', isMain: true }) + ); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/normal-wt', + branch: 'feature-normal', + isMain: false, + }) + ); + expect(response.worktrees[2]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/rebasing-wt', + branch: 'feature/rebasing', + isMain: false, + }) + ); + expect(response.worktrees[3]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/another-normal', + branch: 'feature-other', + isMain: false, + }) + ); + }); + + it('should correctly advance isFirst flag past detached worktrees', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/detached-wt', + 'detached', + '', + 'worktree /project/.worktrees/normal-wt', + 'branch refs/heads/feature-x', + '', + ].join('\n'), + }); + disableWorktreesScan(); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; isMain: boolean }>; + }; + expect(response.worktrees).toHaveLength(3); + expect(response.worktrees[0].isMain).toBe(true); // main + expect(response.worktrees[1].isMain).toBe(false); // detached + expect(response.worktrees[2].isMain).toBe(false); // normal + }); + + it('should not add removed detached worktrees to removedWorktrees list', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/gone-wt', + 'detached', + '', + ].join('\n'), + }); + + // The detached worktree doesn't exist on disk + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if (pathStr.includes('gone-wt')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + return undefined; + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string }>; + removedWorktrees?: Array<{ path: string; branch: string }>; + }; + // Should not be in removed list since we don't know the branch + expect(response.removedWorktrees).toBeUndefined(); + }); + + it('should strip refs/heads/ prefix from recovered branch name', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/wt1', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/wt1': '/project/.worktrees/wt1/.git', + }, + }); + disableWorktreesScan(); + + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/my-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1'); + expect(wt).toBeDefined(); + // Should be 'my-branch', not 'refs/heads/my-branch' + expect(wt!.branch).toBe('my-branch'); + }); + }); + + describe('scanWorktreesDirectory with detached HEAD recovery', () => { + it('should recover branch for discovered worktrees with detached HEAD', async () => { + req.body = { projectPath: '/project' }; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return 'worktree /project\nbranch refs/heads/main\n\n'; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return 'HEAD\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + return '/project/.worktrees/orphan-wt/.git\n'; + } + return ''; + }); + + // .worktrees directory exists and has an orphan worktree + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any, + ]); + vi.mocked(secureFs.stat).mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + } as any); + + // readFile returns branch from rebase-merge/head-name + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/orphan-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + + const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt'); + expect(orphanWt).toBeDefined(); + expect(orphanWt!.branch).toBe('feature/orphan-branch'); + }); + + it('should skip discovered worktrees when all branch detection fails', async () => { + req.body = { projectPath: '/project' }; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return 'worktree /project\nbranch refs/heads/main\n\n'; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return 'HEAD\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('not a git dir'); + } + return ''; + }); + + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any, + ]); + vi.mocked(secureFs.stat).mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + } as any); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + + // Only main worktree should be present + expect(response.worktrees).toHaveLength(1); + expect(response.worktrees[0].branch).toBe('main'); + }); + }); + + describe('PR tracking precedence', () => { + it('should keep manually tracked PR from metadata when branch PR differs', async () => { + req.body = { projectPath: '/project', includeDetails: true }; + + vi.mocked(readAllWorktreeMetadata).mockResolvedValue( + new Map([ + [ + 'feature-a', + { + branch: 'feature-a', + createdAt: '2026-01-01T00:00:00.000Z', + pr: { + number: 99, + url: 'https://github.com/org/repo/pull/99', + title: 'Manual override PR', + state: 'OPEN', + createdAt: '2026-01-01T00:00:00.000Z', + }, + }, + ], + ]) + ); + vi.mocked(isGhCliAvailable).mockResolvedValue(true); + vi.mocked(checkGitHubRemote).mockResolvedValue({ + hasGitHubRemote: true, + owner: 'org', + repo: 'repo', + }); + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if ( + pathStr.includes('MERGE_HEAD') || + pathStr.includes('rebase-merge') || + pathStr.includes('rebase-apply') || + pathStr.includes('CHERRY_PICK_HEAD') + ) { + throw new Error('ENOENT'); + } + return undefined; + }); + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('no git dir'); + } + if (args[0] === 'worktree' && args[1] === 'list') { + return [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : 'feature-a\n'; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + return ''; + }); + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + _opts: unknown, + callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const cb = typeof _opts === 'function' ? _opts : callback!; + if (cmd.includes('gh pr list')) { + cb(null, { + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR', + url: 'https://github.com/org/repo/pull/42', + state: 'OPEN', + headRefName: 'feature-a', + createdAt: '2026-01-02T00:00:00.000Z', + }, + ]), + stderr: '', + }); + } else { + cb(null, { stdout: '', stderr: '' }); + } + } + ); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>; + }; + const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a'); + expect(featureWorktree?.pr?.number).toBe(99); + expect(featureWorktree?.pr?.title).toBe('Manual override PR'); + }); + + it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => { + req.body = { projectPath: '/project-2', includeDetails: true }; + + vi.mocked(readAllWorktreeMetadata).mockResolvedValue( + new Map([ + [ + 'feature-a', + { + branch: 'feature-a', + createdAt: '2026-01-01T00:00:00.000Z', + pr: { + number: 42, + url: 'https://github.com/org/repo/pull/42', + title: 'Old title', + state: 'OPEN', + createdAt: '2026-01-01T00:00:00.000Z', + }, + }, + ], + ]) + ); + vi.mocked(isGhCliAvailable).mockResolvedValue(true); + vi.mocked(checkGitHubRemote).mockResolvedValue({ + hasGitHubRemote: true, + owner: 'org', + repo: 'repo', + }); + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if ( + pathStr.includes('MERGE_HEAD') || + pathStr.includes('rebase-merge') || + pathStr.includes('rebase-apply') || + pathStr.includes('CHERRY_PICK_HEAD') + ) { + throw new Error('ENOENT'); + } + return undefined; + }); + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('no git dir'); + } + if (args[0] === 'worktree' && args[1] === 'list') { + return [ + 'worktree /project-2', + 'branch refs/heads/main', + '', + 'worktree /project-2/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project-2' ? 'main\n' : 'feature-a\n'; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + return ''; + }); + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + _opts: unknown, + callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const cb = typeof _opts === 'function' ? _opts : callback!; + if (cmd.includes('gh pr list')) { + cb(null, { + stdout: JSON.stringify([ + { + number: 42, + title: 'New title from GitHub', + url: 'https://github.com/org/repo/pull/42', + state: 'MERGED', + headRefName: 'feature-a', + createdAt: '2026-01-02T00:00:00.000Z', + }, + ]), + stderr: '', + }); + } else { + cb(null, { stdout: '', stderr: '' }); + } + } + ); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>; + }; + const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a'); + expect(featureWorktree?.pr?.number).toBe(42); + expect(featureWorktree?.pr?.title).toBe('New title from GitHub'); + expect(featureWorktree?.pr?.state).toBe('MERGED'); + expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith( + '/project-2', + 'feature-a', + expect.objectContaining({ + number: 42, + title: 'New title from GitHub', + state: 'MERGED', + }) + ); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/jules_branch/apps/server/tests/unit/routes/worktree/switch-branch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c599fd07e1c506cac71d4326ad367d9c8855cc20 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/routes/worktree/switch-branch.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Request, Response } from 'express'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +vi.mock('@/services/worktree-branch-service.js', () => ({ + performSwitchBranch: vi.fn(), +})); + +import { performSwitchBranch } from '@/services/worktree-branch-service.js'; +import { createSwitchBranchHandler } from '@/routes/worktree/routes/switch-branch.js'; + +const mockPerformSwitchBranch = vi.mocked(performSwitchBranch); + +describe('switch-branch route', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + const context = createMockExpressContext(); + req = context.req; + res = context.res; + }); + + it('should return 400 when branchName is missing', async () => { + req.body = { worktreePath: '/repo/path' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'branchName required', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName starts with a dash', async () => { + req.body = { worktreePath: '/repo/path', branchName: '-flag' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName starts with double dash', async () => { + req.body = { worktreePath: '/repo/path', branchName: '--option' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should return 400 when branchName contains invalid characters', async () => { + req.body = { worktreePath: '/repo/path', branchName: 'branch name with spaces' }; + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid branch name', + }); + expect(mockPerformSwitchBranch).not.toHaveBeenCalled(); + }); + + it('should allow switching when only untracked files exist', async () => { + req.body = { + worktreePath: '/repo/path', + branchName: 'feature/test', + }; + + mockPerformSwitchBranch.mockResolvedValue({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test'", + hasConflicts: false, + stashedChanges: false, + }, + }); + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test'", + hasConflicts: false, + stashedChanges: false, + }, + }); + expect(mockPerformSwitchBranch).toHaveBeenCalledWith('/repo/path', 'feature/test', undefined); + }); + + it('should stash changes and switch when tracked files are modified', async () => { + req.body = { + worktreePath: '/repo/path', + branchName: 'feature/test', + }; + + mockPerformSwitchBranch.mockResolvedValue({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test' (local changes stashed and reapplied)", + hasConflicts: false, + stashedChanges: true, + }, + }); + + const handler = createSwitchBranchHandler(); + await handler(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test' (local changes stashed and reapplied)", + hasConflicts: false, + stashedChanges: true, + }, + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/agent-executor-summary.test.ts b/jules_branch/apps/server/tests/unit/services/agent-executor-summary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bb3cc06d731c4c1b94d0fbeb710d10675f33463 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/agent-executor-summary.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { AgentExecutor } 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 { BaseProvider } from '../../../src/providers/base-provider.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { buildPromptWithImages } from '@automaker/utils'; + +vi.mock('../../../src/lib/secure-fs.js', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + appendFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPromptWithImages: vi.fn(), + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + }; +}); + +describe('AgentExecutor Summary Extraction', () => { + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockPlanApprovalService: PlanApprovalService; + + beforeEach(() => { + vi.clearAllMocks(); + + 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; + + (getFeatureDir as Mock).mockReturnValue('/mock/feature/dir'); + (buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' }); + }); + + it('should extract summary from new session content only', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const previousContent = `Some previous work. +Old summary`; + const newWork = `New implementation work. +New summary`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + previousContent, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify it called saveFeatureSummary with the NEW summary + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'New summary' + ); + + // Ensure it didn't call it with Old summary + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Old summary' + ); + }); + + it('should not save summary if no summary in NEW session content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const previousContent = `Some previous work. +Old summary`; + const newWork = `New implementation work without a summary tag.`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + previousContent, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify it NEVER called saveFeatureSummary because there was no NEW summary + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should extract task summary and update task status during streaming', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Working... ' }], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + // We trigger executeTasksLoop by providing persistedTasks + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + existingApprovedPlanContent: 'Some plan', + persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }], + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Verify it updated task status with summary + expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'T001', + 'completed', + 'Task finished successfully' + ); + }); + + describe('Pipeline step summary fallback', () => { + it('should save fallback summary when extraction fails for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content without a summary tag (extraction will fail) + const newWork = 'Implementation completed without summary tag.'; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, // Pipeline status triggers fallback + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify fallback summary was saved with trimmed content + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Implementation completed without summary tag.' + ); + }); + + it('should not save fallback for non-pipeline status when extraction fails', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content without a summary tag + const newWork = 'Implementation completed without summary tag.'; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'in_progress' as const, // Non-pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify no fallback was saved for non-pipeline status + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should not save empty fallback for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Empty/whitespace-only content + const newWork = ' \n\t '; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify no fallback was saved since content was empty/whitespace + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should prefer extracted summary over fallback for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content WITH a summary tag + const newWork = `Implementation details here. +Proper summary from extraction`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify extracted summary was saved, not the full content + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Proper summary from extraction' + ); + // Ensure it didn't save the full content as fallback + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith( + '/project', + 'test-feature', + expect.stringContaining('Implementation details here') + ); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/agent-executor.test.ts b/jules_branch/apps/server/tests/unit/services/agent-executor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..868878bdafa5cb54761d036bd57572709c3d4966 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/agent-executor.test.ts @@ -0,0 +1,1749 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + AgentExecutor, + type AgentExecutionOptions, + type AgentExecutionResult, + type WaitForApprovalFn, + type SaveFeatureSummaryFn, + type UpdateFeatureSummaryFn, + type BuildTaskPromptFn, +} from '../../../src/services/agent-executor.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { BaseProvider } from '../../../src/providers/base-provider.js'; + +/** + * Unit tests for AgentExecutor + * + * Note: Full integration tests for execute() require complex mocking of + * @automaker/utils and @automaker/platform which have module hoisting issues. + * These tests focus on: + * - Constructor injection + * - Interface exports + * - Type correctness + * + * Integration tests for streaming/marker detection are covered in E2E tests + * and auto-mode-service tests. + */ +describe('AgentExecutor', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockPlanApprovalService: PlanApprovalService; + let mockSettingsService: SettingsService | null; + + beforeEach(() => { + // Reset mocks + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateTaskStatus: vi.fn().mockResolvedValue(undefined), + updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined), + saveFeatureSummary: vi.fn().mockResolvedValue(undefined), + } as unknown as FeatureStateManager; + + mockPlanApprovalService = { + waitForApproval: vi.fn(), + } as unknown as PlanApprovalService; + + mockSettingsService = null; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should accept null settingsService', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should accept undefined settingsService', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService + ); + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should store eventBus dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + // Verify executor was created - actual use tested via execute() + expect(executor).toBeDefined(); + }); + + it('should store featureStateManager dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeDefined(); + }); + + it('should store planApprovalService dependency', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(executor).toBeDefined(); + }); + }); + + describe('interface exports', () => { + it('should export AgentExecutionOptions type', () => { + // Type assertion test - if this compiles, the type is exported correctly + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + }; + expect(options.featureId).toBe('test-feature'); + }); + + it('should export AgentExecutionResult type', () => { + const result: AgentExecutionResult = { + responseText: 'test response', + specDetected: false, + tasksCompleted: 0, + aborted: false, + }; + expect(result.aborted).toBe(false); + }); + + it('should export callback types', () => { + const waitForApproval: WaitForApprovalFn = async () => ({ approved: true }); + const saveFeatureSummary: SaveFeatureSummaryFn = async () => {}; + const updateFeatureSummary: UpdateFeatureSummaryFn = async () => {}; + const buildTaskPrompt: BuildTaskPromptFn = () => 'prompt'; + + expect(typeof waitForApproval).toBe('function'); + expect(typeof saveFeatureSummary).toBe('function'); + expect(typeof updateFeatureSummary).toBe('function'); + expect(typeof buildTaskPrompt).toBe('function'); + }); + }); + + describe('AgentExecutionOptions', () => { + it('should accept required options', () => { + const options: AgentExecutionOptions = { + workDir: '/test/workdir', + featureId: 'feature-123', + prompt: 'Test prompt', + projectPath: '/test/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + }; + + expect(options.workDir).toBe('/test/workdir'); + expect(options.featureId).toBe('feature-123'); + expect(options.prompt).toBe('Test prompt'); + expect(options.projectPath).toBe('/test/project'); + expect(options.abortController).toBeInstanceOf(AbortController); + expect(options.effectiveBareModel).toBe('claude-sonnet-4-6'); + }); + + it('should accept optional options', () => { + const options: AgentExecutionOptions = { + workDir: '/test/workdir', + featureId: 'feature-123', + prompt: 'Test prompt', + projectPath: '/test/project', + abortController: new AbortController(), + provider: {} as BaseProvider, + effectiveBareModel: 'claude-sonnet-4-6', + // Optional fields + imagePaths: ['/image1.png', '/image2.png'], + model: 'claude-sonnet-4-6', + planningMode: 'spec', + requirePlanApproval: true, + previousContent: 'Previous content', + systemPrompt: 'System prompt', + autoLoadClaudeMd: true, + thinkingLevel: 'medium', + branchName: 'feature-branch', + specAlreadyDetected: false, + existingApprovedPlanContent: 'Approved plan', + persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' }], + sdkOptions: { + maxTurns: 100, + allowedTools: ['read', 'write'], + }, + }; + + expect(options.imagePaths).toHaveLength(2); + expect(options.planningMode).toBe('spec'); + expect(options.requirePlanApproval).toBe(true); + expect(options.branchName).toBe('feature-branch'); + }); + }); + + describe('AgentExecutionResult', () => { + it('should contain responseText', () => { + const result: AgentExecutionResult = { + responseText: 'Full response text from agent', + specDetected: true, + tasksCompleted: 5, + aborted: false, + }; + expect(result.responseText).toBe('Full response text from agent'); + }); + + it('should contain specDetected flag', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: true, + tasksCompleted: 0, + aborted: false, + }; + expect(result.specDetected).toBe(true); + }); + + it('should contain tasksCompleted count', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: true, + tasksCompleted: 10, + aborted: false, + }; + expect(result.tasksCompleted).toBe(10); + }); + + it('should contain aborted flag', () => { + const result: AgentExecutionResult = { + responseText: '', + specDetected: false, + tasksCompleted: 3, + aborted: true, + }; + expect(result.aborted).toBe(true); + }); + }); + + describe('execute method signature', () => { + it('should have execute method', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + expect(typeof executor.execute).toBe('function'); + }); + + it('should accept options and callbacks', () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Type check - verifying the signature accepts the expected parameters + // Actual execution would require mocking external modules + const executeSignature = executor.execute.length; + // execute(options, callbacks) = 2 parameters + expect(executeSignature).toBe(2); + }); + }); + + describe('callback types', () => { + it('WaitForApprovalFn should return approval result', async () => { + const waitForApproval: WaitForApprovalFn = vi.fn().mockResolvedValue({ + approved: true, + feedback: 'Looks good', + editedPlan: undefined, + }); + + const result = await waitForApproval('feature-123', '/project'); + expect(result.approved).toBe(true); + expect(result.feedback).toBe('Looks good'); + }); + + it('WaitForApprovalFn should handle rejection with feedback', async () => { + const waitForApproval: WaitForApprovalFn = vi.fn().mockResolvedValue({ + approved: false, + feedback: 'Please add more tests', + editedPlan: '## Revised Plan\n...', + }); + + const result = await waitForApproval('feature-123', '/project'); + expect(result.approved).toBe(false); + expect(result.feedback).toBe('Please add more tests'); + expect(result.editedPlan).toBeDefined(); + }); + + it('SaveFeatureSummaryFn should accept parameters', async () => { + const saveSummary: SaveFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + + await saveSummary('/project', 'feature-123', 'Feature summary text'); + expect(saveSummary).toHaveBeenCalledWith('/project', 'feature-123', 'Feature summary text'); + }); + + it('UpdateFeatureSummaryFn should accept parameters', async () => { + const updateSummary: UpdateFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + + await updateSummary('/project', 'feature-123', 'Updated summary'); + expect(updateSummary).toHaveBeenCalledWith('/project', 'feature-123', 'Updated summary'); + }); + + it('BuildTaskPromptFn should return prompt string', () => { + const buildPrompt: BuildTaskPromptFn = vi.fn().mockReturnValue('Execute T001: Create file'); + + const task = { id: 'T001', description: 'Create file', status: 'pending' as const }; + const allTasks = [task]; + const prompt = buildPrompt(task, allTasks, 0, 'Plan content', 'Template', undefined); + + expect(typeof prompt).toBe('string'); + expect(prompt).toBe('Execute T001: Create file'); + }); + }); + + describe('dependency injection patterns', () => { + it('should allow different eventBus implementations', () => { + const customEventBus = { + emitAutoModeEvent: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + } as unknown as TypedEventBus; + + const executor = new AgentExecutor( + customEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should allow different featureStateManager implementations', () => { + const customStateManager = { + updateTaskStatus: vi.fn().mockResolvedValue(undefined), + updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined), + saveFeatureSummary: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(null), + } as unknown as FeatureStateManager; + + const executor = new AgentExecutor( + mockEventBus, + customStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + + it('should work with mock settingsService', () => { + const customSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + customSettingsService + ); + + expect(executor).toBeInstanceOf(AgentExecutor); + }); + }); + + describe('execute() behavior', () => { + /** + * Execution tests focus on verifiable behaviors without requiring + * full stream mocking. Complex integration scenarios are tested in E2E. + */ + + it('should return aborted=true when abort signal is already aborted', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Create an already-aborted controller + const abortController = new AbortController(); + abortController.abort(); + + // Mock provider that yields nothing (would check signal first) + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Generator yields nothing, simulating immediate abort check + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController, + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Execute - should complete without error even with aborted signal + const result = await executor.execute(options, callbacks); + + // When stream is empty and signal is aborted before stream starts, + // the result depends on whether abort was checked + expect(result).toBeDefined(); + expect(result.responseText).toBeDefined(); + }); + + it('should initialize with previousContent when provided', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Empty stream + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + previousContent: 'Previous context from earlier session', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // Response should start with previous content + expect(result.responseText).toContain('Previous context from earlier session'); + expect(result.responseText).toContain('Follow-up Session'); + }); + + it('should return specDetected=false when no spec markers in content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Simple response without spec markers' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', // No spec detection in skip mode + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + expect(result.specDetected).toBe(false); + expect(result.responseText).toContain('Simple response without spec markers'); + }); + + it('should emit auto_mode_progress events for text content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'First chunk of text' }], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Second chunk of text' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should emit progress events for each text chunk + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_progress', { + featureId: 'test-feature', + branchName: null, + content: 'First chunk of text', + }); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_progress', { + featureId: 'test-feature', + branchName: null, + content: 'Second chunk of text', + }); + }); + + it('should emit auto_mode_tool events for tool use', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + name: 'write_file', + input: { path: '/test/file.ts', content: 'test content' }, + }, + ], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should emit tool event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_tool', { + featureId: 'test-feature', + branchName: null, + tool: 'write_file', + input: { path: '/test/file.ts', content: 'test content' }, + }); + }); + + it('should throw error when provider stream yields error message', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Starting...' }], + }, + }; + yield { + type: 'error', + error: 'API rate limit exceeded', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('API rate limit exceeded'); + }); + + it('should throw "Unknown error" when provider stream yields error with empty message', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'error', + error: '', + session_id: 'sess-123', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('Unknown error'); + }); + + it('should throw with sanitized error when provider yields ANSI-decorated error', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'error', + // ANSI color codes + "Error: " prefix that should be stripped + error: '\x1b[31mError: Connection refused\x1b[0m', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Should strip ANSI codes and "Error: " prefix + await expect(executor.execute(options, callbacks)).rejects.toThrow('Connection refused'); + }); + + it('should throw when result subtype is error_max_turns', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Working on it...' }], + }, + }; + yield { + type: 'result', + subtype: 'error_max_turns', + session_id: 'sess-456', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_turns' + ); + }); + + it('should throw when result subtype is error_during_execution', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_during_execution', + session_id: 'sess-789', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_during_execution' + ); + }); + + it('should throw when result subtype is error_max_structured_output_retries', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_max_structured_output_retries', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_structured_output_retries' + ); + }); + + it('should throw when result subtype is error_max_budget_usd', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'result', + subtype: 'error_max_budget_usd', + session_id: 'sess-budget', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow( + 'Agent execution ended with: error_max_budget_usd' + ); + }); + + it('should NOT throw when result subtype is success', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Done!' }], + }, + }; + yield { + type: 'result', + subtype: 'success', + session_id: 'sess-ok', + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + // Should resolve without throwing + const result = await executor.execute(options, callbacks); + expect(result.aborted).toBe(false); + expect(result.responseText).toContain('Done!'); + }); + + it('should throw error when authentication fails in response', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Error: Invalid API key' }], + }, + }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await expect(executor.execute(options, callbacks)).rejects.toThrow('Authentication failed'); + }); + + it('should accumulate responseText from multiple text blocks', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Part 1.' }, + { type: 'text', text: ' Part 2.' }, + ], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: ' Part 3.' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // All parts should be in response text + expect(result.responseText).toContain('Part 1'); + expect(result.responseText).toContain('Part 2'); + expect(result.responseText).toContain('Part 3'); + }); + + it('should return tasksCompleted=0 when no tasks executed', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Simple response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + expect(result.tasksCompleted).toBe(0); + expect(result.aborted).toBe(false); + }); + + it('should pass branchName to event payloads', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + branchName: 'feature/my-feature', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Branch name should be passed to progress event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_progress', + expect.objectContaining({ + branchName: 'feature/my-feature', + }) + ); + }); + + it('should pass claudeCompatibleProvider to executeQuery options', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + claudeCompatibleProvider: mockClaudeProvider, + }; + + 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); + + expect(mockProvider.executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + }) + ); + }); + + it('should return correct result structure', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Test response' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + const result = await executor.execute(options, callbacks); + + // Verify result has all expected properties + expect(result).toHaveProperty('responseText'); + expect(result).toHaveProperty('specDetected'); + expect(result).toHaveProperty('tasksCompleted'); + expect(result).toHaveProperty('aborted'); + + // Verify types + expect(typeof result.responseText).toBe('string'); + expect(typeof result.specDetected).toBe('boolean'); + expect(typeof result.tasksCompleted).toBe('number'); + expect(typeof result.aborted).toBe('boolean'); + }); + }); + + describe('pipeline summary fallback with scaffold stripping', () => { + it('should strip follow-up scaffold from fallback summary when extraction fails', 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: 'Some agent output without summary markers' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status to trigger fallback + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // The fallback summary should be called without the scaffold header + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Should not contain the scaffold header + expect(savedSummary).not.toContain('---'); + expect(savedSummary).not.toContain('Follow-up Session'); + // Should contain the actual content + expect(savedSummary).toContain('Some agent output without summary markers'); + }); + + it('should not save fallback when scaffold is the only content after stripping', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Provider yields no content - only scaffold will be present + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Empty stream - no actual content + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should not save an empty fallback (after scaffold is stripped) + expect(saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should save extracted summary when available, not fallback', 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: 'Some content\n\nExtracted summary here\n\nMore content', + }, + ], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should save the extracted summary, not the full content + expect(saveFeatureSummary).toHaveBeenCalledTimes(1); + expect(saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Extracted summary here' + ); + }); + + it('should handle scaffold with various whitespace patterns', 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: 'Agent response here' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should strip scaffold and save actual content + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + expect(savedSummary.trim()).toBe('Agent response here'); + }); + + it('should handle scaffold with extra newlines between markers', 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: 'Actual content after scaffold' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // Set up with previous content to trigger scaffold insertion + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Verify the scaffold is stripped + expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/); + }); + + it('should handle content without any scaffold (first session)', 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 session output without summary' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // No previousContent means no scaffold + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: undefined, // No previous content + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + expect(savedSummary).toBe('First session output without summary'); + }); + + it('should handle non-pipeline status without saving fallback', 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: 'Output without summary' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous content', + status: 'implementing', // Non-pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should NOT save fallback for non-pipeline status + expect(saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should correctly handle content that starts with dashes but is not scaffold', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Content that looks like it might have dashes but is actual content + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: '---This is a code comment or separator---' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: undefined, + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Content should be preserved since it's not the scaffold pattern + expect(savedSummary).toContain('---This is a code comment or separator---'); + }); + + it('should handle scaffold at different positions 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: 'Content after scaffold marker' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // With previousContent, scaffold will be at the start of sessionContent + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Scaffold should be stripped, only actual content remains + expect(savedSummary).toBe('Content after scaffold marker'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/agent-output-validation.test.ts b/jules_branch/apps/server/tests/unit/services/agent-output-validation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6939246946ea503bb8c5197af251dfbd6e240e5b --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/agent-output-validation.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Contract tests verifying the tool marker format used by agent-executor + * (which writes agent output) and execution-service (which reads it to + * determine if the agent did meaningful work). + * + * The agent-executor writes: `\n🔧 Tool: ${block.name}\n` + * The execution-service checks: `agentOutput.includes('🔧 Tool:')` + * + * These tests ensure the marker format contract stays consistent and + * document the exact detection logic used for status determination. + */ + +// The exact marker prefix that execution-service searches for +const TOOL_MARKER = '🔧 Tool:'; + +// Minimum output length threshold for "meaningful work" +const MIN_OUTPUT_LENGTH = 200; + +/** + * Simulates the agent-executor's tool_use output format. + * See: agent-executor.ts line ~293 + */ +function formatToolUseBlock(toolName: string, input?: Record): string { + let output = `\n${TOOL_MARKER} ${toolName}\n`; + if (input) output += `Input: ${JSON.stringify(input, null, 2)}\n`; + return output; +} + +/** + * Simulates the execution-service's output validation logic. + * See: execution-service.ts lines ~427-429 + */ +function validateAgentOutput( + agentOutput: string, + skipTests: boolean +): 'verified' | 'waiting_approval' { + const hasToolUsage = agentOutput.includes(TOOL_MARKER); + const hasMinimalOutput = agentOutput.trim().length < MIN_OUTPUT_LENGTH; + const agentDidWork = hasToolUsage && !hasMinimalOutput; + + if (skipTests) return 'waiting_approval'; + if (!agentDidWork) return 'waiting_approval'; + return 'verified'; +} + +describe('Agent Output Validation - Contract Tests', () => { + describe('tool marker format contract', () => { + it('agent-executor tool format contains the expected marker', () => { + const toolOutput = formatToolUseBlock('Read', { file_path: '/src/index.ts' }); + expect(toolOutput).toContain(TOOL_MARKER); + }); + + it('agent-executor tool format includes tool name after marker', () => { + const toolOutput = formatToolUseBlock('Edit', { + file_path: '/src/app.ts', + old_string: 'foo', + new_string: 'bar', + }); + expect(toolOutput).toContain('🔧 Tool: Edit'); + }); + + it('agent-executor tool format includes JSON input', () => { + const input = { file_path: '/src/index.ts' }; + const toolOutput = formatToolUseBlock('Read', input); + expect(toolOutput).toContain('Input: '); + expect(toolOutput).toContain('"file_path": "/src/index.ts"'); + }); + + it('agent-executor tool format works without input', () => { + const toolOutput = formatToolUseBlock('Bash'); + expect(toolOutput).toContain('🔧 Tool: Bash'); + expect(toolOutput).not.toContain('Input:'); + }); + + it('marker includes colon and space to avoid false positives', () => { + // Ensure the marker is specific enough to avoid matching other emoji patterns + expect(TOOL_MARKER).toBe('🔧 Tool:'); + expect(TOOL_MARKER).toContain(':'); + }); + }); + + describe('output validation logic', () => { + it('verified: tool usage + sufficient output', () => { + const output = + 'Starting implementation of the new feature...\n' + + formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + + 'I can see the existing code. Let me make the needed changes.\n' + + formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) + + 'Changes complete. The implementation adds new validation logic and tests.'; + expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('waiting_approval: no tool markers regardless of length', () => { + const longOutput = 'I analyzed the codebase. '.repeat(50); + expect(longOutput.trim().length).toBeGreaterThan(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(longOutput, false)).toBe('waiting_approval'); + }); + + it('waiting_approval: tool markers but insufficient length', () => { + const shortOutput = formatToolUseBlock('Read', { file_path: '/src/a.ts' }); + expect(shortOutput.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(shortOutput, false)).toBe('waiting_approval'); + }); + + it('waiting_approval: empty output', () => { + expect(validateAgentOutput('', false)).toBe('waiting_approval'); + }); + + it('waiting_approval: skipTests always overrides', () => { + const goodOutput = + 'Starting...\n' + + formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + + formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) + + 'Done implementing. '.repeat(15); + expect(goodOutput.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(goodOutput, true)).toBe('waiting_approval'); + }); + + it('boundary: exactly MIN_OUTPUT_LENGTH chars with tool is verified', () => { + const tool = formatToolUseBlock('Read'); + const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - tool.trim().length); + const output = tool + padding; + expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH); + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('boundary: MIN_OUTPUT_LENGTH - 1 chars with tool is waiting_approval', () => { + const marker = `${TOOL_MARKER} Read\n`; + const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - 1 - marker.length); + const output = marker + padding; + expect(output.trim().length).toBe(MIN_OUTPUT_LENGTH - 1); + + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + }); + + describe('realistic provider scenarios', () => { + it('Claude SDK agent with multiple tools → verified', () => { + let output = "I'll implement the feature.\n\n"; + output += formatToolUseBlock('Read', { file_path: '/src/components/App.tsx' }); + output += 'I see the component. Let me update it.\n\n'; + output += formatToolUseBlock('Edit', { + file_path: '/src/components/App.tsx', + old_string: 'const App = () => {', + new_string: 'const App: React.FC = () => {', + }); + output += 'Done. The component is now typed correctly.\n'; + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + + it('Cursor CLI quick exit (no tools) → waiting_approval', () => { + const output = 'Task received. Processing...\nResult: completed successfully.'; + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Codex CLI with brief acknowledgment → waiting_approval', () => { + const output = 'Understood the task. Starting implementation.\nDone.'; + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Agent that only reads but makes no edits (single Read tool, short output) → waiting_approval', () => { + const output = formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + 'File read.'; + expect(output.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH); + expect(validateAgentOutput(output, false)).toBe('waiting_approval'); + }); + + it('Agent with extensive tool usage and explanation → verified', () => { + let output = 'Analyzing the codebase for the authentication feature.\n\n'; + for (let i = 0; i < 5; i++) { + output += formatToolUseBlock('Read', { file_path: `/src/auth/handler${i}.ts` }); + output += `Found handler ${i}. `; + } + output += formatToolUseBlock('Edit', { + file_path: '/src/auth/handler0.ts', + old_string: 'function login() {}', + new_string: 'async function login(creds: Credentials) { ... }', + }); + output += 'Implementation complete with all authentication changes applied.\n'; + + expect(validateAgentOutput(output, false)).toBe('verified'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/agent-service.test.ts b/jules_branch/apps/server/tests/unit/services/agent-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..22ab63831b9d64b8f3aaec798236d47ac9c4991b --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/agent-service.test.ts @@ -0,0 +1,997 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentService } from '@/services/agent-service.js'; +import { ProviderFactory } from '@/providers/provider-factory.js'; +import * as fs from 'fs/promises'; +import * as imageHandler from '@automaker/utils'; +import * as promptBuilder from '@automaker/utils'; +import * as contextLoader from '@automaker/utils'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; + +// Create a shared mock logger instance for assertions using vi.hoisted +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('fs/promises'); +vi.mock('@/providers/provider-factory.js'); +vi.mock('@automaker/utils', async () => { + const actual = await vi.importActual('@automaker/utils'); + return { + ...actual, + loadContextFiles: vi.fn(), + buildPromptWithImages: vi.fn(), + readImageAsBase64: vi.fn(), + createLogger: vi.fn(() => mockLogger), + }; +}); + +describe('agent-service.ts', () => { + let service: AgentService; + const mockEvents = { + subscribe: vi.fn(), + emit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new AgentService('/test/data', mockEvents as any); + + // Mock loadContextFiles to return empty context by default + vi.mocked(contextLoader.loadContextFiles).mockResolvedValue({ + files: [], + formattedPrompt: '', + }); + }); + + describe('initialize', () => { + it('should create state directory', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.initialize(); + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('agent-sessions'), { + recursive: true, + }); + }); + }); + + describe('startConversation', () => { + it('should create new session with empty messages', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await service.startConversation({ + sessionId: 'session-1', + workingDirectory: '/test/dir', + }); + + expect(result.success).toBe(true); + expect(result.messages).toEqual([]); + expect(result.sessionId).toBe('session-1'); + }); + + it('should load existing session', async () => { + const existingMessages = [ + { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: '2024-01-01T00:00:00Z', + }, + ]; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMessages)); + + const result = await service.startConversation({ + sessionId: 'session-1', + workingDirectory: '/test/dir', + }); + + expect(result.success).toBe(true); + expect(result.messages).toEqual(existingMessages); + }); + + it('should use process.cwd() if no working directory provided', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await service.startConversation({ + sessionId: 'session-1', + }); + + expect(result.success).toBe(true); + }); + + it('should reuse existing session if already started', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + // Start session first time + await service.startConversation({ + sessionId: 'session-1', + }); + + // Start again with same ID + const result = await service.startConversation({ + sessionId: 'session-1', + }); + + expect(result.success).toBe(true); + // First call reads metadata file and session file via ensureSession (2 calls) + // Since no metadata or messages exist, a fresh session is created without loading queue state. + // Second call should reuse in-memory session (no additional calls) + expect(fs.readFile).toHaveBeenCalledTimes(2); + }); + }); + + describe('sendMessage', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + workingDirectory: '/test/dir', + }); + }); + + it('should throw if session not found', async () => { + await expect( + service.sendMessage({ + sessionId: 'nonexistent', + message: 'Hello', + }) + ).rejects.toThrow('Session nonexistent not found'); + }); + + it('should process message and stream responses', async () => { + const mockProvider = { + getName: () => 'claude', + executeQuery: async function* () { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Response' }], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + const result = await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + workingDirectory: '/custom/dir', + }); + + expect(result.success).toBe(true); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should emit tool_result events from provider stream', async () => { + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* () { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'Read', + tool_use_id: 'tool-1', + input: { file_path: 'README.md' }, + }, + ], + }, + }; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'File contents here', + }, + ], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + }); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'agent:stream', + expect.objectContaining({ + sessionId: 'session-1', + type: 'tool_result', + tool: { + name: 'Read', + input: { + toolUseId: 'tool-1', + content: 'File contents here', + }, + }, + }) + ); + }); + + it('should emit tool_result with unknown tool name for unregistered tool_use_id', async () => { + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* () { + // Yield tool_result WITHOUT a preceding tool_use (unregistered tool_use_id) + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: 'unregistered-id', + content: 'Some result content', + }, + ], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + }); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'agent:stream', + expect.objectContaining({ + sessionId: 'session-1', + type: 'tool_result', + tool: { + name: 'unknown', + input: { + toolUseId: 'unregistered-id', + content: 'Some result content', + }, + }, + }) + ); + }); + + it('should handle images in message', async () => { + const mockProvider = { + getName: () => 'claude', + executeQuery: async function* () { + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(imageHandler.readImageAsBase64).mockResolvedValue({ + base64: 'base64data', + mimeType: 'image/png', + filename: 'test.png', + originalPath: '/path/test.png', + }); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Check image', + hasImages: true, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Check this', + imagePaths: ['/path/test.png'], + }); + + expect(imageHandler.readImageAsBase64).toHaveBeenCalledWith('/path/test.png'); + }); + + it('should handle failed image loading gracefully', async () => { + const mockProvider = { + getName: () => 'claude', + executeQuery: async function* () { + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(imageHandler.readImageAsBase64).mockRejectedValue(new Error('Image not found')); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Check image', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Check this', + imagePaths: ['/path/test.png'], + }); + + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('should use custom model if provided', async () => { + const mockProvider = { + getName: () => 'claude', + executeQuery: async function* () { + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + model: 'claude-sonnet-4-6', + }); + + expect(ProviderFactory.getProviderForModel).toHaveBeenCalledWith('claude-sonnet-4-6'); + }); + + it('should save session messages', async () => { + const mockProvider = { + getName: () => 'claude', + executeQuery: async function* () { + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + }); + + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should include context/history preparation for Gemini requests', async () => { + let capturedOptions: any; + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* (options: any) { + capturedOptions = options; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini'); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + model: 'gemini-2.5-flash', + }); + + expect(contextLoader.loadContextFiles).toHaveBeenCalled(); + expect(capturedOptions).toBeDefined(); + }); + }); + + describe('stopExecution', () => { + it('should stop execution for a session', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + await service.startConversation({ + sessionId: 'session-1', + }); + + // Should return success + const result = await service.stopExecution('session-1'); + expect(result.success).toBeDefined(); + }); + }); + + describe('getHistory', () => { + it('should return message history', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + await service.startConversation({ + sessionId: 'session-1', + }); + + const history = await service.getHistory('session-1'); + + expect(history).toBeDefined(); + expect(history?.messages).toEqual([]); + }); + + it('should handle non-existent session', async () => { + const history = await service.getHistory('nonexistent'); + expect(history).toBeDefined(); + expect(history.success).toBe(false); + expect(history.error).toBeDefined(); + expect(typeof history.error).toBe('string'); + }); + }); + + describe('clearSession', () => { + it('should clear session messages', async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + + await service.clearSession('session-1'); + + const history = await service.getHistory('session-1'); + expect(history?.messages).toEqual([]); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should clear sdkSessionId from persisted metadata to prevent stale session errors', async () => { + // Setup: Session exists in metadata with an sdkSessionId (simulating + // a session that previously communicated with a CLI provider like OpenCode) + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sdkSessionId: 'stale-opencode-session-id', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + // Start the session (loads from disk metadata) + await service.startConversation({ + sessionId: 'session-1', + workingDirectory: '/test/dir', + }); + + // Clear the session + await service.clearSession('session-1'); + + // Verify that the LAST writeFile call to sessions-metadata.json + // (from clearSdkSessionId) has sdkSessionId removed. + // Earlier writes may still include it (e.g., from updateSessionTimestamp). + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + const metadataWriteCalls = writeFileCalls.filter( + (call) => + typeof call[0] === 'string' && (call[0] as string).includes('sessions-metadata.json') + ); + + expect(metadataWriteCalls.length).toBeGreaterThan(0); + const lastMetadataWriteCall = metadataWriteCalls[metadataWriteCalls.length - 1]; + const savedMetadata = JSON.parse(lastMetadataWriteCall[1] as string); + expect(savedMetadata['session-1'].sdkSessionId).toBeUndefined(); + }); + }); + + describe('clearSdkSessionId', () => { + it('should remove sdkSessionId from persisted metadata', async () => { + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + sdkSessionId: 'old-provider-session-id', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('session-1'); + + const writeFileCalls = vi.mocked(fs.writeFile).mock.calls; + expect(writeFileCalls.length).toBeGreaterThan(0); + + const savedMetadata = JSON.parse(writeFileCalls[0][1] as string); + expect(savedMetadata['session-1'].sdkSessionId).toBeUndefined(); + expect(savedMetadata['session-1'].updatedAt).not.toBe('2024-01-01T00:00:00Z'); + }); + + it('should do nothing if session has no sdkSessionId', async () => { + const metadata = { + 'session-1': { + id: 'session-1', + name: 'Test Session', + workingDirectory: '/test/dir', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(metadata)); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('session-1'); + + // writeFile should not have been called since there's no sdkSessionId to clear + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should do nothing if session does not exist in metadata', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await service.clearSdkSessionId('nonexistent'); + + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe('createSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should create a new session with metadata', async () => { + const session = await service.createSession('Test Session', '/test/project', '/test/dir'); + + expect(session.id).toBeDefined(); + expect(session.name).toBe('Test Session'); + expect(session.projectPath).toBe('/test/project'); + expect(session.workingDirectory).toBeDefined(); + expect(session.createdAt).toBeDefined(); + expect(session.updatedAt).toBeDefined(); + }); + + it('should use process.cwd() if no working directory provided', async () => { + const session = await service.createSession('Test Session'); + + expect(session.workingDirectory).toBeDefined(); + }); + + it('should validate working directory', async () => { + // Set ALLOWED_ROOT_DIRECTORY to restrict paths + const originalAllowedRoot = process.env.ALLOWED_ROOT_DIRECTORY; + process.env.ALLOWED_ROOT_DIRECTORY = '/allowed/projects'; + + // Re-import platform to initialize with new env var + vi.resetModules(); + const { initAllowedPaths } = await import('@automaker/platform'); + initAllowedPaths(); + + const { AgentService } = await import('@/services/agent-service.js'); + const testService = new AgentService('/test/data', mockEvents as any); + vi.mocked(fs.readFile).mockResolvedValue('{}'); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await expect( + testService.createSession('Test Session', undefined, '/invalid/path') + ).rejects.toThrow(); + + // Restore original value + if (originalAllowedRoot) { + process.env.ALLOWED_ROOT_DIRECTORY = originalAllowedRoot; + } else { + delete process.env.ALLOWED_ROOT_DIRECTORY; + } + vi.resetModules(); + const { initAllowedPaths: reinit } = await import('@automaker/platform'); + reinit(); + }); + }); + + describe('setSessionModel', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should set model for existing session', async () => { + vi.mocked(fs.readFile).mockResolvedValue('{"session-1": {}}'); + const result = await service.setSessionModel('session-1', 'claude-sonnet-4-6'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.setSessionModel('nonexistent', 'claude-sonnet-4-6'); + + expect(result).toBe(false); + }); + }); + + describe('updateSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should update session metadata', async () => { + const result = await service.updateSession('session-1', { name: 'Updated Name' }); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('Updated Name'); + expect(result?.updatedAt).not.toBe('2024-01-01T00:00:00Z'); + }); + + it('should return null for non-existent session', async () => { + const result = await service.updateSession('nonexistent', { name: 'Updated Name' }); + + expect(result).toBeNull(); + }); + }); + + describe('archiveSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should archive a session', async () => { + const result = await service.archiveSession('session-1'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.archiveSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('unarchiveSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + archived: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + }); + + it('should unarchive a session', async () => { + const result = await service.unarchiveSession('session-1'); + + expect(result).toBe(true); + }); + + it('should return false for non-existent session', async () => { + const result = await service.unarchiveSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('deleteSession', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + }); + + it('should delete a session', async () => { + const result = await service.deleteSession('session-1'); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should return false for non-existent session', async () => { + const result = await service.deleteSession('nonexistent'); + + expect(result).toBe(false); + }); + }); + + describe('listSessions', () => { + beforeEach(() => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + 'session-1': { + id: 'session-1', + name: 'Test Session 1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + archived: false, + }, + 'session-2': { + id: 'session-2', + name: 'Test Session 2', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-03T00:00:00Z', + archived: true, + }, + }) + ); + }); + + it('should list non-archived sessions by default', async () => { + const sessions = await service.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe('session-1'); + }); + + it('should include archived sessions when requested', async () => { + const sessions = await service.listSessions(true); + + expect(sessions.length).toBe(2); + }); + + it('should sort sessions by updatedAt descending', async () => { + const sessions = await service.listSessions(true); + + expect(sessions[0].id).toBe('session-2'); + expect(sessions[1].id).toBe('session-1'); + }); + }); + + describe('addToQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should add prompt to queue', async () => { + const result = await service.addToQueue('session-1', { + message: 'Test prompt', + imagePaths: ['/test/image.png'], + model: 'claude-sonnet-4-6', + }); + + expect(result.success).toBe(true); + expect(result.queuedPrompt).toBeDefined(); + expect(result.queuedPrompt?.message).toBe('Test prompt'); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.addToQueue('nonexistent', { + message: 'Test prompt', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); + + describe('getQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + }); + + it('should return queue for session', async () => { + await service.addToQueue('session-1', { message: 'Test prompt' }); + const result = await service.getQueue('session-1'); + + expect(result.success).toBe(true); + expect(result.queue).toBeDefined(); + expect(result.queue?.length).toBe(1); + }); + + it('should return error for non-existent session', async () => { + const result = await service.getQueue('nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); + + describe('removeFromQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + + const addResult = await service.addToQueue('session-1', { message: 'Test prompt' }); + vi.clearAllMocks(); + }); + + it('should remove prompt from queue', async () => { + const queueResult = await service.getQueue('session-1'); + const promptId = queueResult.queue![0].id; + + const result = await service.removeFromQueue('session-1', promptId); + + expect(result.success).toBe(true); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.removeFromQueue('nonexistent', 'prompt-id'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + + it('should return error for non-existent prompt', async () => { + const result = await service.removeFromQueue('session-1', 'nonexistent-prompt-id'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Prompt not found in queue'); + }); + }); + + describe('clearQueue', () => { + beforeEach(async () => { + const error: any = new Error('ENOENT'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + await service.startConversation({ + sessionId: 'session-1', + }); + + await service.addToQueue('session-1', { message: 'Test prompt 1' }); + await service.addToQueue('session-1', { message: 'Test prompt 2' }); + vi.clearAllMocks(); + }); + + it('should clear all prompts from queue', async () => { + const result = await service.clearQueue('session-1'); + + expect(result.success).toBe(true); + const queueResult = await service.getQueue('session-1'); + expect(queueResult.queue?.length).toBe(0); + expect(mockEvents.emit).toHaveBeenCalled(); + }); + + it('should return error for non-existent session', async () => { + const result = await service.clearQueue('nonexistent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Session not found'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/auto-loop-coordinator.test.ts b/jules_branch/apps/server/tests/unit/services/auto-loop-coordinator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c39ea971917e0db9fe3a451b964158ee781ff4e --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -0,0 +1,1432 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + AutoLoopCoordinator, + getWorktreeAutoLoopKey, + type AutoModeConfig, + type ProjectAutoLoopState, + type ExecuteFeatureFn, + type LoadPendingFeaturesFn, + type LoadAllFeaturesFn, + type SaveExecutionStateFn, + type ClearExecutionStateFn, + type ResetStuckFeaturesFn, + type IsFeatureFinishedFn, +} from '../../../src/services/auto-loop-coordinator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { Feature } from '@automaker/types'; + +describe('auto-loop-coordinator.ts', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockConcurrencyManager: ConcurrencyManager; + let mockSettingsService: SettingsService | null; + + // Callback mocks + let mockExecuteFeature: ExecuteFeatureFn; + let mockLoadPendingFeatures: LoadPendingFeaturesFn; + let mockLoadAllFeatures: LoadAllFeaturesFn; + let mockSaveExecutionState: SaveExecutionStateFn; + let mockClearExecutionState: ClearExecutionStateFn; + let mockResetStuckFeatures: ResetStuckFeaturesFn; + let mockIsFeatureFinished: IsFeatureFinishedFn; + let mockIsFeatureRunning: (featureId: string) => boolean; + + let coordinator: AutoLoopCoordinator; + + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'ready', + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockConcurrencyManager = { + getRunningCountForWorktree: vi.fn().mockResolvedValue(0), + isRunning: vi.fn().mockReturnValue(false), + } as unknown as ConcurrencyManager; + + mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + maxConcurrency: 3, + projects: [{ id: 'proj-1', path: '/test/project' }], + autoModeByWorktree: {}, + }), + } as unknown as SettingsService; + + // Callback mocks + mockExecuteFeature = vi.fn().mockResolvedValue(undefined); + mockLoadPendingFeatures = vi.fn().mockResolvedValue([]); + mockLoadAllFeatures = vi.fn().mockResolvedValue([]); + mockSaveExecutionState = vi.fn().mockResolvedValue(undefined); + mockClearExecutionState = vi.fn().mockResolvedValue(undefined); + mockResetStuckFeatures = vi.fn().mockResolvedValue(undefined); + mockIsFeatureFinished = vi.fn().mockReturnValue(false); + mockIsFeatureRunning = vi.fn().mockReturnValue(false); + + coordinator = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning, + mockLoadAllFeatures + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('getWorktreeAutoLoopKey', () => { + it('returns correct key for main worktree (null branch)', () => { + const key = getWorktreeAutoLoopKey('/test/project', null); + expect(key).toBe('/test/project::__main__'); + }); + + it('returns correct key for named branch', () => { + const key = getWorktreeAutoLoopKey('/test/project', 'feature/test-1'); + expect(key).toBe('/test/project::feature/test-1'); + }); + + it("normalizes 'main' branch to null", () => { + const key = getWorktreeAutoLoopKey('/test/project', 'main'); + expect(key).toBe('/test/project::__main__'); + }); + }); + + describe('startAutoLoopForProject', () => { + it('throws if loop already running for project/worktree', async () => { + // Start the first loop + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Try to start another - should throw + await expect(coordinator.startAutoLoopForProject('/test/project', null, 1)).rejects.toThrow( + 'Auto mode is already running for main worktree in project' + ); + }); + + it('creates ProjectAutoLoopState with correct config', async () => { + await coordinator.startAutoLoopForProject('/test/project', 'feature-branch', 2); + + const config = coordinator.getAutoLoopConfigForProject('/test/project', 'feature-branch'); + expect(config).toEqual({ + maxConcurrency: 2, + useWorktrees: true, + projectPath: '/test/project', + branchName: 'feature-branch', + }); + }); + + it('emits auto_mode_started event', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_started', { + message: 'Auto mode started with max 3 concurrent features', + projectPath: '/test/project', + branchName: null, + maxConcurrency: 3, + }); + }); + + it('calls saveExecutionState', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + expect(mockSaveExecutionState).toHaveBeenCalledWith('/test/project', null, 3); + }); + + it('resets stuck features on start', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(mockResetStuckFeatures).toHaveBeenCalledWith('/test/project'); + }); + + it('uses settings maxConcurrency when not provided', async () => { + const result = await coordinator.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(3); // from mockSettingsService + }); + + it('uses worktree-specific maxConcurrency from settings', async () => { + vi.mocked(mockSettingsService!.getGlobalSettings).mockResolvedValue({ + maxConcurrency: 5, + projects: [{ id: 'proj-1', path: '/test/project' }], + autoModeByWorktree: { + 'proj-1::__main__': { maxConcurrency: 7 }, + }, + }); + + const result = await coordinator.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(7); + }); + }); + + describe('stopAutoLoopForProject', () => { + it('aborts running loop', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('emits auto_mode_stopped event', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_stopped', { + message: 'Auto mode stopped', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('calls clearExecutionState', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockClearExecutionState).toHaveBeenCalledWith('/test/project', null); + }); + + it('returns 0 when no loop running', async () => { + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(mockClearExecutionState).not.toHaveBeenCalled(); + }); + }); + + describe('isAutoLoopRunningForProject', () => { + it('returns true when running', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(true); + }); + + it('returns false when not running', () => { + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('returns false for different worktree', async () => { + await coordinator.startAutoLoopForProject('/test/project', 'branch-a', 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', 'branch-b')).toBe(false); + }); + }); + + describe('runAutoLoopForProject', () => { + it('loads pending features each iteration', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Advance time to trigger loop iterations + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop to avoid hanging + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockLoadPendingFeatures).toHaveBeenCalled(); + }); + + it('executes features within concurrency limit', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(3000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + + it('emits idle event when no work remains (running=0, pending=0)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration and idle event + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('skips already-running features', async () => { + const feature2: Feature = { ...testFeature, id: 'feature-2' }; + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature, feature2]); + vi.mocked(mockIsFeatureRunning) + .mockReturnValueOnce(true) // feature-1 is running + .mockReturnValueOnce(false); // feature-2 is not running + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(3000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute feature-2, not feature-1 + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-2', true, true); + }); + + it('stops when aborted', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Stop immediately + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should not have executed many features + expect(mockExecuteFeature.mock.calls.length).toBeLessThanOrEqual(1); + }); + + it('waits when at capacity', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2); // At capacity for maxConcurrency=2 + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should not have executed features because at capacity + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('counts all running features (auto + manual) against concurrency limit', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // 2 manual features running — total count is 2 + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(2); + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT execute because total running count (2) meets the concurrency limit (2) + expect(mockExecuteFeature).not.toHaveBeenCalled(); + // Verify it was called WITHOUT autoModeOnly (counts all tasks) + // The coordinator's wrapper passes options through as undefined when not specified + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + undefined + ); + }); + + it('allows auto dispatch when manual tasks finish and capacity becomes available', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First call: at capacity (2 manual features running) + // Second call: capacity freed (1 feature running) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(2) // at capacity + .mockResolvedValueOnce(1); // capacity available after manual task completes + + await coordinator.startAutoLoopForProject('/test/project', null, 2); + + // First iteration: at capacity, should wait + await vi.advanceTimersByTimeAsync(5000); + + // Second iteration: capacity available, should execute + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after capacity freed + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + + it('waits when manually started tasks already fill concurrency limit at auto mode activation', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // Manual tasks already fill the limit + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3); + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Auto mode should remain waiting, not dispatch + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('resumes dispatching when all running tasks complete simultaneously', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([testFeature]); + // First check: all 3 slots occupied + // Second check: all tasks completed simultaneously + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree) + .mockResolvedValueOnce(3) // all slots full + .mockResolvedValueOnce(0); // all tasks completed at once + + await coordinator.startAutoLoopForProject('/test/project', null, 3); + + // First iteration: at capacity + await vi.advanceTimersByTimeAsync(5000); + // Second iteration: all freed + await vi.advanceTimersByTimeAsync(6000); + + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute after all tasks freed capacity + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + }); + }); + + describe('priority-based feature selection', () => { + it('selects highest priority feature first (lowest number)', async () => { + const lowPriority: Feature = { + ...testFeature, + id: 'feature-low', + priority: 3, + title: 'Low Priority', + }; + const highPriority: Feature = { + ...testFeature, + id: 'feature-high', + priority: 1, + title: 'High Priority', + }; + const medPriority: Feature = { + ...testFeature, + id: 'feature-med', + priority: 2, + title: 'Med Priority', + }; + + // Return features in non-priority order + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([ + lowPriority, + medPriority, + highPriority, + ]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([lowPriority, medPriority, highPriority]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute the highest priority feature (priority=1) + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true); + }); + + it('uses default priority of 2 when not specified', async () => { + const noPriority: Feature = { ...testFeature, id: 'feature-none', title: 'No Priority' }; + const highPriority: Feature = { + ...testFeature, + id: 'feature-high', + priority: 1, + title: 'High Priority', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noPriority, highPriority]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([noPriority, highPriority]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // High priority (1) should be selected over default priority (2) + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-high', true, true); + }); + + it('selects first feature when priorities are equal', async () => { + const featureA: Feature = { + ...testFeature, + id: 'feature-a', + priority: 2, + title: 'Feature A', + }; + const featureB: Feature = { + ...testFeature, + id: 'feature-b', + priority: 2, + title: 'Feature B', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([featureA, featureB]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([featureA, featureB]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // When priorities equal, the first feature from the filtered list should be chosen + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-a', true, true); + }); + }); + + describe('dependency-aware feature selection', () => { + it('skips features with unsatisfied dependencies', async () => { + const depFeature: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'in_progress', + title: 'Dependency Feature', + }; + const blockedFeature: Feature = { + ...testFeature, + id: 'feature-blocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Blocked Feature', + }; + const readyFeature: Feature = { + ...testFeature, + id: 'feature-ready', + priority: 2, + title: 'Ready Feature', + }; + + // Pending features (backlog/ready status) + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([blockedFeature, readyFeature]); + // All features (including the in-progress dependency) + vi.mocked(mockLoadAllFeatures).mockResolvedValue([depFeature, blockedFeature, readyFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should skip blocked feature (dependency not complete) and execute ready feature + expect(mockExecuteFeature).toHaveBeenCalledWith('/test/project', 'feature-ready', true, true); + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'feature-blocked', + true, + true + ); + }); + + it('picks features whose dependencies are completed', async () => { + const completedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'completed', + title: 'Completed Dependency', + }; + const unblockedFeature: Feature = { + ...testFeature, + id: 'feature-unblocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Unblocked Feature', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedDep, unblockedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should execute the unblocked feature since its dependency is completed + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked', + true, + true + ); + }); + + it('picks features whose dependencies are verified', async () => { + const verifiedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'verified', + title: 'Verified Dependency', + }; + const unblockedFeature: Feature = { + ...testFeature, + id: 'feature-unblocked', + dependencies: ['feature-dep'], + priority: 1, + title: 'Unblocked Feature', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([unblockedFeature]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([verifiedDep, unblockedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked', + true, + true + ); + }); + + it('respects both priority and dependencies together', async () => { + const completedDep: Feature = { + ...testFeature, + id: 'feature-dep', + status: 'completed', + title: 'Completed Dep', + }; + const blockedHighPriority: Feature = { + ...testFeature, + id: 'feature-blocked-hp', + dependencies: ['feature-not-done'], + priority: 1, + title: 'Blocked High Priority', + }; + const unblockedLowPriority: Feature = { + ...testFeature, + id: 'feature-unblocked-lp', + dependencies: ['feature-dep'], + priority: 3, + title: 'Unblocked Low Priority', + }; + const unblockedMedPriority: Feature = { + ...testFeature, + id: 'feature-unblocked-mp', + priority: 2, + title: 'Unblocked Med Priority', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([ + blockedHighPriority, + unblockedLowPriority, + unblockedMedPriority, + ]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([ + completedDep, + blockedHighPriority, + unblockedLowPriority, + unblockedMedPriority, + ]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should skip blocked high-priority and pick the unblocked medium-priority + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-unblocked-mp', + true, + true + ); + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'feature-blocked-hp', + true, + true + ); + }); + + it('handles features with no dependencies (always eligible)', async () => { + const noDeps: Feature = { + ...testFeature, + id: 'feature-no-deps', + priority: 2, + title: 'No Dependencies', + }; + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([noDeps]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([noDeps]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-no-deps', + true, + true + ); + }); + }); + + describe('failure tracking', () => { + it('trackFailureAndCheckPauseForProject returns true after threshold', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Track 3 failures (threshold) + const result1 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 1', + }); + expect(result1).toBe(false); + + const result2 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 2', + }); + expect(result2).toBe(false); + + const result3 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 3', + }); + expect(result3).toBe(true); // Should pause after 3 + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('agent errors count as failures', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Agent failed', + }); + + // First error should not pause + expect(result).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('clears failures on success (recordSuccessForProject)', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Add 2 failures + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 1', + }); + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 2', + }); + + // Record success - should clear failures + coordinator.recordSuccessForProject('/test/project'); + + // Next failure should return false (not hitting threshold) + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error 3', + }); + expect(result).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('signalShouldPauseForProject emits event and stops loop', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + coordinator.signalShouldPauseForProject('/test/project', { + type: 'quota_exhausted', + message: 'Rate limited', + }); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_paused_failures', + expect.objectContaining({ + errorType: 'quota_exhausted', + projectPath: '/test/project', + }) + ); + + // Loop should be stopped + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(false); + }); + + it('quota/rate limit errors pause immediately', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'quota_exhausted', + message: 'API quota exceeded', + }); + + expect(result).toBe(true); // Should pause immediately + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('rate_limit type also pauses immediately', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + const result = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'rate_limit', + message: 'Rate limited', + }); + + expect(result).toBe(true); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + }); + + describe('multiple projects', () => { + it('runs concurrent loops for different projects', async () => { + await coordinator.startAutoLoopForProject('/project-a', null, 1); + await coordinator.startAutoLoopForProject('/project-b', null, 1); + + expect(coordinator.isAutoLoopRunningForProject('/project-a', null)).toBe(true); + expect(coordinator.isAutoLoopRunningForProject('/project-b', null)).toBe(true); + + await coordinator.stopAutoLoopForProject('/project-a', null); + await coordinator.stopAutoLoopForProject('/project-b', null); + }); + + it('runs concurrent loops for different worktrees of same project', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + await coordinator.startAutoLoopForProject('/test/project', 'feature-branch', 1); + + expect(coordinator.isAutoLoopRunningForProject('/test/project', null)).toBe(true); + expect(coordinator.isAutoLoopRunningForProject('/test/project', 'feature-branch')).toBe(true); + + await coordinator.stopAutoLoopForProject('/test/project', null); + await coordinator.stopAutoLoopForProject('/test/project', 'feature-branch'); + }); + + it('stopping one loop does not affect others', async () => { + await coordinator.startAutoLoopForProject('/project-a', null, 1); + await coordinator.startAutoLoopForProject('/project-b', null, 1); + + await coordinator.stopAutoLoopForProject('/project-a', null); + + expect(coordinator.isAutoLoopRunningForProject('/project-a', null)).toBe(false); + expect(coordinator.isAutoLoopRunningForProject('/project-b', null)).toBe(true); + + await coordinator.stopAutoLoopForProject('/project-b', null); + }); + }); + + describe('getAutoLoopConfigForProject', () => { + it('returns config when loop is running', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 5); + + const config = coordinator.getAutoLoopConfigForProject('/test/project', null); + + expect(config).toEqual({ + maxConcurrency: 5, + useWorktrees: true, + projectPath: '/test/project', + branchName: null, + }); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('returns null when no loop running', () => { + const config = coordinator.getAutoLoopConfigForProject('/test/project', null); + + expect(config).toBeNull(); + }); + }); + + describe('getRunningCountForWorktree', () => { + it('delegates to ConcurrencyManager', async () => { + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(3); + + const count = await coordinator.getRunningCountForWorktree('/test/project', null); + + expect(count).toBe(3); + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + undefined + ); + }); + + it('passes autoModeOnly option to ConcurrencyManager', async () => { + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(1); + + const count = await coordinator.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + + expect(count).toBe(1); + expect(mockConcurrencyManager.getRunningCountForWorktree).toHaveBeenCalledWith( + '/test/project', + null, + { autoModeOnly: true } + ); + }); + }); + + describe('resetFailureTrackingForProject', () => { + it('clears consecutive failures and paused flag', async () => { + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Add failures + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + + // Reset failure tracking + coordinator.resetFailureTrackingForProject('/test/project'); + + // Next 3 failures should be needed to trigger pause again + const result1 = coordinator.trackFailureAndCheckPauseForProject('/test/project', { + type: 'agent_error', + message: 'Error', + }); + expect(result1).toBe(false); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + }); + + describe('edge cases', () => { + it('handles null settingsService gracefully', async () => { + const coordWithoutSettings = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + null, // No settings service + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + ); + + // Should use default concurrency + const result = await coordWithoutSettings.startAutoLoopForProject('/test/project', null); + + expect(result).toBe(1); // DEFAULT_MAX_CONCURRENCY + + await coordWithoutSettings.stopAutoLoopForProject('/test/project', null); + }); + + it('handles resetStuckFeatures error gracefully', async () => { + vi.mocked(mockResetStuckFeatures).mockRejectedValue(new Error('Reset failed')); + + // Should not throw + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + expect(mockResetStuckFeatures).toHaveBeenCalled(); + + await coordinator.stopAutoLoopForProject('/test/project', null); + }); + + it('trackFailureAndCheckPauseForProject returns false when no loop', () => { + const result = coordinator.trackFailureAndCheckPauseForProject('/nonexistent', { + type: 'agent_error', + message: 'Error', + }); + + expect(result).toBe(false); + }); + + it('signalShouldPauseForProject does nothing when no loop', () => { + // Should not throw + coordinator.signalShouldPauseForProject('/nonexistent', { + type: 'quota_exhausted', + message: 'Error', + }); + + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_paused_failures', + expect.anything() + ); + }); + + it('does not emit stopped event when loop was not running', async () => { + const result = await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(result).toBe(0); + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_stopped', + expect.anything() + ); + }); + + it('bypasses dependency checks when loadAllFeaturesFn is omitted', async () => { + // Create a dependency feature that is NOT completed (in_progress) + const inProgressDep: Feature = { + ...testFeature, + id: 'dep-feature', + status: 'in_progress', + title: 'In-Progress Dependency', + }; + // Create a pending feature that depends on the in-progress dep + const pendingFeatureWithDep: Feature = { + ...testFeature, + id: 'feature-with-dep', + dependencies: ['dep-feature'], + status: 'ready', + title: 'Feature With Dependency', + }; + + // loadAllFeaturesFn is NOT provided, so dependency checks are bypassed entirely + // (the coordinator returns true instead of calling areDependenciesSatisfied) + const coordWithoutLoadAll = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + // loadAllFeaturesFn omitted + ); + + // pendingFeatures includes the in-progress dep and the pending feature; + // since loadAllFeaturesFn is absent, dependency checks are bypassed, + // so pendingFeatureWithDep is eligible even though its dependency is not completed + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([inProgressDep, pendingFeatureWithDep]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + // The in-progress dep is not finished and not running, so both features pass the + // isFeatureFinished filter; but only pendingFeatureWithDep should be executed + // because we mark dep-feature as running to prevent it from being picked + vi.mocked(mockIsFeatureFinished).mockReturnValue(false); + vi.mocked(mockIsFeatureRunning as ReturnType).mockImplementation( + (id: string) => id === 'dep-feature' + ); + + await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1); + await vi.advanceTimersByTimeAsync(3000); + await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null); + + // pendingFeatureWithDep executes despite its dependency not being completed, + // because dependency checks are bypassed when loadAllFeaturesFn is omitted + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-with-dep', + true, + true + ); + // dep-feature is not executed because it is marked as running + expect(mockExecuteFeature).not.toHaveBeenCalledWith( + '/test/project', + 'dep-feature', + true, + true + ); + }); + }); + + describe('auto_mode_idle emission timing (idle check fix)', () => { + it('emits auto_mode_idle when no features in any state (empty project)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).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('does NOT emit auto_mode_idle when features are in in_progress status', async () => { + // No pending features (backlog/ready) + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // But there are features in in_progress status + const inProgressFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'In Progress Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]); + // No running features in concurrency manager (they were released during status update) + 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 + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('emits auto_mode_idle after in_progress feature completes', async () => { + const completedFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'completed', + title: 'Completed Feature', + }; + + // Initially has in_progress feature + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]); + 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 + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle because all features are completed + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in main worktree has no branchName + const mainWorktreeFeature: Feature = { + ...testFeature, + id: 'feature-main', + status: 'in_progress', + title: 'Main Worktree Feature', + branchName: undefined, // Main worktree feature + }; + // Feature in branch worktree has branchName + const branchFeature: Feature = { + ...testFeature, + id: 'feature-branch', + status: 'in_progress', + title: 'Branch Feature', + branchName: 'feature/some-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for main worktree + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: null, + }) + ); + }); + + it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in matching branch + const matchingBranchFeature: Feature = { + ...testFeature, + id: 'feature-matching', + status: 'in_progress', + title: 'Matching Branch Feature', + branchName: 'feature/test-branch', + }; + // Feature in different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([ + matchingBranchFeature, + differentBranchFeature, + ]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: 'feature/test-branch', + }) + ); + }); + + it('emits auto_mode_idle when in_progress feature has different branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Only feature is in a different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should emit auto_mode_idle because the in_progress feature is in a different branch + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: 'feature/test-branch', + }); + }); + + it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => { + // backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check + // But this test verifies the idle check doesn't incorrectly block on backlog/ready + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check) + const backlogFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'backlog', + title: 'Backlog Feature', + }; + const readyFeature: Feature = { + ...testFeature, + id: 'feature-2', + status: 'ready', + title: 'Ready Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]); + 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 + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there are backlog/ready features + // (even though they're not in_progress, the idle check only looks at in_progress status) + // Actually, backlog/ready would be caught by loadPendingFeatures on next iteration, + // so this should emit idle since runningCount=0 and no in_progress features + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features')); + 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 + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => { + // Create coordinator without loadAllFeaturesFn + const coordWithoutLoadAll = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + // loadAllFeaturesFn omitted + ); + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).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 multiple times to trigger multiple loop iterations + await vi.advanceTimersByTimeAsync(11000); // First idle check + await vi.advanceTimersByTimeAsync(11000); // Second idle check + await vi.advanceTimersByTimeAsync(11000); // Third idle check + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should only emit auto_mode_idle once despite multiple iterations + const idleCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_idle'); + expect(idleCalls.length).toBe(1); + }); + + it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => { + // This test reproduces the exact bug scenario described in the feature: + // When a feature completes, there's a brief window where: + // 1. The feature has been released from runningFeatures (so runningCount = 0) + // 2. The feature's status is still 'in_progress' during the status update transition + // 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses) + // The fix ensures auto_mode_idle is NOT emitted in this window + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features + // Feature is still in in_progress status (during status update transition) + const transitioningFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'Transitioning Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]); + // Feature has been released from concurrency manager (runningCount = 0) + 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 + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // The fix prevents auto_mode_idle from being emitted in this scenario + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/auto-mode-facade.test.ts b/jules_branch/apps/server/tests/unit/services/auto-mode-facade.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a2ab4cf9ae0d598863d2cb5a401c59a785fb79e --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/auto-mode-facade.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js'; +import type { Feature } from '@automaker/types'; + +describe('AutoModeServiceFacade', () => { + describe('isFeatureEligibleForAutoMode', () => { + it('should include features with pipeline_* status', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: 'main' }, + { id: '2', status: 'pipeline_testing', branchName: 'main' }, + { id: '3', status: 'in_progress', branchName: 'main' }, + { id: '4', status: 'interrupted', branchName: 'main' }, + { id: '5', status: 'backlog', branchName: 'main' }, + ]; + + const branchName = 'main'; + const primaryBranch = 'main'; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch) + ); + + expect(filtered.map((f) => f.id)).toContain('1'); // ready + expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing + expect(filtered.map((f) => f.id)).toContain('4'); // interrupted + expect(filtered.map((f) => f.id)).toContain('5'); // backlog + expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress + }); + + it('should correctly handle main worktree alignment', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: undefined }, + { id: '2', status: 'ready', branchName: 'main' }, + { id: '3', status: 'ready', branchName: 'other' }, + ]; + + const branchName = null; // main worktree + const primaryBranch = 'main'; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch) + ); + + expect(filtered.map((f) => f.id)).toContain('1'); // no branch + expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch + expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch + }); + + it('should exclude completed, verified, and waiting_approval statuses', () => { + const features: Partial[] = [ + { id: '1', status: 'completed', branchName: 'main' }, + { id: '2', status: 'verified', branchName: 'main' }, + { id: '3', status: 'waiting_approval', branchName: 'main' }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main') + ); + + expect(filtered).toHaveLength(0); + }); + + it('should include pipeline_complete as eligible (still a pipeline status)', () => { + const feature: Partial = { + id: '1', + status: 'pipeline_complete', + branchName: 'main', + }; + + const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode( + feature as Feature, + 'main', + 'main' + ); + + expect(result).toBe(true); + }); + + it('should filter pipeline features by branch in named worktrees', () => { + const features: Partial[] = [ + { id: '1', status: 'pipeline_testing', branchName: 'feature-branch' }, + { id: '2', status: 'pipeline_review', branchName: 'other-branch' }, + { id: '3', status: 'pipeline_deploy', branchName: undefined }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null) + ); + + expect(filtered.map((f) => f.id)).toEqual(['1']); + }); + + it('should handle null primaryBranch for main worktree', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: undefined }, + { id: '2', status: 'ready', branchName: 'main' }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null) + ); + + // When primaryBranch is null, only features with no branchName are included + expect(filtered.map((f) => f.id)).toEqual(['1']); + }); + + it('should include various pipeline_* step IDs as eligible', () => { + const statuses = [ + 'pipeline_step_abc_123', + 'pipeline_code_review', + 'pipeline_step1', + 'pipeline_testing', + 'pipeline_deploy', + ]; + + for (const status of statuses) { + const feature: Partial = { id: '1', status, branchName: 'main' }; + const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode( + feature as Feature, + 'main', + 'main' + ); + expect(result).toBe(true); + } + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts b/jules_branch/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5a660ba62c3278b906bfbff7ec88602b79768ec --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/auto-mode-task-parsing.test.ts @@ -0,0 +1,570 @@ +import { describe, it, expect } from 'vitest'; +import type { ParsedTask } from '@automaker/types'; + +/** + * Test the task parsing logic by reimplementing the parsing functions + * These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine + */ + +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', + }; +} + +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; +} + +describe('Task Parsing', () => { + describe('parseTaskLine', () => { + it('should parse task with file path', () => { + const line = '- [ ] T001: Create user model | File: src/models/user.ts'; + const result = parseTaskLine(line); + expect(result).toEqual({ + id: 'T001', + description: 'Create user model', + filePath: 'src/models/user.ts', + phase: undefined, + status: 'pending', + }); + }); + + it('should parse task without file path', () => { + const line = '- [ ] T002: Setup database connection'; + const result = parseTaskLine(line); + expect(result).toEqual({ + id: 'T002', + description: 'Setup database connection', + phase: undefined, + status: 'pending', + }); + }); + + it('should include phase when provided', () => { + const line = '- [ ] T003: Write tests | File: tests/user.test.ts'; + const result = parseTaskLine(line, 'Phase 1: Foundation'); + expect(result?.phase).toBe('Phase 1: Foundation'); + }); + + it('should return null for invalid line', () => { + expect(parseTaskLine('- [ ] Invalid format')).toBeNull(); + expect(parseTaskLine('Not a task line')).toBeNull(); + expect(parseTaskLine('')).toBeNull(); + }); + + it('should handle multi-word descriptions', () => { + const line = '- [ ] T004: Implement user authentication with JWT tokens | File: src/auth.ts'; + const result = parseTaskLine(line); + expect(result?.description).toBe('Implement user authentication with JWT tokens'); + }); + + it('should trim whitespace from description and file path', () => { + const line = '- [ ] T005: Create API endpoint | File: src/routes/api.ts '; + const result = parseTaskLine(line); + expect(result?.description).toBe('Create API endpoint'); + expect(result?.filePath).toBe('src/routes/api.ts'); + }); + }); + + 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].id).toBe('T001'); + 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 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'); + }); + }); + + describe('spec content generation patterns', () => { + it('should match the expected lite mode output format', () => { + const liteModeOutput = ` +1. **Goal**: Implement user registration +2. **Approach**: Create form component, add validation, connect to API +3. **Files to Touch**: src/components/Register.tsx, src/api/auth.ts +4. **Tasks**: + 1. Create registration form + 2. Add form validation + 3. Connect to backend API +5. **Risks**: Form state management complexity + +[PLAN_GENERATED] Planning outline complete. +`; + expect(liteModeOutput).toContain('[PLAN_GENERATED]'); + expect(liteModeOutput).toContain('Goal'); + expect(liteModeOutput).toContain('Approach'); + }); + + it('should match the expected spec mode output format', () => { + const specModeOutput = ` +1. **Problem**: Users cannot register for accounts + +2. **Solution**: Implement registration form with email/password validation + +3. **Acceptance Criteria**: + - GIVEN a new user, WHEN they fill in valid details, THEN account is created + +4. **Files to Modify**: + | File | Purpose | Action | + |------|---------|--------| + | src/Register.tsx | Registration form | create | + +5. **Implementation Tasks**: +\`\`\`tasks +- [ ] T001: Create registration component | File: src/Register.tsx +- [ ] T002: Add form validation | File: src/Register.tsx +\`\`\` + +6. **Verification**: Manual testing of registration flow + +[SPEC_GENERATED] Please review the specification above. +`; + expect(specModeOutput).toContain('[SPEC_GENERATED]'); + expect(specModeOutput).toContain('```tasks'); + expect(specModeOutput).toContain('T001'); + }); + + it('should match the expected full mode output format', () => { + const fullModeOutput = ` +1. **Problem Statement**: Users need ability to create accounts + +2. **User Story**: As a new user, I want to register, so that I can access the app + +3. **Acceptance Criteria**: + - **Happy Path**: GIVEN valid email, WHEN registering, THEN account created + - **Edge Cases**: GIVEN existing email, WHEN registering, THEN error shown + +4. **Technical Context**: + | Aspect | Value | + |--------|-------| + | Affected Files | src/Register.tsx | + +5. **Non-Goals**: Social login, password recovery + +6. **Implementation Tasks**: +\`\`\`tasks +## Phase 1: Foundation +- [ ] T001: Setup component structure | File: src/Register.tsx + +## Phase 2: Core Implementation +- [ ] T002: Add form logic | File: src/Register.tsx + +## Phase 3: Integration & Testing +- [ ] T003: Connect to API | File: src/api/auth.ts +\`\`\` + +[SPEC_GENERATED] Please review the comprehensive specification above. +`; + expect(fullModeOutput).toContain('Phase 1'); + expect(fullModeOutput).toContain('Phase 2'); + expect(fullModeOutput).toContain('Phase 3'); + expect(fullModeOutput).toContain('[SPEC_GENERATED]'); + }); + }); + + describe('detectSpecFallback - non-Claude model support', () => { + /** + * Reimplementation of detectSpecFallback for testing + * This mirrors the logic in auto-mode-service.ts for detecting specs + * when the [SPEC_GENERATED] marker is missing (common with non-Claude models) + */ + 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; + } + + 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 style)', () => { + 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 +- [ ] T002: Update CSS variables +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Summary section', () => { + const content = ` +## Summary +Adding a new dashboard component. + +- [ ] T001: Create dashboard layout +- [ ] T002: Add widgets +`; + 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 +- [ ] T002: Phase 2 implementation +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation steps', () => { + const content = ` +## Implementation Steps +Follow these steps: + +- [ ] T001: Step one +- [ ] T002: Step two +`; + 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', () => { + const content = 'Just some random text without any structure'; + expect(detectSpecFallback(content)).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); + + const content2 = ` +## acceptance criteria +Lower case section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content2)).toBe(true); + }); + + it('should detect OpenAI-style output without explicit marker', () => { + // Non-Claude models may format specs differently but still have the key elements + const openAIStyleOutput = ` +# Feature Specification: User Authentication + +**Goal**: Allow users to securely log into the application + +**Solution**: Implement JWT-based authentication with refresh tokens + +## Acceptance Criteria +1. Users can log in with email and password +2. Invalid credentials show error message +3. Sessions persist across page refreshes + +## Implementation Tasks +\`\`\`tasks +- [ ] T001: Create auth service | File: src/services/auth.ts +- [ ] T002: Build login component | File: src/components/Login.tsx +- [ ] T003: Add protected routes | File: src/App.tsx +\`\`\` +`; + expect(detectSpecFallback(openAIStyleOutput)).toBe(true); + }); + + it('should detect Gemini-style output without explicit marker', () => { + const geminiStyleOutput = ` +## Overview + +This specification describes the implementation of a user profile page. + +## Technical Context +- Framework: React +- State: Redux + +## Tasks + +- [ ] T001: Create ProfilePage component +- [ ] T002: Add profile API endpoint +- [ ] T003: Style the profile page +`; + expect(detectSpecFallback(geminiStyleOutput)).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts b/jules_branch/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..58cbaeb96810a53ed9309aa0bc57799d8f4ec1fa --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies (hoisted) +vi.mock('../../../../src/services/agent-executor.js'); +vi.mock('../../../../src/lib/settings-helpers.js'); +vi.mock('../../../../src/providers/provider-factory.js'); +vi.mock('../../../../src/lib/sdk-options.js'); +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn((model, fallback) => model || fallback), + DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' }, +})); + +import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js'; +import { AgentExecutor } from '../../../../src/services/agent-executor.js'; +import * as settingsHelpers from '../../../../src/lib/settings-helpers.js'; +import { ProviderFactory } from '../../../../src/providers/provider-factory.js'; +import * as sdkOptions from '../../../../src/lib/sdk-options.js'; + +describe('AutoModeServiceFacade Agent Runner', () => { + let mockAgentExecutor: MockAgentExecutor; + let mockSettingsService: MockSettingsService; + let facade: AutoModeServiceFacade; + + // Type definitions for mocks + interface MockAgentExecutor { + execute: ReturnType; + } + interface MockSettingsService { + getGlobalSettings: ReturnType; + getCredentials: ReturnType; + getProjectSettings: ReturnType; + } + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up the mock for createAutoModeOptions + // Note: Using 'as any' because Options type from SDK is complex and we only need + // the specific fields that are verified in tests (maxTurns, allowedTools, etc.) + vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({ + maxTurns: 123, + allowedTools: ['tool1'], + systemPrompt: 'system-prompt', + } as any); + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue(undefined), + }; + (AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) { + return mockAgentExecutor; + }); + + mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + getProjectSettings: vi.fn().mockResolvedValue({}), + }; + + // Helper to access the private createRunAgentFn via factory creation + facade = AutoModeServiceFacade.create('/project', { + events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any, + settingsService: mockSettingsService, + sharedServices: { + eventBus: { emitAutoModeEvent: vi.fn() } as any, + worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any, + concurrencyManager: { + isRunning: vi.fn().mockReturnValue(false), + getRunningFeature: vi.fn().mockReturnValue(null), + } as any, + } as any, + }); + }); + + it('should resolve provider by providerId and pass to AgentExecutor', async () => { + // 1. Setup mocks + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { id: 'zai-1', name: 'Zai' }; + const mockCredentials = { apiKey: 'test-key' }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: mockCredentials, + resolvedModel: undefined, + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + // 2. Execute + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'model-1', + { + providerId: 'zai-1', + } + ); + + // 3. Verify + expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith( + mockSettingsService, + 'model-1', + 'zai-1', + '[AutoModeFacade]' + ); + + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + credentials: mockCredentials, + model: 'model-1', // Original model ID + }), + expect.any(Object) + ); + }); + + it('should fallback to model-based lookup if providerId is not provided', async () => { + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: { apiKey: 'model-key' }, + resolvedModel: 'resolved-model-1', + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'model-1', + { + // no providerId + } + ); + + expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith( + mockSettingsService, + 'model-1', + undefined, + '[AutoModeFacade]' + ); + + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + }), + expect.any(Object) + ); + }); + + it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => { + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { + id: 'zai-1', + name: 'Zai', + models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }], + }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: { apiKey: 'test-key' }, + resolvedModel: 'claude-3-5-opus', + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'custom-model-1', + { + providerId: 'zai-1', + } + ); + + // Verify createAutoModeOptions was called with the mapped model + expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-3-5-opus', + }) + ); + + // Verify AgentExecutor.execute still gets the original custom model ID + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'custom-model-1', + }), + expect.any(Object) + ); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/claude-usage-service.test.ts b/jules_branch/apps/server/tests/unit/services/claude-usage-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb88381ef985fec32d23fae961be6b9a78cacbf3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -0,0 +1,739 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ClaudeUsageService } from '@/services/claude-usage-service.js'; +import { spawn } from 'child_process'; +import * as pty from 'node-pty'; +import * as os from 'os'; + +vi.mock('child_process'); +vi.mock('node-pty'); +vi.mock('os'); + +describe('claude-usage-service.ts', () => { + let service: ClaudeUsageService; + let mockSpawnProcess: any; + let mockPtyProcess: any; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ClaudeUsageService(); + + // Mock spawn process for isAvailable and Mac commands + mockSpawnProcess = { + on: vi.fn(), + kill: vi.fn(), + stdout: { + on: vi.fn(), + }, + stderr: { + on: vi.fn(), + }, + }; + + // Mock PTY process for Windows + mockPtyProcess = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + }; + + vi.mocked(spawn).mockReturnValue(mockSpawnProcess as any); + vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); + }); + + describe('isAvailable', () => { + it('should return true when Claude CLI is available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + // Simulate successful which/where command + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); // Exit code 0 = found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(true); + expect(spawn).toHaveBeenCalledWith('which', ['claude']); + }); + + it('should return false when Claude CLI is not available', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(1); // Exit code 1 = not found + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it('should return false on error', async () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'error') { + callback(new Error('Command failed')); + } + return mockSpawnProcess; + }); + + const result = await service.isAvailable(); + + expect(result).toBe(false); + }); + + it("should use 'where' command on Windows", async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const ptyService = new ClaudeUsageService(); // Create new service after platform mock + + mockSpawnProcess.on.mockImplementation((event: string, callback: Function) => { + if (event === 'close') { + callback(0); + } + return mockSpawnProcess; + }); + + await ptyService.isAvailable(); + + expect(spawn).toHaveBeenCalledWith('where', ['claude']); + }); + }); + + describe('stripAnsiCodes', () => { + it('should strip ANSI color codes from text', () => { + const service = new ClaudeUsageService(); + const input = '\x1B[31mRed text\x1B[0m Normal text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Red text Normal text'); + }); + + it('should handle text without ANSI codes', () => { + const service = new ClaudeUsageService(); + const input = 'Plain text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Plain text'); + }); + + it('should strip OSC sequences (window title, etc.)', () => { + const service = new ClaudeUsageService(); + // OSC sequence to set window title: ESC ] 0 ; title BEL + const input = '\x1B]0;Claude Code\x07Regular text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Regular text'); + }); + + it('should strip DEC private mode sequences', () => { + const service = new ClaudeUsageService(); + // DEC private mode sequences like ESC[?2026h and ESC[?2026l + const input = '\x1B[?2026lClaude Code\x1B[?2026h more text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Claude Code more text'); + }); + + it('should handle complex terminal output with mixed escape sequences', () => { + const service = new ClaudeUsageService(); + // Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h" + // This contains OSC (set title) and DEC private mode sequences + const input = + '\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 0%used Resets3am'); + }); + + it('should strip single character escape sequences', () => { + const service = new ClaudeUsageService(); + // ESC c is the reset terminal command + const input = '\x1BcReset text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Reset text'); + }); + + it('should remove control characters but preserve newlines and tabs', () => { + const service = new ClaudeUsageService(); + // BEL character (\x07) should be stripped, but the word "Bell" is regular text + const input = 'Line 1\nLine 2\tTabbed\x07 with bell'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + // BEL is stripped, newlines and tabs preserved + expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); + }); + + it('should convert cursor forward (ESC[nC) to spaces', () => { + const service = new ClaudeUsageService(); + // Claude CLI TUI uses ESC[1C instead of space between words + const input = 'Current\x1B[1Csession'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session'); + }); + + it('should handle multi-character cursor forward sequences', () => { + const service = new ClaudeUsageService(); + // ESC[3C = move cursor forward 3 positions = 3 spaces + const input = 'Hello\x1B[3Cworld'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Hello world'); + }); + + it('should handle real Claude CLI TUI output with cursor movement codes', () => { + const service = new ClaudeUsageService(); + // Simulates actual Claude CLI /usage output where words are separated by ESC[1C + const input = + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toContain('Current week (all models)'); + expect(result).toContain('51% used'); + expect(result).toContain('Resets Feb 19 at 3pm (America/Los_Angeles)'); + }); + + it('should parse usage output with cursor movement codes between words', () => { + const service = new ClaudeUsageService(); + // Simulates the full /usage TUI output with ESC[1C between every word + const output = + 'Current\x1B[1Csession\n' + + '\x1B[32m█████████████▌\x1B[0m\x1B[1C27%\x1B[1Cused\n' + + 'Resets\x1B[1C9pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(all\x1B[1Cmodels)\n' + + '\x1B[32m█████████████████████████▌\x1B[0m\x1B[1C51%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C3pm\x1B[1C(America/Los_Angeles)\n' + + '\n' + + 'Current\x1B[1Cweek\x1B[1C(Sonnet\x1B[1Conly)\n' + + '\x1B[32m██▌\x1B[0m\x1B[1C5%\x1B[1Cused\n' + + 'Resets\x1B[1CFeb\x1B[1C19\x1B[1Cat\x1B[1C11pm\x1B[1C(America/Los_Angeles)'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(27); + expect(result.weeklyPercentage).toBe(51); + expect(result.sonnetWeeklyPercentage).toBe(5); + expect(result.weeklyResetText).toContain('Resets Feb 19 at 3pm'); + expect(result.weeklyResetText).not.toContain('America/Los_Angeles'); + }); + }); + + describe('parseResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should parse duration format with hours and minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 2h 15m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T12:15:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse duration format with only minutes', () => { + const service = new ClaudeUsageService(); + const text = 'Resets in 30m'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const expected = new Date('2025-01-15T10:30:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should parse simple time format (AM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 11am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + // Should be today at 11am, or tomorrow if already passed + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(11); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse simple time format (PM)', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 3pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(0); + }); + + it('should parse date format with month, day, and time', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Dec 22 at 8pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(11); // December = 11 + expect(resultDate.getDate()).toBe(22); + expect(resultDate.getHours()).toBe(20); + }); + + it('should parse date format with comma separator', () => { + const service = new ClaudeUsageService(); + const text = 'Resets Jan 15, 3:30pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'weekly'); + + const resultDate = new Date(result); + expect(resultDate.getMonth()).toBe(0); // January = 0 + expect(resultDate.getDate()).toBe(15); + expect(resultDate.getHours()).toBe(15); + expect(resultDate.getMinutes()).toBe(30); + }); + + it('should handle 12am correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12am'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(0); + }); + + it('should handle 12pm correctly', () => { + const service = new ClaudeUsageService(); + const text = 'Resets 12pm'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + + const resultDate = new Date(result); + expect(resultDate.getHours()).toBe(12); + }); + + it('should return default reset time for unparseable text', () => { + const service = new ClaudeUsageService(); + const text = 'Invalid reset text'; + // @ts-expect-error - accessing private method for testing + const result = service.parseResetTime(text, 'session'); + // @ts-expect-error - accessing private method for testing + const defaultResult = service.getDefaultResetTime('session'); + + expect(result).toBe(defaultResult); + }); + }); + + describe('getDefaultResetTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T10:00:00Z')); // Wednesday + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return session default (5 hours from now)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('session'); + + const expected = new Date('2025-01-15T15:00:00Z'); + expect(new Date(result)).toEqual(expected); + }); + + it('should return weekly default (next Monday at noon)', () => { + const service = new ClaudeUsageService(); + // @ts-expect-error - accessing private method for testing + const result = service.getDefaultResetTime('weekly'); + + const resultDate = new Date(result); + // Next Monday from Wednesday should be 5 days away + expect(resultDate.getDay()).toBe(1); // Monday + expect(resultDate.getHours()).toBe(12); + expect(resultDate.getMinutes()).toBe(59); + }); + }); + + describe('parseSection', () => { + it('should parse section with percentage left', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '████████████████░░░░ 65% left', 'Resets in 2h 15m']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(35); // 100 - 65 = 35% used + expect(result.resetText).toBe('Resets in 2h 15m'); + }); + + it('should parse section with percentage used', () => { + const service = new ClaudeUsageService(); + const lines = [ + 'Current week (all models)', + '██████████░░░░░░░░░░ 40% used', + 'Resets Jan 15, 3:30pm', + ]; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current week (all models)', 'weekly'); + + expect(result.percentage).toBe(40); // Already in % used + }); + + it('should return zero percentage when section not found', () => { + const service = new ClaudeUsageService(); + const lines = ['Some other text', 'No matching section']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.percentage).toBe(0); + }); + + it('should strip timezone from reset text', () => { + const service = new ClaudeUsageService(); + const lines = ['Current session', '65% left', 'Resets 3pm (America/Los_Angeles)']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'Current session', 'session'); + + expect(result.resetText).toBe('Resets 3pm'); + expect(result.resetText).not.toContain('America/Los_Angeles'); + }); + + it('should handle case-insensitive section matching', () => { + const service = new ClaudeUsageService(); + const lines = ['CURRENT SESSION', '65% left', 'Resets in 2h']; + // @ts-expect-error - accessing private method for testing + const result = service.parseSection(lines, 'current session', 'session'); + + expect(result.percentage).toBe(35); + }); + }); + + describe('parseUsageOutput', () => { + it('should parse complete usage output', () => { + const service = new ClaudeUsageService(); + const output = ` +Claude Code v1.0.27 + +Current session +████████████████░░░░ 65% left +Resets in 2h 15m + +Current week (all models) +██████████░░░░░░░░░░ 35% left +Resets Jan 15, 3:30pm (America/Los_Angeles) + +Current week (Sonnet only) +████████████████████ 80% left +Resets Jan 15, 3:30pm (America/Los_Angeles) +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(result.weeklyPercentage).toBe(65); // 100 - 35 + expect(result.sonnetWeeklyPercentage).toBe(20); // 100 - 80 + expect(result.sessionResetText).toContain('Resets in 2h 15m'); + expect(result.weeklyResetText).toContain('Resets Jan 15, 3:30pm'); + expect(result.userTimezone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + }); + + it('should handle output with ANSI codes', () => { + const service = new ClaudeUsageService(); + const output = ` +\x1B[1mClaude Code v1.0.27\x1B[0m + +\x1B[1mCurrent session\x1B[0m +\x1B[32m████████████████░░░░\x1B[0m 65% left +Resets in 2h 15m +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(35); + }); + + it('should handle Opus section name', () => { + const service = new ClaudeUsageService(); + const output = ` +Current session +65% left +Resets in 2h + +Current week (all models) +35% left +Resets Jan 15, 3pm + +Current week (Opus) +90% left +Resets Jan 15, 3pm +`; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sonnetWeeklyPercentage).toBe(10); // 100 - 90 + }); + + it('should set default values for missing sections', () => { + const service = new ClaudeUsageService(); + const output = 'Claude Code v1.0.27'; + // @ts-expect-error - accessing private method for testing + const result = service.parseUsageOutput(output); + + expect(result.sessionPercentage).toBe(0); + expect(result.weeklyPercentage).toBe(0); + expect(result.sonnetWeeklyPercentage).toBe(0); + expect(result.sessionTokensUsed).toBe(0); + expect(result.sessionLimit).toBe(0); + expect(result.costUsed).toBeNull(); + expect(result.costLimit).toBeNull(); + expect(result.costCurrency).toBeNull(); + }); + }); + + // Note: executeClaudeUsageCommandMac tests removed - the service now uses PTY for all platforms + // The executeClaudeUsageCommandMac method exists but is dead code (never called) + describe.skip('executeClaudeUsageCommandMac (deprecated - uses PTY now)', () => { + it('should be skipped - service now uses PTY for all platforms', () => { + expect(true).toBe(true); + }); + }); + + describe('executeClaudeUsageCommandPty', () => { + // Note: The service now uses PTY for all platforms, using process.cwd() as the working directory + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('win32'); + }); + + it('should use node-pty and return output', async () => { + const ptyService = new ClaudeUsageService(); + const mockOutput = ` +Current session +65% left +Resets in 2h +`; + + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = ptyService.fetchUsageData(); + + // Simulate data + dataCallback!(mockOutput); + + // Simulate successful exit + exitCallback!({ exitCode: 0 }); + + const result = await promise; + + expect(result.sessionPercentage).toBe(35); + // Service uses process.cwd() for --add-dir + expect(pty.spawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'claude', '--add-dir', process.cwd()], + expect.objectContaining({ + cwd: process.cwd(), + }) + ); + }); + + it('should send escape key after seeing usage data', async () => { + vi.useFakeTimers(); + const ptyService = new ClaudeUsageService(); + + const mockOutput = 'Current session\n65% left'; + + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = ptyService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!(mockOutput); + + // Advance time to trigger escape key sending (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); + + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + // Complete the promise to avoid unhandled rejection + exitCallback!({ exitCode: 0 }); + await promise; + + vi.useRealTimers(); + }); + + it('should handle authentication errors', async () => { + const ptyService = new ClaudeUsageService(); + let dataCallback: Function | undefined; + let exitCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn((callback: Function) => { + exitCallback = callback; + }), + write: vi.fn(), + kill: vi.fn(), + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = ptyService.fetchUsageData(); + + // Send data containing the authentication error pattern the service looks for + dataCallback!('"type":"authentication_error"'); + + // Trigger the exit handler which checks for auth errors + exitCallback!({ exitCode: 1 }); + + await expect(promise).rejects.toThrow( + "Claude CLI authentication issue. Please run 'claude logout' and then 'claude login' in your terminal to refresh permissions." + ); + }); + + it('should handle timeout with no data', async () => { + vi.useFakeTimers(); + const ptyService = new ClaudeUsageService(); + + const mockPty = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + killed: false, + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = ptyService.fetchUsageData(); + + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); + + await expect(promise).rejects.toThrow( + 'The Claude CLI took too long to respond. This can happen if the CLI is waiting for a trust prompt or is otherwise busy.' + ); + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should return data on timeout if data was captured', async () => { + vi.useFakeTimers(); + const ptyService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + killed: false, + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + const promise = ptyService.fetchUsageData(); + + // Simulate receiving usage data + dataCallback!('Current session\n65% left\nResets in 2h'); + + // Advance time past timeout (45 seconds) + vi.advanceTimersByTime(46000); + + // Should resolve with data instead of rejecting + const result = await promise; + expect(result.sessionPercentage).toBe(35); // 100 - 65 + expect(mockPty.kill).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should send SIGTERM after ESC if process does not exit', async () => { + vi.useFakeTimers(); + // Mock Unix platform to test SIGTERM behavior (Windows calls kill() without signal) + vi.mocked(os.platform).mockReturnValue('darwin'); + const ptyService = new ClaudeUsageService(); + + let dataCallback: Function | undefined; + + const mockPty = { + onData: vi.fn((callback: Function) => { + dataCallback = callback; + }), + onExit: vi.fn(), + write: vi.fn(), + kill: vi.fn(), + killed: false, + }; + vi.mocked(pty.spawn).mockReturnValue(mockPty as any); + + ptyService.fetchUsageData(); + + // Simulate seeing usage data + dataCallback!('Current session\n65% left'); + + // Advance 3s to trigger ESC (impl uses 3000ms delay) + vi.advanceTimersByTime(3100); + expect(mockPty.write).toHaveBeenCalledWith('\x1b'); + + // Advance another 2s to trigger SIGTERM fallback + vi.advanceTimersByTime(2100); + expect(mockPty.kill).toHaveBeenCalledWith('SIGTERM'); + + vi.useRealTimers(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/concurrency-manager.test.ts b/jules_branch/apps/server/tests/unit/services/concurrency-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7cce4823f05965994a9e1e6338b3fb0d6636752 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/concurrency-manager.test.ts @@ -0,0 +1,693 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + ConcurrencyManager, + type RunningFeature, + type GetCurrentBranchFn, +} from '@/services/concurrency-manager.js'; + +describe('ConcurrencyManager', () => { + let manager: ConcurrencyManager; + let mockGetCurrentBranch: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + // Default: primary branch is 'main' + mockGetCurrentBranch = vi.fn().mockResolvedValue('main'); + manager = new ConcurrencyManager(mockGetCurrentBranch); + }); + + describe('acquire', () => { + it('should create new entry with leaseCount: 1 on first acquire', () => { + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(result.featureId).toBe('feature-1'); + expect(result.projectPath).toBe('/test/project'); + expect(result.isAutoMode).toBe(true); + expect(result.leaseCount).toBe(1); + expect(result.worktreePath).toBeNull(); + expect(result.branchName).toBeNull(); + expect(result.startTime).toBeDefined(); + expect(result.abortController).toBeInstanceOf(AbortController); + }); + + it('should increment leaseCount when allowReuse is true for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire with allowReuse + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(2); + }); + + it('should throw "already running" when allowReuse is false for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire without allowReuse + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }) + ).toThrow('already running'); + }); + + it('should throw "already running" when allowReuse is explicitly false', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: false, + }) + ).toThrow('already running'); + }); + + it('should use provided abortController', () => { + const customAbortController = new AbortController(); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + abortController: customAbortController, + }); + + expect(result.abortController).toBe(customAbortController); + }); + + it('should return the existing entry when allowReuse is true', () => { + const first = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const second = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // Should be the same object reference + expect(second).toBe(first); + }); + + it('should allow multiple nested acquire calls with allowReuse', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(3); + }); + }); + + describe('release', () => { + it('should decrement leaseCount on release', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.release('feature-1'); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.leaseCount).toBe(1); + }); + + it('should delete entry when leaseCount reaches 0', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should delete entry immediately when force is true regardless of leaseCount', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3, but force should still delete + manager.release('feature-1', { force: true }); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should do nothing when releasing non-existent feature', () => { + // Should not throw + manager.release('non-existent-feature'); + manager.release('non-existent-feature', { force: true }); + }); + + it('should only delete entry after all leases are released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3 + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('isRunning', () => { + it('should return false when feature is not running', () => { + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should return true when feature is running', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(manager.isRunning('feature-1')).toBe(true); + }); + + it('should return false after feature is released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('getRunningFeature', () => { + it('should return undefined for non-existent feature', () => { + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should return the RunningFeature entry', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry).toBeDefined(); + expect(entry?.featureId).toBe('feature-1'); + expect(entry?.projectPath).toBe('/test/project'); + }); + }); + + describe('getRunningCount (project-level)', () => { + it('should return 0 when no features are running', () => { + expect(manager.getRunningCount('/test/project')).toBe(0); + }); + + it('should count features for specific project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(2); + }); + + it('should only count features for the specified project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/project-a', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/project-a')).toBe(2); + expect(manager.getRunningCount('/project-b')).toBe(1); + expect(manager.getRunningCount('/project-c')).toBe(0); + }); + }); + + describe('getRunningCountForWorktree', () => { + it('should return 0 when no features are running', async () => { + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(0); + }); + + it('should count features with null branchName as main worktree', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + // entry.branchName is null by default + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features matching primary branch as main worktree', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features with exact branch match for feature worktrees', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-branch' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + // feature-2 has null branchName + + const featureBranchCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(featureBranchCount).toBe(1); + + const mainWorktreeCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainWorktreeCount).toBe(1); + }); + + it('should respect branch normalization (main is treated as null)', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + // Feature with branchName 'main' should count as main worktree + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + // Feature with branchName null should also count as main worktree + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + const mainCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainCount).toBe(2); + }); + + it('should count only auto-mode features when autoModeOnly is true', async () => { + // Auto-mode feature on main worktree + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Manual feature on main worktree + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + + // Without autoModeOnly: counts both + const totalCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(totalCount).toBe(2); + + // With autoModeOnly: counts only auto-mode features + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(1); + }); + + it('should count only auto-mode features on specific worktree when autoModeOnly is true', async () => { + // Auto-mode feature on feature branch + manager.acquire({ + featureId: 'feature-auto', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-auto', { branchName: 'feature-branch' }); + + // Manual feature on same feature branch + manager.acquire({ + featureId: 'feature-manual', + projectPath: '/test/project', + isAutoMode: false, + }); + manager.updateRunningFeature('feature-manual', { branchName: 'feature-branch' }); + + // Another auto-mode feature on different branch (should not be counted) + manager.acquire({ + featureId: 'feature-other', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-other', { branchName: 'other-branch' }); + + const autoModeCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch', + { autoModeOnly: true } + ); + expect(autoModeCount).toBe(1); + + const totalCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(totalCount).toBe(2); + }); + + it('should return 0 when autoModeOnly is true and only manual features are running', async () => { + manager.acquire({ + featureId: 'feature-manual-1', + projectPath: '/test/project', + isAutoMode: false, + }); + + manager.acquire({ + featureId: 'feature-manual-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + const autoModeCount = await manager.getRunningCountForWorktree('/test/project', null, { + autoModeOnly: true, + }); + expect(autoModeCount).toBe(0); + }); + + it('should filter by both projectPath and branchName', async () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-x' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { branchName: 'feature-x' }); + + const countA = await manager.getRunningCountForWorktree('/project-a', 'feature-x'); + const countB = await manager.getRunningCountForWorktree('/project-b', 'feature-x'); + + expect(countA).toBe(1); + expect(countB).toBe(1); + }); + }); + + describe('getAllRunning', () => { + it('should return empty array when no features are running', () => { + expect(manager.getAllRunning()).toEqual([]); + }); + + it('should return array with all running features', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: false, + }); + + const running = manager.getAllRunning(); + expect(running).toHaveLength(2); + expect(running.map((r) => r.featureId)).toContain('feature-1'); + expect(running.map((r) => r.featureId)).toContain('feature-2'); + }); + + it('should include feature metadata', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4', provider: 'claude' }); + + const running = manager.getAllRunning(); + expect(running[0].model).toBe('claude-sonnet-4'); + expect(running[0].provider).toBe('claude'); + }); + }); + + describe('updateRunningFeature', () => { + it('should update worktreePath and branchName', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + worktreePath: '/worktrees/feature-1', + branchName: 'feature-1-branch', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.worktreePath).toBe('/worktrees/feature-1'); + expect(entry?.branchName).toBe('feature-1-branch'); + }); + + it('should update model and provider', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + model: 'claude-opus-4-5-20251101', + provider: 'claude', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.model).toBe('claude-opus-4-5-20251101'); + expect(entry?.provider).toBe('claude'); + }); + + it('should do nothing for non-existent feature', () => { + // Should not throw + manager.updateRunningFeature('non-existent', { model: 'test' }); + }); + + it('should preserve other properties when updating partial fields', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const original = manager.getRunningFeature('feature-1'); + const originalStartTime = original?.startTime; + + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4' }); + + const updated = manager.getRunningFeature('feature-1'); + expect(updated?.startTime).toBe(originalStartTime); + expect(updated?.projectPath).toBe('/test/project'); + expect(updated?.isAutoMode).toBe(true); + expect(updated?.model).toBe('claude-sonnet-4'); + }); + }); + + describe('edge cases', () => { + it('should handle multiple features for same project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(3); + expect(manager.isRunning('feature-1')).toBe(true); + expect(manager.isRunning('feature-2')).toBe(true); + expect(manager.isRunning('feature-3')).toBe(true); + }); + + it('should handle features across different worktrees', async () => { + // Main worktree feature + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Worktree A feature + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { + worktreePath: '/worktrees/a', + branchName: 'branch-a', + }); + + // Worktree B feature + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-3', { + worktreePath: '/worktrees/b', + branchName: 'branch-b', + }); + + expect(await manager.getRunningCountForWorktree('/test/project', null)).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-a')).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-b')).toBe(1); + expect(manager.getRunningCount('/test/project')).toBe(3); + }); + + it('should return 0 counts and empty arrays for empty state', () => { + expect(manager.getRunningCount('/any/project')).toBe(0); + expect(manager.getAllRunning()).toEqual([]); + expect(manager.isRunning('any-feature')).toBe(false); + expect(manager.getRunningFeature('any-feature')).toBeUndefined(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/cursor-config-service.test.ts b/jules_branch/apps/server/tests/unit/services/cursor-config-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..50f6b86e408007b119b47785b49a57df91112ce9 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/cursor-config-service.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { + getGlobalConfigPath, + getProjectConfigPath, + readGlobalConfig, + writeGlobalConfig, + readProjectConfig, + writeProjectConfig, + deleteProjectConfig, + getEffectivePermissions, + applyProfileToProject, + applyProfileGlobally, + detectProfile, + generateExampleConfig, + hasProjectConfig, + getAvailableProfiles, +} from '@/services/cursor-config-service.js'; + +vi.mock('fs/promises'); +vi.mock('os'); + +describe('cursor-config-service.ts', () => { + const mockHomedir = path.join(path.sep, 'home', 'user'); + const testProjectPath = path.join(path.sep, 'tmp', 'test-project'); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHomedir); + delete process.env.XDG_CONFIG_HOME; + delete process.env.CURSOR_CONFIG_DIR; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('getGlobalConfigPath', () => { + it('should return default path using homedir', () => { + const result = getGlobalConfigPath(); + expect(result).toContain('.cursor'); + expect(result).toContain('cli-config.json'); + }); + + it('should use CURSOR_CONFIG_DIR if set', () => { + const customDir = path.join(path.sep, 'custom', 'cursor', 'config'); + process.env.CURSOR_CONFIG_DIR = customDir; + + const result = getGlobalConfigPath(); + + expect(result).toContain('custom'); + expect(result).toContain('cli-config.json'); + }); + }); + + describe('getProjectConfigPath', () => { + it('should return project config path', () => { + const result = getProjectConfigPath(testProjectPath); + expect(result).toContain('.cursor'); + expect(result).toContain('cli.json'); + }); + }); + + describe('readGlobalConfig', () => { + it('should read and parse global config', async () => { + const mockConfig = { version: 1, permissions: { allow: ['*'], deny: [] } }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readGlobalConfig(); + + expect(result).toEqual(mockConfig); + expect(fs.readFile).toHaveBeenCalledWith(expect.stringContaining('cli-config.json'), 'utf-8'); + }); + + it('should return null if file does not exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await readGlobalConfig(); + + expect(result).toBeNull(); + }); + + it('should throw on other errors', async () => { + const error = new Error('Permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + await expect(readGlobalConfig()).rejects.toThrow('Permission denied'); + }); + }); + + describe('writeGlobalConfig', () => { + it('should create directory and write config', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = { version: 1, permissions: { allow: ['*'], deny: [] } }; + await writeGlobalConfig(config); + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('.cursor'), { + recursive: true, + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('cli-config.json'), + expect.any(String) + ); + }); + }); + + describe('readProjectConfig', () => { + it('should read and parse project config', async () => { + const mockConfig = { version: 1, permissions: { allow: ['read'], deny: ['write'] } }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig)); + + const result = await readProjectConfig(testProjectPath); + + expect(result).toEqual(mockConfig); + expect(fs.readFile).toHaveBeenCalledWith(expect.stringContaining('cli.json'), 'utf-8'); + }); + + it('should return null if file does not exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await readProjectConfig(testProjectPath); + + expect(result).toBeNull(); + }); + + it('should throw on other errors', async () => { + const error = new Error('Read error') as NodeJS.ErrnoException; + error.code = 'EIO'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + await expect(readProjectConfig(testProjectPath)).rejects.toThrow('Read error'); + }); + }); + + describe('writeProjectConfig', () => { + it('should write project config with only permissions', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const config = { version: 1, permissions: { allow: ['read'], deny: ['write'] } }; + await writeProjectConfig(testProjectPath, config); + + expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('.cursor'), { + recursive: true, + }); + + // Check that only permissions is written (no version) + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed).toEqual({ permissions: { allow: ['read'], deny: ['write'] } }); + expect(parsed.version).toBeUndefined(); + }); + }); + + describe('deleteProjectConfig', () => { + it('should delete project config', async () => { + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await deleteProjectConfig(testProjectPath); + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('cli.json')); + }); + + it('should not throw if file does not exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.unlink).mockRejectedValue(error); + + await expect(deleteProjectConfig(testProjectPath)).resolves.not.toThrow(); + }); + + it('should throw on other errors', async () => { + const error = new Error('Permission denied') as NodeJS.ErrnoException; + error.code = 'EACCES'; + vi.mocked(fs.unlink).mockRejectedValue(error); + + await expect(deleteProjectConfig(testProjectPath)).rejects.toThrow('Permission denied'); + }); + }); + + describe('getEffectivePermissions', () => { + it('should return project permissions if available', async () => { + const projectPerms = { allow: ['read'], deny: ['write'] }; + vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ permissions: projectPerms })); + + const result = await getEffectivePermissions(testProjectPath); + + expect(result).toEqual(projectPerms); + }); + + it('should fall back to global permissions', async () => { + const globalPerms = { allow: ['*'], deny: [] }; + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.readFile) + .mockRejectedValueOnce(error) // Project config not found + .mockResolvedValueOnce(JSON.stringify({ permissions: globalPerms })); + + const result = await getEffectivePermissions(testProjectPath); + + expect(result).toEqual(globalPerms); + }); + + it('should return null if no config exists', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await getEffectivePermissions(testProjectPath); + + expect(result).toBeNull(); + }); + + it('should return global permissions if no project path provided', async () => { + const globalPerms = { allow: ['*'], deny: [] }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ permissions: globalPerms })); + + const result = await getEffectivePermissions(); + + expect(result).toEqual(globalPerms); + }); + }); + + describe('applyProfileToProject', () => { + it('should write development profile to project', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await applyProfileToProject(testProjectPath, 'development'); + + expect(fs.writeFile).toHaveBeenCalled(); + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed.permissions).toBeDefined(); + }); + + it('should throw on unknown profile', async () => { + await expect(applyProfileToProject(testProjectPath, 'unknown' as any)).rejects.toThrow( + 'Unknown permission profile: unknown' + ); + }); + }); + + describe('applyProfileGlobally', () => { + it('should write profile to global config', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); // No existing config + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await applyProfileGlobally('strict'); + + expect(fs.writeFile).toHaveBeenCalled(); + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed.version).toBe(1); + expect(parsed.permissions).toBeDefined(); + }); + + it('should preserve existing settings', async () => { + const existingConfig = { version: 1, someOtherSetting: 'value' }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingConfig)); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await applyProfileGlobally('development'); + + const writtenContent = vi.mocked(fs.writeFile).mock.calls[0][1] as string; + const parsed = JSON.parse(writtenContent); + expect(parsed.someOtherSetting).toBe('value'); + }); + + it('should throw on unknown profile', async () => { + await expect(applyProfileGlobally('unknown' as any)).rejects.toThrow( + 'Unknown permission profile: unknown' + ); + }); + }); + + describe('detectProfile', () => { + it('should return null for null permissions', () => { + expect(detectProfile(null)).toBeNull(); + }); + + it('should return custom for non-matching permissions', () => { + const customPerms = { allow: ['some-custom'], deny: ['other-custom'] }; + const result = detectProfile(customPerms); + expect(result).toBe('custom'); + }); + + it('should detect matching profile', () => { + // Get a profile's permissions and verify detection works + const profiles = getAvailableProfiles(); + if (profiles.length > 0) { + const profile = profiles[0]; + const result = detectProfile(profile.permissions); + expect(result).toBe(profile.id); + } + }); + }); + + describe('generateExampleConfig', () => { + it('should generate development profile config by default', () => { + const config = generateExampleConfig(); + const parsed = JSON.parse(config); + + expect(parsed.version).toBe(1); + expect(parsed.permissions).toBeDefined(); + }); + + it('should generate specified profile config', () => { + const config = generateExampleConfig('strict'); + const parsed = JSON.parse(config); + + expect(parsed.version).toBe(1); + expect(parsed.permissions).toBeDefined(); + expect(parsed.permissions.deny).toBeDefined(); + }); + }); + + describe('hasProjectConfig', () => { + it('should return true if config exists', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + + const result = await hasProjectConfig(testProjectPath); + + expect(result).toBe(true); + }); + + it('should return false if config does not exist', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + const result = await hasProjectConfig(testProjectPath); + + expect(result).toBe(false); + }); + }); + + describe('getAvailableProfiles', () => { + it('should return all available profiles', () => { + const profiles = getAvailableProfiles(); + + expect(Array.isArray(profiles)).toBe(true); + expect(profiles.length).toBeGreaterThan(0); + expect(profiles.some((p) => p.id === 'strict')).toBe(true); + expect(profiles.some((p) => p.id === 'development')).toBe(true); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/dev-server-event-types.test.ts b/jules_branch/apps/server/tests/unit/services/dev-server-event-types.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bf4dcbfa9917b3fd78740024381cc77aeadac78 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/dev-server-event-types.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { spawn } from 'child_process'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), +})); + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn(), +})); + +// Mock net +vi.mock('net', () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import net from 'net'; + +describe('DevServerService Event Types', () => { + let testDataDir: string; + let worktreeDir: string; + let mockEmitter: EventEmitter; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`); + worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`); + await fs.mkdir(testDataDir, { recursive: true }); + await fs.mkdir(worktreeDir, { recursive: true }); + + mockEmitter = new EventEmitter(); + + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + }); + + afterEach(async () => { + try { + await fs.rm(testDataDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should emit all required event types during dev server lifecycle', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const emittedEvents: Record = { + 'dev-server:starting': [], + 'dev-server:started': [], + 'dev-server:url-detected': [], + 'dev-server:output': [], + 'dev-server:stopped': [], + }; + + Object.keys(emittedEvents).forEach((type) => { + mockEmitter.on(type, (payload) => emittedEvents[type].push(payload)); + }); + + // 1. Starting & Started + await service.startDevServer(worktreeDir, worktreeDir); + expect(emittedEvents['dev-server:starting'].length).toBe(1); + expect(emittedEvents['dev-server:started'].length).toBe(1); + + // 2. Output & URL Detected + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); + // Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms) + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1); + expect(emittedEvents['dev-server:url-detected'].length).toBe(1); + expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/'); + + // 3. Stopped + await service.stopDevServer(worktreeDir); + expect(emittedEvents['dev-server:stopped'].length).toBe(1); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + mockProcess.pid = 12345; + mockProcess.unref = vi.fn(); + return mockProcess; +} diff --git a/jules_branch/apps/server/tests/unit/services/dev-server-persistence.test.ts b/jules_branch/apps/server/tests/unit/services/dev-server-persistence.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..24dca037fa668acc9c48e6c60ba61979b3056182 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/dev-server-persistence.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { spawn, execSync } from 'child_process'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), +})); + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn(), +})); + +// Mock net +vi.mock('net', () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import net from 'net'; + +describe('DevServerService Persistence & Sync', () => { + let testDataDir: string; + let worktreeDir: string; + let mockEmitter: EventEmitter; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`); + worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`); + await fs.mkdir(testDataDir, { recursive: true }); + await fs.mkdir(worktreeDir, { recursive: true }); + + mockEmitter = new EventEmitter(); + + // Default mock for secureFs.access - return resolved (file exists) + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Default mock for net.createServer - port available + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + // Default mock for execSync - no process on port + vi.mocked(execSync).mockImplementation(() => { + throw new Error('No process found'); + }); + }); + + afterEach(async () => { + try { + await fs.rm(testDataDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should emit dev-server:starting when startDevServer is called', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const events: any[] = []; + mockEmitter.on('dev-server:starting', (payload) => events.push(payload)); + + await service.startDevServer(worktreeDir, worktreeDir); + + expect(events.length).toBe(1); + expect(events[0].worktreePath).toBe(worktreeDir); + }); + + it('should prevent concurrent starts for the same worktree', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + // Delay spawn to simulate long starting time + vi.mocked(spawn).mockImplementation(() => { + const p = createMockProcess(); + // Don't return immediately, simulate some work + return p as any; + }); + + // Start first one (don't await yet if we want to test concurrency) + const promise1 = service.startDevServer(worktreeDir, worktreeDir); + + // Try to start second one immediately + const result2 = await service.startDevServer(worktreeDir, worktreeDir); + + expect(result2.success).toBe(false); + expect(result2.error).toContain('already starting'); + + await promise1; + }); + + it('should persist state to dev-servers.json when started', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + await service.startDevServer(worktreeDir, worktreeDir); + + const statePath = path.join(testDataDir, 'dev-servers.json'); + const exists = await fs + .access(statePath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + const content = await fs.readFile(statePath, 'utf-8'); + const state = JSON.parse(content); + expect(state.length).toBe(1); + expect(state[0].worktreePath).toBe(worktreeDir); + }); + + it('should load state from dev-servers.json on initialize', async () => { + // 1. Create a fake state file + const persistedInfo = [ + { + worktreePath: worktreeDir, + allocatedPort: 3005, + port: 3005, + url: 'http://localhost:3005', + startedAt: new Date().toISOString(), + urlDetected: true, + customCommand: 'npm run dev', + }, + ]; + await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); + + // 2. Mock port as IN USE (so it re-attaches) + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + // Fail to listen = port in use + process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE'))); + }); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + expect(service.isRunning(worktreeDir)).toBe(true); + const info = service.getServerInfo(worktreeDir); + expect(info?.port).toBe(3005); + }); + + it('should prune stale servers from state on initialize if port is available', async () => { + // 1. Create a fake state file + const persistedInfo = [ + { + worktreePath: worktreeDir, + allocatedPort: 3005, + port: 3005, + url: 'http://localhost:3005', + startedAt: new Date().toISOString(), + urlDetected: true, + }, + ]; + await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); + + // 2. Mock port as AVAILABLE (so it prunes) + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + expect(service.isRunning(worktreeDir)).toBe(false); + + // Give it a moment to complete the pruning saveState + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check if file was updated + const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); + const state = JSON.parse(content); + expect(state.length).toBe(0); + }); + + it('should update persisted state when URL is detected', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + await service.startDevServer(worktreeDir, worktreeDir); + + // Simulate output with URL + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n')); + + // Give it a moment to process and save (needs to wait for saveQueue) + await new Promise((resolve) => setTimeout(resolve, 300)); + + const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); + const state = JSON.parse(content); + expect(state[0].url).toBe('http://localhost:5555/'); + expect(state[0].port).toBe(5555); + expect(state[0].urlDetected).toBe(true); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + mockProcess.pid = 12345; + mockProcess.unref = vi.fn(); + return mockProcess; +} diff --git a/jules_branch/apps/server/tests/unit/services/dev-server-service.test.ts b/jules_branch/apps/server/tests/unit/services/dev-server-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e3164508ac212707dc8dac300a2268b3a1861da --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/dev-server-service.test.ts @@ -0,0 +1,900 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), +})); + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn(), +})); + +// Mock net +vi.mock('net', () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import { spawn, execSync } from 'child_process'; +import * as secureFs from '@/lib/secure-fs.js'; +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 }); + + // Default mock for secureFs.access - return resolved (file exists) + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Default mock for net.createServer - port available + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + // Default mock for execSync - no process on port + vi.mocked(execSync).mockImplementation(() => { + throw new Error('No process found'); + }); + }); + + 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 { + // Ignore cleanup errors + } + }); + + describe('getDevServerService', () => { + it('should return a singleton instance', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + + const instance1 = getDevServerService(); + const instance2 = getDevServerService(); + + expect(instance1).toBe(instance2); + }); + }); + + describe('startDevServer', () => { + it('should return error if worktree path does not exist', async () => { + vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('File not found')); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer('/project', '/nonexistent/worktree'); + + expect(result.success).toBe(false); + expect(result.error).toContain('does not exist'); + }); + + it('should return error if no package.json found', async () => { + vi.mocked(secureFs.access).mockImplementation(async (p: any) => { + if (typeof p === 'string' && p.includes('package.json')) { + throw new Error('File not found'); + } + return undefined; + }); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + expect(result.success).toBe(false); + expect(result.error).toContain('No package.json found'); + }); + + it('should detect npm as package manager with package-lock.json', async () => { + vi.mocked(secureFs.access).mockImplementation(async (p: any) => { + const pathStr = typeof p === 'string' ? p : ''; + if (pathStr.includes('bun.lockb')) throw new Error('Not found'); + if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found'); + if (pathStr.includes('yarn.lock')) throw new Error('Not found'); + if (pathStr.includes('package-lock.json')) return undefined; + if (pathStr.includes('package.json')) return undefined; + return undefined; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith('npm', ['run', 'dev'], expect.any(Object)); + }); + + it('should detect yarn as package manager with yarn.lock', async () => { + vi.mocked(secureFs.access).mockImplementation(async (p: any) => { + const pathStr = typeof p === 'string' ? p : ''; + if (pathStr.includes('bun.lockb')) throw new Error('Not found'); + if (pathStr.includes('pnpm-lock.yaml')) throw new Error('Not found'); + if (pathStr.includes('yarn.lock')) return undefined; + if (pathStr.includes('package.json')) return undefined; + return undefined; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith('yarn', ['dev'], expect.any(Object)); + }); + + it('should detect pnpm as package manager with pnpm-lock.yaml', async () => { + vi.mocked(secureFs.access).mockImplementation(async (p: any) => { + const pathStr = typeof p === 'string' ? p : ''; + if (pathStr.includes('bun.lockb')) throw new Error('Not found'); + if (pathStr.includes('pnpm-lock.yaml')) return undefined; + if (pathStr.includes('package.json')) return undefined; + return undefined; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith('pnpm', ['run', 'dev'], expect.any(Object)); + }); + + it('should detect bun as package manager with bun.lockb', async () => { + vi.mocked(secureFs.access).mockImplementation(async (p: any) => { + const pathStr = typeof p === 'string' ? p : ''; + if (pathStr.includes('bun.lockb')) return undefined; + if (pathStr.includes('package.json')) return undefined; + return undefined; + }); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(spawn).toHaveBeenCalledWith('bun', ['run', 'dev'], expect.any(Object)); + }); + + it('should return existing server info if already running', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + // Start first server + const result1 = await service.startDevServer(testDir, testDir); + expect(result1.success).toBe(true); + + // Try to start again - should return existing + const result2 = await service.startDevServer(testDir, testDir); + expect(result2.success).toBe(true); + expect(result2.result?.message).toContain('already running'); + }); + + it('should start dev server successfully', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + expect(result.success).toBe(true); + expect(result.result).toBeDefined(); + expect(result.result?.port).toBeGreaterThanOrEqual(3001); + expect(result.result?.url).toContain('http://localhost:'); + }); + }); + + describe('stopDevServer', () => { + it('should return success if server not found', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.stopDevServer('/nonexistent/path'); + + expect(result.success).toBe(true); + expect(result.result?.message).toContain('already stopped'); + }); + + it('should stop a running server', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + // Start server + await service.startDevServer(testDir, testDir); + + // Stop server + const result = await service.stopDevServer(testDir); + + expect(result.success).toBe(true); + expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + }); + + describe('listDevServers', () => { + it('should return empty list when no servers running', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = service.listDevServers(); + + expect(result.success).toBe(true); + expect(result.result.servers).toEqual([]); + }); + + it('should list running servers', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const result = service.listDevServers(); + + expect(result.success).toBe(true); + expect(result.result.servers.length).toBeGreaterThanOrEqual(1); + expect(result.result.servers[0].worktreePath).toBe(testDir); + }); + }); + + describe('isRunning', () => { + it('should return false for non-running server', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + expect(service.isRunning('/some/path')).toBe(false); + }); + + it('should return true for running server', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + expect(service.isRunning(testDir)).toBe(true); + }); + }); + + describe('getServerInfo', () => { + it('should return undefined for non-running server', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + expect(service.getServerInfo('/some/path')).toBeUndefined(); + }); + + it('should return info for running server', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const info = service.getServerInfo(testDir); + expect(info).toBeDefined(); + expect(info?.worktreePath).toBe(testDir); + expect(info?.port).toBeGreaterThanOrEqual(3001); + }); + }); + + describe('getAllocatedPorts', () => { + it('should return allocated ports', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + const ports = service.getAllocatedPorts(); + expect(ports.length).toBeGreaterThanOrEqual(1); + expect(ports[0]).toBeGreaterThanOrEqual(3001); + }); + }); + + describe('stopAll', () => { + it('should stop all running servers', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + await service.stopAll(); + + expect(service.listDevServers().result.servers).toHaveLength(0); + }); + }); + + describe('URL detection from output', () => { + it('should detect Vite format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + // Start server + await service.startDevServer(testDir, testDir); + + // Simulate Vite output + mockProcess.stdout.emit('data', Buffer.from(' VITE v5.0.0 ready in 123 ms\n')); + mockProcess.stdout.emit('data', Buffer.from(' ➜ Local: http://localhost:5173/\n')); + + // Give it a moment to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:5173/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Next.js format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate Next.js output + mockProcess.stdout.emit( + 'data', + Buffer.from('ready - started server on 0.0.0.0:3000, url: http://localhost:3000\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect generic localhost URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate generic output with URL + mockProcess.stdout.emit('data', Buffer.from('Server running at http://localhost:8080\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8080'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should keep initial URL if no URL detected in output', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + // Simulate output without URL + mockProcess.stdout.emit('data', Buffer.from('Server starting...\n')); + mockProcess.stdout.emit('data', Buffer.from('Ready!\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Should keep the initial allocated URL + expect(serverInfo?.url).toBe(result.result?.url); + expect(serverInfo?.urlDetected).toBe(false); + }); + + it('should detect HTTPS URLs', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate HTTPS dev server + mockProcess.stdout.emit('data', Buffer.from('Server listening at https://localhost:3443\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('https://localhost:3443'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should only detect URL once (not update after first detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // First URL + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const firstUrl = service.getServerInfo(testDir)?.url; + + // Try to emit another URL + mockProcess.stdout.emit('data', Buffer.from('Network: http://192.168.1.1:5173/\n')); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should keep the first detected URL + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe(firstUrl); + expect(serverInfo?.url).toBe('http://localhost:5173/'); + }); + + it('should detect Astro format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Astro uses the same "Local:" prefix as Vite + mockProcess.stdout.emit('data', Buffer.from(' 🚀 astro v4.0.0 started in 200ms\n')); + mockProcess.stdout.emit('data', Buffer.from(' ┃ Local http://localhost:4321/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Astro doesn't use "Local:" with colon, so it should be caught by the localhost URL pattern + expect(serverInfo?.url).toBe('http://localhost:4321/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Remix format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Remix App Server started at http://localhost:3000\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Django format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Starting development server at http://127.0.0.1:8000/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://127.0.0.1:8000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Webpack Dev Server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from(' [webpack-dev-server] Project is running at http://localhost:8080/\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8080/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect PHP built-in server format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit( + 'data', + Buffer.from('Development Server (http://localhost:8000) started\n') + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:8000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "listening on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers only print the port number, not a full URL + mockProcess.stdout.emit('data', Buffer.from('Server listening on port 4000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect "running on port" format (port-only detection)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Application running on port 9000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should strip ANSI escape codes before detecting URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Simulate Vite output with ANSI color codes + mockProcess.stdout.emit( + 'data', + Buffer.from( + ' \x1B[32m➜\x1B[0m \x1B[1mLocal:\x1B[0m \x1B[36mhttp://localhost:5173/\x1B[0m\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:5173/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize 0.0.0.0 to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening at http://0.0.0.0:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should normalize [::] to localhost', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Local: http://[::]:4000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should update port field when detected URL has different port', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + const allocatedPort = result.result?.port; + + // Server starts on a completely different port (ignoring PORT env var) + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:9999/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:9999/'); + expect(serverInfo?.port).toBe(9999); + // The port should be different from what was initially allocated + if (allocatedPort !== 9999) { + expect(serverInfo?.port).not.toBe(allocatedPort); + } + }); + + it('should detect URL from stderr output', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Some servers output URL info to stderr + mockProcess.stderr.emit('data', Buffer.from('Local: http://localhost:3000/\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should not match URLs without a port (non-dev-server URLs)', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + const result = await service.startDevServer(testDir, testDir); + + // CDN/external URLs should not be detected + mockProcess.stdout.emit( + 'data', + Buffer.from('Downloading from https://cdn.example.com/bundle.js\n') + ); + mockProcess.stdout.emit('data', Buffer.from('Fetching https://registry.npmjs.org/package\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + // Should keep the initial allocated URL since external URLs don't match + expect(serverInfo?.url).toBe(result.result?.url); + expect(serverInfo?.urlDetected).toBe(false); + }); + + it('should handle URLs with trailing punctuation', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // URL followed by punctuation + mockProcess.stdout.emit('data', Buffer.from('Server started at http://localhost:3000.\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Express/Fastify format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + mockProcess.stdout.emit('data', Buffer.from('Server listening on http://localhost:3000\n')); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:3000'); + expect(serverInfo?.urlDetected).toBe(true); + }); + + it('should detect Angular CLI format URL', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + + await service.startDevServer(testDir, testDir); + + // Angular CLI output + mockProcess.stderr.emit( + 'data', + Buffer.from( + '** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **\n' + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const serverInfo = service.getServerInfo(testDir); + expect(serverInfo?.url).toBe('http://localhost:4200/'); + expect(serverInfo?.urlDetected).toBe(true); + }); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + mockProcess.pid = 12345; + + // Don't exit immediately - let the test control the lifecycle + return mockProcess; +} diff --git a/jules_branch/apps/server/tests/unit/services/event-hook-service.test.ts b/jules_branch/apps/server/tests/unit/services/event-hook-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fba67664f9b44a4a2ade401482b2432cb05c1ec7 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/event-hook-service.test.ts @@ -0,0 +1,1476 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventHookService } from '../../../src/services/event-hook-service.js'; +import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { EventHistoryService } from '../../../src/services/event-history-service.js'; +import type { FeatureLoader } from '../../../src/services/feature-loader.js'; + +// Mock global fetch for ntfy tests +const originalFetch = global.fetch; + +/** + * Create a mock EventEmitter for testing + */ +function createMockEventEmitter(): EventEmitter & { + subscribers: Set; + simulateEvent: (type: EventType, payload: unknown) => void; +} { + const subscribers = new Set(); + + return { + subscribers, + emit(type: EventType, payload: unknown) { + for (const callback of subscribers) { + callback(type, payload); + } + }, + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + simulateEvent(type: EventType, payload: unknown) { + for (const callback of subscribers) { + callback(type, payload); + } + }, + }; +} + +/** + * Create a mock SettingsService + */ +function createMockSettingsService( + hooks: unknown[] = [], + ntfyEndpoints: unknown[] = [] +): SettingsService { + return { + getGlobalSettings: vi.fn().mockResolvedValue({ + eventHooks: hooks, + ntfyEndpoints: ntfyEndpoints, + }), + } as unknown as SettingsService; +} + +/** + * Create a mock EventHistoryService + */ +function createMockEventHistoryService() { + return { + storeEvent: vi.fn().mockResolvedValue({ id: 'test-event-id' }), + } as unknown as EventHistoryService; +} + +/** + * Create a mock FeatureLoader + */ +function createMockFeatureLoader(features: Record = {}) { + return { + get: vi.fn().mockImplementation((_projectPath: string, featureId: string) => { + return Promise.resolve(features[featureId] || null); + }), + } as unknown as FeatureLoader; +} + +describe('EventHookService', () => { + let service: EventHookService; + let mockEmitter: ReturnType; + let mockSettingsService: ReturnType; + let mockEventHistoryService: ReturnType; + let mockFeatureLoader: ReturnType; + let mockFetch: ReturnType; + + beforeEach(() => { + service = new EventHookService(); + mockEmitter = createMockEventEmitter(); + mockSettingsService = createMockSettingsService(); + mockEventHistoryService = createMockEventHistoryService(); + mockFeatureLoader = createMockFeatureLoader(); + // Set up mock fetch for ntfy tests + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + service.destroy(); + global.fetch = originalFetch; + }); + + describe('initialize', () => { + it('should subscribe to the event emitter', () => { + service.initialize(mockEmitter, mockSettingsService, mockEventHistoryService); + expect(mockEmitter.subscribers.size).toBe(1); + }); + + it('should log initialization', () => { + service.initialize(mockEmitter, mockSettingsService); + expect(mockEmitter.subscribers.size).toBe(1); + }); + }); + + describe('destroy', () => { + it('should unsubscribe from the event emitter', () => { + service.initialize(mockEmitter, mockSettingsService); + expect(mockEmitter.subscribers.size).toBe(1); + + service.destroy(); + expect(mockEmitter.subscribers.size).toBe(0); + }); + }); + + describe('event mapping - auto_mode_feature_complete', () => { + it('should map to feature_success when passes is true', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s', + projectPath: '/test/project', + }); + + // Allow async processing + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + }); + + it('should map to feature_error when passes is false', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.passes).toBe(false); + }); + + it('should NOT populate error field for successful feature completion', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s - auto-verified', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + // Critical: error should NOT contain the success message + expect(storeCall.error).toBeUndefined(); + expect(storeCall.errorType).toBeUndefined(); + }); + + it('should populate error field for failed feature completion', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + // Error field should be populated for error triggers + expect(storeCall.error).toBe('Feature stopped by user'); + }); + + it('should ignore feature complete events without explicit auto execution mode', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + featureId: 'feat-1', + featureName: 'Manual Feature', + passes: true, + message: 'Manually verified', + projectPath: '/test/project', + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + }); + + describe('event mapping - feature:completed', () => { + it('should map manual completion to feature_success', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('feature:completed', { + featureId: 'feat-1', + featureName: 'Manual Feature', + projectPath: '/test/project', + passes: true, + executionMode: 'manual', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + }); + }); + + describe('event mapping - auto_mode_error', () => { + it('should map to feature_error when featureId is present', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Network timeout', + errorType: 'network', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.error).toBe('Network timeout'); + expect(storeCall.errorType).toBe('network'); + }); + + it('should map to auto_mode_error when featureId is not present', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + error: 'System error', + errorType: 'system', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('auto_mode_error'); + expect(storeCall.error).toBe('System error'); + expect(storeCall.errorType).toBe('system'); + }); + }); + + describe('event mapping - auto_mode_idle', () => { + it('should map to auto_mode_complete', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_idle', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('auto_mode_complete'); + }); + }); + + describe('event mapping - feature:created', () => { + it('should trigger feature_created hook', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('feature:created', { + featureId: 'feat-1', + featureName: 'New Feature', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_created'); + expect(storeCall.featureId).toBe('feat-1'); + }); + }); + + describe('event mapping - unhandled events', () => { + it('should ignore auto-mode events with unrecognized types', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_progress', + featureId: 'feat-1', + content: 'Working...', + projectPath: '/test/project', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + + it('should ignore events without a type', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + featureId: 'feat-1', + projectPath: '/test/project', + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + }); + + describe('hook execution', () => { + it('should execute matching enabled hooks for feature_success', async () => { + const hooks = [ + { + id: 'hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Success Hook', + action: { + type: 'shell', + command: 'echo "success"', + }, + }, + { + id: 'hook-2', + enabled: true, + trigger: 'feature_error', + name: 'Error Hook', + action: { + type: 'shell', + command: 'echo "error"', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockSettingsService.getGlobalSettings).toHaveBeenCalled(); + }); + + // The error hook should NOT have been triggered for a success event + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + }); + + it('should NOT execute error hooks when feature completes successfully', async () => { + // This is the key regression test for the bug: + // "Error event hook fired when a feature completes successfully" + const hooks = [ + { + id: 'hook-error', + enabled: true, + trigger: 'feature_error', + name: 'Error Notification', + action: { + type: 'shell', + command: 'echo "ERROR FIRED"', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed in 30s - auto-verified', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Verify the trigger was feature_success, not feature_error + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + // And no error information should be present + expect(storeCall.error).toBeUndefined(); + expect(storeCall.errorType).toBeUndefined(); + }); + }); + + describe('feature name loading', () => { + it('should load feature name from feature loader when not in payload', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Loaded Feature Title' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.featureName).toBe('Loaded Feature Title'); + }); + + it('should fall back to payload featureName when loader fails', async () => { + mockFeatureLoader = createMockFeatureLoader({}); // Empty - no features found + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Fallback Name', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.featureName).toBe('Fallback Name'); + }); + }); + + describe('event mapping - feature_status_changed (non-auto-mode completion)', () => { + it('should trigger feature_success when status changes to verified', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.featureName).toBe('Manual Feature'); + expect(storeCall.passes).toBe(true); + }); + + it('should trigger feature_success when status changes to waiting_approval', async () => { + mockFeatureLoader = createMockFeatureLoader({ + 'feat-1': { title: 'Manual Feature' }, + }); + + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'waiting_approval', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_success'); + expect(storeCall.passes).toBe(true); + expect(storeCall.featureName).toBe('Manual Feature'); + }); + + it('should NOT trigger hooks for non-completion status changes', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'in_progress', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(mockEventHistoryService.storeEvent).not.toHaveBeenCalled(); + }); + + it('should NOT double-fire hooks when auto_mode_feature_complete already fired', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_feature_complete fires (auto-mode path) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Auto Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once (from auto_mode_feature_complete) + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should NOT double-fire hooks when auto_mode_error already fired for feature', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // First: auto_mode_error fires for a feature + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Something failed', + errorType: 'execution', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Then: feature_status_changed fires for the same feature (e.g., reset to backlog) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-1', + projectPath: '/test/project', + status: 'verified', // unlikely after error, but tests the dedup + }); + + // Give it time to process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should still only have been called once + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + it('should fire hooks for different features independently', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Auto-mode completion for feat-1 + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: true, + message: 'Done', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(1); + }); + + // Manual completion for feat-2 (different feature) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'feature_status_changed', + featureId: 'feat-2', + projectPath: '/test/project', + status: 'verified', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalledTimes(2); + }); + + // feat-2 should have triggered feature_success + const secondCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[1][0]; + expect(secondCall.trigger).toBe('feature_success'); + expect(secondCall.featureId).toBe('feat-2'); + }); + }); + + describe('error context for error events', () => { + it('should use payload.error when available for error triggers', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_error', + featureId: 'feat-1', + error: 'Authentication failed', + errorType: 'auth', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.error).toBe('Authentication failed'); + expect(storeCall.errorType).toBe('auth'); + }); + + it('should fall back to payload.message for error field in error triggers', async () => { + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + passes: false, + message: 'Feature stopped by user', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + const storeCall = (mockEventHistoryService.storeEvent as ReturnType).mock + .calls[0][0]; + expect(storeCall.trigger).toBe('feature_error'); + expect(storeCall.error).toBe('Feature stopped by user'); + }); + }); + + describe('ntfy hook execution', () => { + const mockNtfyEndpoint = { + id: 'endpoint-1', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none' as const, + enabled: true, + }; + + it('should execute ntfy hook when endpoint is configured', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Success Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: 'Feature {{featureName}} completed!', + priority: 3, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Title']).toBe('Feature Test Feature completed!'); + }); + + it('should NOT execute ntfy hook when endpoint is not found', async () => { + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Missing Endpoint', + action: { + type: 'ntfy', + endpointId: 'non-existent-endpoint', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should NOT have been called since endpoint doesn't exist + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use ntfy endpoint default values when hook does not override', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'tada', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_error', + name: 'Ntfy Error Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + // No title, tags, or emoji - should use endpoint defaults + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Failed Feature', + passes: false, + message: 'Something went wrong', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Should use default tags and emoji from endpoint + expect(options.headers['Tags']).toBe('tada,default-tag'); + // Click URL gets deep-link query param when feature context is available + expect(options.headers['Click']).toContain('https://default.example.com/board'); + expect(options.headers['Click']).toContain('featureId=feat-1'); + }); + + it('should send ntfy notification with authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithAuth = { + ...mockNtfyEndpoint, + authType: 'token' as const, + token: 'tk_test_token', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Authenticated Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token'); + }); + + it('should handle ntfy notification failure gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook That Will Fail', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + // Should not throw - error should be caught gracefully + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // Event should still be stored even if ntfy hook fails + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + it('should substitute variables in ntfy title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Variables', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-123', + featureName: 'Cool Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/my-project', + projectName: 'my-project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[my-project] Cool Feature'); + expect(options.body).toContain('feat-123'); + }); + + it('should NOT execute ntfy hook when endpoint is disabled', async () => { + const disabledEndpoint = { + ...mockNtfyEndpoint, + enabled: false, + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Disabled Endpoint', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should not be called because endpoint is disabled + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use hook-specific values over endpoint defaults', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'default-emoji', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Overrides', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + tags: 'override-tag', + emoji: 'override-emoji', + clickUrl: 'https://override.example.com', + priority: 5, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Hook values should override endpoint defaults + expect(options.headers['Tags']).toBe('override-emoji,override-tag'); + // Click URL uses hook-specific base URL with deep link params applied + expect(options.headers['Click']).toContain('https://override.example.com/board'); + expect(options.headers['Click']).toContain('featureId=feat-1'); + expect(options.headers['Priority']).toBe('5'); + }); + + describe('click URL deep linking', () => { + it('should generate board URL with featureId query param when feature context is available', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'test-feature-123', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use /board path with featureId query param + expect(clickUrl).toContain('/board'); + expect(clickUrl).toContain('featureId=test-feature-123'); + // Should NOT use the old path-based format + expect(clickUrl).not.toContain('/feature/'); + }); + + it('should generate board URL without featureId when no feature context', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'auto_mode_complete', + name: 'Auto Mode Complete Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_idle', + executionMode: 'auto', + projectPath: '/test/project', + totalFeatures: 5, + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should navigate to board without featureId + expect(clickUrl).toContain('/board'); + expect(clickUrl).not.toContain('featureId='); + }); + + it('should apply deep link params to hook-specific click URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Custom Click URL', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + clickUrl: 'https://custom.example.com/custom-page', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-789', + featureName: 'Custom URL Test', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use the hook-specific click URL with deep link params applied + expect(clickUrl).toContain('https://custom.example.com/board'); + expect(clickUrl).toContain('featureId=feat-789'); + }); + + it('should preserve existing query params when adding featureId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com/board?view=list', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-456', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should preserve existing query params and add featureId + expect(clickUrl).toContain('view=list'); + expect(clickUrl).toContain('featureId=feat-456'); + // Should be properly formatted URL + expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/execution-service.test.ts b/jules_branch/apps/server/tests/unit/services/execution-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..af02bada8bc35fdd903ab6a560344da39a97b113 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/execution-service.test.ts @@ -0,0 +1,2059 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import type { Feature } from '@automaker/types'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + */ +const normalizePath = (p: string): string => path.resolve(p); +import { + ExecutionService, + type RunAgentFn, + type ExecutePipelineFn, + type UpdateFeatureStatusFn, + type LoadFeatureFn, + type GetPlanningPromptPrefixFn, + type SaveFeatureSummaryFn, + type RecordLearningsFn, + type ContextExistsFn, + type ResumeFeatureFn, + type TrackFailureFn, + type SignalPauseFn, + type RecordSuccessFn, +} from '../../../src/services/execution-service.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { + ConcurrencyManager, + RunningFeature, +} from '../../../src/services/concurrency-manager.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import { pipelineService } from '../../../src/services/pipeline-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + getUseClaudeCodeSystemPromptSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; +import { extractSummary } from '../../../src/services/spec-parser.js'; +import { resolveModelString } from '@automaker/model-resolver'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + getPipelineConfig: vi.fn(), + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + }, +})); + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock sdk-options +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +// Mock provider-factory +vi.mock('../../../src/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderNameForModel: vi.fn().mockReturnValue('anthropic'), + }, +})); + +// Mock spec-parser +vi.mock('../../../src/services/spec-parser.js', () => ({ + extractSummary: vi.fn().mockReturnValue('Test summary'), +})); + +// Mock @automaker/utils +vi.mock('@automaker/utils', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + classifyError: vi.fn((error: unknown) => { + const err = error as Error | null; + if (err?.name === 'AbortError' || err?.message?.includes('abort')) { + return { isAbort: true, type: 'abort', message: 'Aborted' }; + } + return { isAbort: false, type: 'unknown', message: err?.message || 'Unknown error' }; + }), + loadContextFiles: vi.fn(), + recordMemoryUsage: vi.fn().mockResolvedValue(undefined), +})); + +describe('execution-service.ts', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockConcurrencyManager: ConcurrencyManager; + let mockWorktreeResolver: WorktreeResolver; + let mockSettingsService: SettingsService | null; + + // Callback mocks + let mockRunAgentFn: RunAgentFn; + let mockExecutePipelineFn: ExecutePipelineFn; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadFeatureFn: LoadFeatureFn; + let mockGetPlanningPromptPrefixFn: GetPlanningPromptPrefixFn; + let mockSaveFeatureSummaryFn: SaveFeatureSummaryFn; + let mockRecordLearningsFn: RecordLearningsFn; + let mockContextExistsFn: ContextExistsFn; + let mockResumeFeatureFn: ResumeFeatureFn; + let mockTrackFailureFn: TrackFailureFn; + let mockSignalPauseFn: SignalPauseFn; + let mockRecordSuccessFn: RecordSuccessFn; + let mockSaveExecutionStateFn: vi.Mock; + let mockLoadContextFilesFn: vi.Mock; + + let service: ExecutionService; + + // Test data + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'backlog', + branchName: 'feature/test-1', + }; + + const createRunningFeature = (featureId: string): RunningFeature => ({ + featureId, + projectPath: '/test/project', + worktreePath: null, + branchName: null, + abortController: new AbortController(), + isAutoMode: false, + startTime: Date.now(), + leaseCount: 1, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + ...createRunningFeature(featureId), + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn(), + isRunning: vi.fn(), + } as unknown as ConcurrencyManager; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + } as unknown as WorktreeResolver; + + mockSettingsService = null; + + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined); + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + mockGetPlanningPromptPrefixFn = vi.fn().mockResolvedValue(''); + mockSaveFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + mockRecordLearningsFn = vi.fn().mockResolvedValue(undefined); + mockContextExistsFn = vi.fn().mockResolvedValue(false); + mockResumeFeatureFn = vi.fn().mockResolvedValue(undefined); + mockTrackFailureFn = vi.fn().mockReturnValue(false); + mockSignalPauseFn = vi.fn(); + mockRecordSuccessFn = vi.fn(); + mockSaveExecutionStateFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ + formattedPrompt: 'test context', + memoryFiles: [], + }); + + // Default mocks for secureFs + // Include tool usage markers to simulate meaningful agent output. + // The execution service checks for '🔧 Tool:' markers and minimum + // output length to determine if the agent did real work. + vi.mocked(secureFs.readFile).mockResolvedValue( + 'Starting implementation...\n\n🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\n\n' + + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts", "old_string": "foo", "new_string": "bar"}\n\n' + + 'Implementation complete. Updated the code as requested.' + ); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Re-setup platform mocks + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + + // Default pipeline config (no steps) + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ version: 1, steps: [] }); + + // Re-setup settings helpers mocks (vi.clearAllMocks clears implementations) + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + } as Awaited>); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(getUseClaudeCodeSystemPromptSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + // Re-setup spec-parser mock + vi.mocked(extractSummary).mockReturnValue('Test summary'); + + // Re-setup model-resolver mock + vi.mocked(resolveModelString).mockReturnValue('claude-sonnet-4'); + + service = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('creates service with all dependencies', () => { + expect(service).toBeInstanceOf(ExecutionService); + }); + + it('accepts null settingsService', () => { + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + null, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + expect(svc).toBeInstanceOf(ExecutionService); + }); + }); + + describe('buildFeaturePrompt', () => { + const taskPrompts = { + implementationInstructions: 'impl instructions', + playwrightVerificationInstructions: 'playwright instructions', + }; + + it('includes feature title and description', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('**Feature ID:** feature-1'); + expect(prompt).toContain('Test description'); + }); + + it('includes specification when present', () => { + const featureWithSpec: Feature = { + ...testFeature, + spec: 'Detailed specification here', + }; + const prompt = service.buildFeaturePrompt(featureWithSpec, taskPrompts); + expect(prompt).toContain('**Specification:**'); + expect(prompt).toContain('Detailed specification here'); + }); + + it('includes acceptance criteria from task prompts', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('impl instructions'); + }); + + it('adds playwright instructions when skipTests is false', () => { + const featureWithTests: Feature = { ...testFeature, skipTests: false }; + const prompt = service.buildFeaturePrompt(featureWithTests, taskPrompts); + expect(prompt).toContain('playwright instructions'); + }); + + it('omits playwright instructions when skipTests is true', () => { + const featureWithoutTests: Feature = { ...testFeature, skipTests: true }; + const prompt = service.buildFeaturePrompt(featureWithoutTests, taskPrompts); + expect(prompt).not.toContain('playwright instructions'); + }); + + it('includes images note when imagePaths present', () => { + const featureWithImages: Feature = { + ...testFeature, + imagePaths: ['/path/to/image.png', { path: '/path/to/image2.jpg', mimeType: 'image/jpeg' }], + }; + const prompt = service.buildFeaturePrompt(featureWithImages, taskPrompts); + expect(prompt).toContain('Context Images Attached:'); + expect(prompt).toContain('2 image(s)'); + }); + + it('extracts title from first line of description', () => { + const featureWithLongDesc: Feature = { + ...testFeature, + description: 'First line title\nRest of description', + }; + const prompt = service.buildFeaturePrompt(featureWithLongDesc, taskPrompts); + expect(prompt).toContain('**Title:** First line title'); + }); + + it('truncates long titles to 60 characters', () => { + const longDescription = 'A'.repeat(100); + const featureWithLongTitle: Feature = { + ...testFeature, + description: longDescription, + }; + const prompt = service.buildFeaturePrompt(featureWithLongTitle, taskPrompts); + expect(prompt).toContain('**Title:** ' + 'A'.repeat(57) + '...'); + }); + }); + + describe('executeFeature', () => { + it('throws if feature not found', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue(null); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'nonexistent'); + + // Error event should be emitted + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ featureId: 'nonexistent' }) + ); + }); + + it('acquires running feature slot', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith( + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + }); + + it('updates status to in_progress before starting', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + }); + + it('emits feature_start event after status update', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_start', + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + + // Verify order: status update happens before event + const statusCallIndex = mockUpdateFeatureStatusFn.mock.invocationCallOrder[0]; + const eventCallIndex = mockEventBus.emitAutoModeEvent.mock.invocationCallOrder[0]; + expect(statusCallIndex).toBeLessThan(eventCallIndex); + }); + + it('runs agent with correct prompt', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project + expect(callArgs[1]).toBe('feature-1'); + expect(callArgs[2]).toContain('Feature Task'); + expect(callArgs[3]).toBeInstanceOf(AbortController); + expect(callArgs[4]).toBe('/test/project'); + // Model (index 6) should be resolved + expect(callArgs[6]).toBe('claude-sonnet-4'); + }); + + it('passes providerId to runAgentFn when present on feature', async () => { + const featureWithProvider: Feature = { + ...testFeature, + providerId: 'zai-provider-1', + }; + vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + const options = callArgs[7]; + expect(options.providerId).toBe('zai-provider-1'); + }); + + it('executes pipeline after agent completes', async () => { + const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }]; + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: pipelineSteps as any, + }); + + await service.executeFeature('/test/project', 'feature-1'); + + // Agent runs first + expect(mockRunAgentFn).toHaveBeenCalled(); + // Then pipeline executes + expect(mockExecutePipelineFn).toHaveBeenCalledWith( + expect.objectContaining({ + projectPath: '/test/project', + featureId: 'feature-1', + steps: pipelineSteps, + }) + ); + }); + + it('updates status to verified on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('updates status to waiting_approval when skipTests is true', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('records success on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordSuccessFn).toHaveBeenCalled(); + }); + + it('releases running feature in finally block', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + + it('redirects to resumeFeature when context exists', async () => { + mockContextExistsFn = vi.fn().mockResolvedValue(true); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', true); + + expect(mockResumeFeatureFn).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + // Should not run agent + expect(mockRunAgentFn).not.toHaveBeenCalled(); + }); + + it('emits feature_complete event on success when isAutoMode is true', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: true, + }) + ); + }); + + it('does not emit feature_complete event on success when isAutoMode is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false, false); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + }); + + describe('executeFeature - approved plan handling', () => { + it('builds continuation prompt for approved plan', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'The approved plan content' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Agent should be called with continuation prompt + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + expect(callArgs[1]).toBe('feature-1'); + expect(callArgs[2]).toContain('The approved plan content'); + }); + + it('recursively calls executeFeature with continuation', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // acquire should be called twice - once for initial, once for recursive + expect(mockConcurrencyManager.acquire).toHaveBeenCalledTimes(2); + // Second call should have allowReuse: true + expect(mockConcurrencyManager.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ allowReuse: true }) + ); + }); + + it('skips contextExists check when continuation prompt provided', async () => { + // Feature has context AND approved plan, but continuation prompt is provided + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + mockContextExistsFn = vi.fn().mockResolvedValue(true); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // resumeFeature should NOT be called even though context exists + // because we're going through approved plan flow + expect(mockResumeFeatureFn).not.toHaveBeenCalled(); + }); + }); + + describe('executeFeature - incomplete task retry', () => { + const createServiceWithMocks = () => { + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('does not re-run agent when feature has no tasks', async () => { + // Feature with no planSpec/tasks - should complete normally with 1 agent call + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('does not re-run agent when all tasks are completed', async () => { + const featureWithCompletedTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithCompletedTasks); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Only the initial agent call + the approved-plan recursive call + // The approved plan triggers recursive executeFeature, so runAgentFn is called once in the inner call + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent when there are pending tasks after initial execution', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + tasksCompleted: 1, + }, + }; + + // After first agent run, loadFeature returns feature with pending tasks + // After second agent run, loadFeature returns feature with all tasks completed + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + { id: 'T003', title: 'Task 3', status: 'completed', description: 'Third task' }, + ], + tasksCompleted: 3, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + // First call: initial feature load at the top of executeFeature + // Second call: after first agent run (check for incomplete tasks) - has pending tasks + // Third call: after second agent run (check for incomplete tasks) - all done + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have called runAgentFn twice: initial + one retry + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry call should contain continuation prompt about incomplete tasks + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('Continue Implementation - Incomplete Tasks'); + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('T003'); + + // Should have emitted a progress event about retrying + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_progress', + expect.objectContaining({ + featureId: 'feature-1', + content: expect.stringContaining('Re-running to complete tasks'), + }) + ); + }); + + it('respects maximum retry attempts', async () => { + const featureAlwaysPending: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + // Always return feature with pending tasks (agent never completes T002) + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureAlwaysPending); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Initial run + 3 retry attempts = 4 total + expect(mockRunAgentFn).toHaveBeenCalledTimes(4); + + // Should still set final status even with incomplete tasks + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('stops retrying when abort signal is triggered', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPendingTasks); + + // Simulate abort after first agent run + let runCount = 0; + const capturedAbortController = { current: null as AbortController | null }; + mockRunAgentFn = vi.fn().mockImplementation((_wd, _fid, _prompt, abortCtrl) => { + capturedAbortController.current = abortCtrl; + runCount++; + if (runCount >= 1) { + // Abort after first run + abortCtrl.abort(); + } + return Promise.resolve(); + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should only have the initial run, then abort prevents retries + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }); + + it('re-runs agent for in_progress tasks (not just pending)', async () => { + const featureWithInProgressTask: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + ], + tasksCompleted: 1, + currentTaskId: 'T002', + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithInProgressTask; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // Should have retried for the in_progress task + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + + // The retry prompt should mention the in_progress task + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + expect(retryCallArgs[2]).toContain('T002'); + expect(retryCallArgs[2]).toContain('in_progress'); + }); + + it('uses planningMode skip and no plan approval for retry runs', async () => { + const featureWithPendingTasks: Feature = { + ...testFeature, + planningMode: 'full', + requirePlanApproval: true, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'pending', description: 'Second task' }, + ], + tasksCompleted: 1, + }, + }; + + const featureAllDone: Feature = { + ...testFeature, + planSpec: { + status: 'approved', + content: 'Plan', + tasks: [ + { id: 'T001', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'T002', title: 'Task 2', status: 'completed', description: 'Second task' }, + ], + tasksCompleted: 2, + }, + }; + + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount <= 2) return featureWithPendingTasks; + return featureAllDone; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, false, undefined, { + continuationPrompt: 'Continue', + _calledInternally: true, + }); + + // The retry agent call should use planningMode: 'skip' and requirePlanApproval: false + const retryCallArgs = mockRunAgentFn.mock.calls[1]; + const retryOptions = retryCallArgs[7]; // options object + expect(retryOptions.planningMode).toBe('skip'); + expect(retryOptions.requirePlanApproval).toBe(false); + }); + }); + + describe('executeFeature - error handling', () => { + it('classifies and emits error event', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ + featureId: 'feature-1', + error: 'Test error', + }) + ); + }); + + it('updates status to backlog on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'backlog' + ); + }); + + it('tracks failure and checks pause', async () => { + const testError = new Error('Rate limit error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockTrackFailureFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Rate limit error', + }) + ); + }); + + it('signals pause when threshold reached', async () => { + const testError = new Error('Quota exceeded'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + mockTrackFailureFn = vi.fn().mockReturnValue(true); // threshold reached + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockSignalPauseFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Quota exceeded', + }) + ); + }); + + it('handles abort signal without error event (emits feature_complete when isAutoMode=true)', async () => { + const abortError = new Error('abort'); + abortError.name = 'AbortError'; + mockRunAgentFn = vi.fn().mockRejectedValue(abortError); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', false, true); + + // Should emit feature_complete with stopped by user + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: false, + message: 'Feature stopped by user', + }) + ); + + // Should NOT emit error event + const errorCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_error'); + expect(errorCalls.length).toBe(0); + }); + + it('handles abort signal without emitting feature_complete when isAutoMode=false', async () => { + const abortError = new Error('abort'); + abortError.name = 'AbortError'; + mockRunAgentFn = vi.fn().mockRejectedValue(abortError); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', false, false); + + // Should NOT emit feature_complete when isAutoMode is false + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + + // Should NOT emit error event (abort is not an error) + const errorCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_error'); + expect(errorCalls.length).toBe(0); + }); + + it('releases running feature even on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + }); + + describe('stopFeature', () => { + it('returns false if feature not running', async () => { + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(false); + }); + + it('aborts running feature', async () => { + const runningFeature = createRunningFeature('feature-1'); + const abortSpy = vi.spyOn(runningFeature.abortController, 'abort'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(true); + expect(abortSpy).toHaveBeenCalled(); + }); + + it('releases running feature with force', async () => { + const runningFeature = createRunningFeature('feature-1'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + await service.stopFeature('feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true }); + }); + + it('immediately updates feature status to interrupted before subprocess terminates', async () => { + const runningFeature = createRunningFeature('feature-1'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + await service.stopFeature('feature-1'); + + // Should update to 'interrupted' immediately so the UI reflects the stop + // without waiting for the CLI subprocess to fully terminate + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'interrupted' + ); + }); + + it('still aborts and releases even if status update fails', async () => { + const runningFeature = createRunningFeature('feature-1'); + const abortSpy = vi.spyOn(runningFeature.abortController, 'abort'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + vi.mocked(mockUpdateFeatureStatusFn).mockRejectedValueOnce(new Error('disk error')); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(true); + expect(abortSpy).toHaveBeenCalled(); + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true }); + }); + }); + + describe('worktree resolution', () => { + it('uses worktree when useWorktrees is true and branch exists', async () => { + await service.executeFeature('/test/project', 'feature-1', true); + + expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1' + ); + }); + + it('emits error and does not execute agent when worktree is not found in worktree mode', async () => { + vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null); + + await service.executeFeature('/test/project', 'feature-1', true); + + expect(mockRunAgentFn).not.toHaveBeenCalled(); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ + featureId: 'feature-1', + error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".', + }) + ); + }); + + it('skips worktree resolution when useWorktrees is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false); + + expect(mockWorktreeResolver.findWorktreeForBranch).not.toHaveBeenCalled(); + }); + }); + + describe('auto-mode integration', () => { + it('saves execution state when isAutoMode is true', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockSaveExecutionStateFn).toHaveBeenCalledWith('/test/project'); + }); + + it('saves execution state after completion in auto-mode', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + // Should be called twice: once at start, once at end + expect(mockSaveExecutionStateFn).toHaveBeenCalledTimes(2); + }); + + it('does not save execution state when isAutoMode is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false, false); + + expect(mockSaveExecutionStateFn).not.toHaveBeenCalled(); + }); + }); + + describe('planning mode', () => { + it('calls getPlanningPromptPrefix for features', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockGetPlanningPromptPrefixFn).toHaveBeenCalledWith(testFeature); + }); + + it('emits planning_started event when planning mode is not skip', async () => { + const featureWithPlanning: Feature = { + ...testFeature, + planningMode: 'lite', + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPlanning); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'planning_started', + expect.objectContaining({ + featureId: 'feature-1', + mode: 'lite', + }) + ); + }); + }); + + describe('summary extraction', () => { + it('extracts and saves summary from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('records learnings from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordLearningsFn).toHaveBeenCalledWith( + '/test/project', + testFeature, + 'Agent output' + ); + }); + + it('handles missing agent output gracefully', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + // Should not throw (isAutoMode=true so event is emitted) + await service.executeFeature('/test/project', 'feature-1', false, true); + + // Feature should still complete successfully + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ passes: true }) + ); + }); + + // Helper to create ExecutionService with a custom loadFeatureFn that returns + // different features on first load (initial) vs subsequent loads (after completion) + const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => { + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + return loadCallCount === 1 ? testFeature : completedFeature; + }); + + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('does not overwrite accumulated summary when feature already has one', async () => { + const featureWithAccumulatedSummary: Feature = { + ...testFeature, + summary: + '### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings', + }; + + const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // saveFeatureSummaryFn should NOT be called because feature already has a summary + // This prevents overwriting accumulated pipeline summaries with just the last step's output + expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled(); + }); + + it('saves summary when feature has no existing summary', async () => { + const featureWithoutSummary: Feature = { + ...testFeature, + summary: undefined, + }; + + vi.mocked(secureFs.readFile).mockResolvedValue( + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\nNew summary' + ); + + const svc = createServiceWithCustomLoad(featureWithoutSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should save the extracted summary since feature has none + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => { + // Empty string is falsy, so it should be treated as "no summary" and a new one should be saved + const featureWithEmptySummary: Feature = { + ...testFeature, + summary: '', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue( + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\nNew summary' + ); + + const svc = createServiceWithCustomLoad(featureWithEmptySummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // Empty string is falsy, so it should save a new summary + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => { + // This is the key scenario: feature went through pipeline steps, accumulated a summary, + // then status changed to 'verified' - we should NOT overwrite the accumulated summary + const featureWithAccumulatedSummary: Feature = { + ...testFeature, + status: 'verified', + summary: + '### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary'); + + const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // The accumulated summary should be preserved + expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled(); + }); + }); + + describe('executeFeature - agent output validation', () => { + // Helper to generate realistic agent output with tool markers + const makeAgentOutput = (toolCount: number, extraText = ''): string => { + let output = 'Starting implementation...\n\n'; + for (let i = 0; i < toolCount; i++) { + output += `🔧 Tool: Edit\nInput: {"file_path": "/src/file${i}.ts", "old_string": "old${i}", "new_string": "new${i}"}\n\n`; + } + output += `Implementation complete. ${extraText}`; + return output; + }; + + const createServiceWithMocks = () => { + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('sets verified when agent output has tool usage and sufficient length', async () => { + const output = makeAgentOutput(3, 'Updated authentication module with new login flow.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('sets waiting_approval when agent output is empty', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output has no tool usage markers', async () => { + // Long output but no tool markers - agent printed text but didn't use tools + const longOutputNoTools = 'I analyzed the codebase and found several issues. '.repeat(20); + vi.mocked(secureFs.readFile).mockResolvedValue(longOutputNoTools); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output has tool markers but is too short', async () => { + // Has a tool marker but total output is under 200 chars + const shortWithTool = '🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\nDone.'; + expect(shortWithTool.trim().length).toBeLessThan(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(shortWithTool); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output file is missing (ENOENT)', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets waiting_approval when agent output is only whitespace', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(' \n\n\t \n '); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('sets verified when output is exactly at the 200 char threshold with tool usage', async () => { + // Create output that's exactly 200 chars trimmed with tool markers + const toolMarker = '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n'; + const padding = 'x'.repeat(200 - toolMarker.length); + const output = toolMarker + padding; + expect(output.trim().length).toBeGreaterThanOrEqual(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('sets waiting_approval when output is 199 chars with tool usage (below threshold)', async () => { + const toolMarker = '🔧 Tool: Read\n'; + const padding = 'x'.repeat(199 - toolMarker.length); + const output = toolMarker + padding; + expect(output.trim().length).toBe(199); + + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('skipTests always takes priority over output validation', async () => { + // Meaningful output with tool usage - would normally be 'verified' + const output = makeAgentOutput(5, 'All changes applied successfully.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + // skipTests=true always means waiting_approval regardless of output quality + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('skipTests with empty output still results in waiting_approval', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = createServiceWithMocks(); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('still records success even when output validation fails', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // recordSuccess should still be called - the agent ran without errors + expect(mockRecordSuccessFn).toHaveBeenCalled(); + }); + + it('still extracts summary when output has content but no tool markers', async () => { + const outputNoTools = 'A '.repeat(150); // > 200 chars but no tool markers + vi.mocked(secureFs.readFile).mockResolvedValue(outputNoTools); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Summary extraction still runs even though status is waiting_approval + expect(extractSummary).toHaveBeenCalledWith(outputNoTools); + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('emits feature_complete with passes=true even when output validation routes to waiting_approval', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + // The agent ran without error - it's still a "pass" from the execution perspective + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ passes: true }) + ); + }); + + it('handles realistic Cursor CLI output that exits quickly', async () => { + // Simulates a Cursor CLI that prints a brief message and exits + const cursorQuickExit = 'Task received. Processing...\nResult: completed successfully.'; + expect(cursorQuickExit.includes('🔧 Tool:')).toBe(false); + + vi.mocked(secureFs.readFile).mockResolvedValue(cursorQuickExit); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // No tool usage = waiting_approval + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('handles realistic Claude SDK output with multiple tool uses', async () => { + // Simulates a Claude SDK agent that does real work + const claudeOutput = + "I'll implement the requested feature.\n\n" + + '🔧 Tool: Read\nInput: {"file_path": "/src/components/App.tsx"}\n\n' + + 'I can see the existing component structure. Let me modify it.\n\n' + + '🔧 Tool: Edit\nInput: {"file_path": "/src/components/App.tsx", "old_string": "const App = () => {", "new_string": "const App: React.FC = () => {"}\n\n' + + '🔧 Tool: Write\nInput: {"file_path": "/src/components/NewFeature.tsx"}\n\n' + + "I've created the new component and updated the existing one. The feature is now implemented with proper TypeScript types."; + + vi.mocked(secureFs.readFile).mockResolvedValue(claudeOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Real work = verified + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('reads agent output from the correct path with utf-8 encoding', async () => { + const output = makeAgentOutput(2, 'Done with changes.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Verify readFile was called with the correct path derived from getFeatureDir + expect(secureFs.readFile).toHaveBeenCalledWith( + '/test/project/.automaker/features/feature-1/agent-output.md', + 'utf-8' + ); + }); + + it('completion message includes auto-verified when status is verified', async () => { + const output = makeAgentOutput(3, 'All changes applied.'); + vi.mocked(secureFs.readFile).mockResolvedValue(output); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + message: expect.stringContaining('auto-verified'), + }) + ); + }); + + it('completion message does NOT include auto-verified when status is waiting_approval', async () => { + // Empty output → waiting_approval + vi.mocked(secureFs.readFile).mockResolvedValue(''); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1', false, true); + + const completeCall = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.find((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCall).toBeDefined(); + expect((completeCall![1] as { message: string }).message).not.toContain('auto-verified'); + }); + + it('uses same agentOutput for both status determination and summary extraction', async () => { + // Specific output that is long enough with tool markers (verified path) + // AND has content for summary extraction + const specificOutput = + '🔧 Tool: Read\nReading file...\n🔧 Tool: Edit\nEditing file...\n' + + 'The implementation is complete. Here is a detailed description of what was done. '.repeat( + 3 + ); + vi.mocked(secureFs.readFile).mockResolvedValue(specificOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Status should be verified (has tools + long enough) + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + // extractSummary should receive the exact same output + expect(extractSummary).toHaveBeenCalledWith(specificOutput); + // recordLearnings should also receive the same output + expect(mockRecordLearningsFn).toHaveBeenCalledWith( + '/test/project', + testFeature, + specificOutput + ); + }); + + it('does not call recordMemoryUsage when output is empty and memoryFiles is empty', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(''); + const { recordMemoryUsage } = await import('@automaker/utils'); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // With empty output and empty memoryFiles, recordMemoryUsage should not be called + expect(recordMemoryUsage).not.toHaveBeenCalled(); + }); + + it('handles output with special unicode characters correctly', async () => { + // Output with various unicode but includes tool markers + const unicodeOutput = + '🔧 Tool: Read\n' + + '🔧 Tool: Edit\n' + + 'Añadiendo función de búsqueda con caracteres especiales: ñ, ü, ö, é, 日本語テスト. ' + + 'Die Änderungen wurden erfolgreich implementiert. '.repeat(3); + vi.mocked(secureFs.readFile).mockResolvedValue(unicodeOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should still detect tool markers and sufficient length + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('treats output with only newlines and spaces around tool marker as insufficient', async () => { + // Has tool marker but surrounded by whitespace, total trimmed < 200 + const sparseOutput = '\n\n 🔧 Tool: Read \n\n'; + expect(sparseOutput.trim().length).toBeLessThan(200); + + vi.mocked(secureFs.readFile).mockResolvedValue(sparseOutput); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('detects tool marker substring correctly (partial match like "🔧 Tools:" does not count)', async () => { + // Output with a similar but not exact marker - "🔧 Tools:" instead of "🔧 Tool:" + const wrongMarker = '🔧 Tools: Read\n🔧 Tools: Edit\n' + 'Implementation done. '.repeat(20); + expect(wrongMarker.includes('🔧 Tool:')).toBe(false); + + vi.mocked(secureFs.readFile).mockResolvedValue(wrongMarker); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // "🔧 Tools:" is not the same as "🔧 Tool:" - should be waiting_approval + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('pipeline merge_conflict status short-circuits before output validation', async () => { + // Set up pipeline that results in merge_conflict + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any, + }); + + // After pipeline, loadFeature returns merge_conflict status + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount === 1) return testFeature; // initial load + // All subsequent loads (task check + pipeline refresh) return merge_conflict + return { ...testFeature, status: 'merge_conflict' }; + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should NOT have called updateFeatureStatusFn with 'verified' or 'waiting_approval' + // because pipeline merge_conflict short-circuits the method + const statusCalls = vi + .mocked(mockUpdateFeatureStatusFn) + .mock.calls.filter((call) => call[2] === 'verified' || call[2] === 'waiting_approval'); + // The only non-in_progress status call should be absent since merge_conflict returns early + expect(statusCalls.length).toBe(0); + }); + + it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => { + // Set up pipeline with steps + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any, + }); + + // Pipeline succeeds, but reading agent output throws after pipeline completes + mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined); + // Simulate an error after pipeline completes by making loadFeature throw + // on the post-pipeline refresh call + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + if (loadCallCount === 1) return testFeature; // initial load + // Second call is the task-retry check, third is the pipeline refresh + if (loadCallCount <= 2) return testFeature; + throw new Error('Unexpected post-pipeline error'); + }); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should set to waiting_approval, NOT backlog, since pipeline completed + const backlogCalls = vi + .mocked(mockUpdateFeatureStatusFn) + .mock.calls.filter((call) => call[2] === 'backlog'); + expect(backlogCalls.length).toBe(0); + + const waitingCalls = vi + .mocked(mockUpdateFeatureStatusFn) + .mock.calls.filter((call) => call[2] === 'waiting_approval'); + expect(waitingCalls.length).toBeGreaterThan(0); + }); + + it('still sets backlog when error occurs before pipeline completes', async () => { + // Set up pipeline with steps + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ + version: 1, + steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any, + }); + + // Pipeline itself throws (e.g., agent error during pipeline step) + mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed')); + + const svc = createServiceWithMocks(); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should still set to backlog since pipeline did NOT complete + const backlogCalls = vi + .mocked(mockUpdateFeatureStatusFn) + .mock.calls.filter((call) => call[2] === 'backlog'); + expect(backlogCalls.length).toBe(1); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/feature-export-service.test.ts b/jules_branch/apps/server/tests/unit/services/feature-export-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ba36d12a21672af2ea585a05e352a53f70790e1 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/feature-export-service.test.ts @@ -0,0 +1,623 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js'; +import type { Feature, FeatureExport } from '@automaker/types'; +import type { FeatureLoader } from '@/services/feature-loader.js'; + +describe('feature-export-service.ts', () => { + let exportService: FeatureExportService; + let mockFeatureLoader: { + get: ReturnType; + getAll: ReturnType; + create: ReturnType; + update: ReturnType; + generateFeatureId: ReturnType; + }; + const testProjectPath = '/test/project'; + + const sampleFeature: Feature = { + id: 'feature-123-abc', + title: 'Test Feature', + category: 'UI', + description: 'A test feature description', + status: 'pending', + priority: 1, + dependencies: ['feature-456'], + descriptionHistory: [ + { + description: 'Initial description', + timestamp: '2024-01-01T00:00:00.000Z', + source: 'initial', + }, + ], + planSpec: { + status: 'generated', + content: 'Plan content', + version: 1, + reviewedByUser: false, + }, + imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'], + textFilePaths: [ + { + id: 'file-1', + path: '/tmp/doc.txt', + filename: 'doc.txt', + mimeType: 'text/plain', + content: 'Some content', + }, + ], + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock FeatureLoader instance + mockFeatureLoader = { + get: vi.fn(), + getAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'), + }; + + // Inject mock via constructor + exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader); + }); + + describe('exportFeatureData', () => { + it('should export feature to JSON format', () => { + const result = exportService.exportFeatureData(sampleFeature, { format: 'json' }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.version).toBe(FEATURE_EXPORT_VERSION); + expect(parsed.feature.id).toBe(sampleFeature.id); + expect(parsed.feature.title).toBe(sampleFeature.title); + expect(parsed.exportedAt).toBeDefined(); + }); + + it('should export feature to YAML format', () => { + const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' }); + + expect(result).toContain('version:'); + expect(result).toContain('feature:'); + expect(result).toContain('Test Feature'); + expect(result).toContain('exportedAt:'); + }); + + it('should exclude description history when option is false', () => { + const result = exportService.exportFeatureData(sampleFeature, { + format: 'json', + includeHistory: false, + }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.descriptionHistory).toBeUndefined(); + }); + + it('should include description history by default', () => { + const result = exportService.exportFeatureData(sampleFeature, { format: 'json' }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.descriptionHistory).toBeDefined(); + expect(parsed.feature.descriptionHistory).toHaveLength(1); + }); + + it('should exclude plan spec when option is false', () => { + const result = exportService.exportFeatureData(sampleFeature, { + format: 'json', + includePlanSpec: false, + }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.planSpec).toBeUndefined(); + }); + + it('should include plan spec by default', () => { + const result = exportService.exportFeatureData(sampleFeature, { format: 'json' }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.planSpec).toBeDefined(); + }); + + it('should include metadata when provided', () => { + const result = exportService.exportFeatureData(sampleFeature, { + format: 'json', + metadata: { projectName: 'TestProject', branch: 'main' }, + }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' }); + }); + + it('should include exportedBy when provided', () => { + const result = exportService.exportFeatureData(sampleFeature, { + format: 'json', + exportedBy: 'test-user', + }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.exportedBy).toBe('test-user'); + }); + + it('should remove transient fields (titleGenerating, error)', () => { + const featureWithTransient: Feature = { + ...sampleFeature, + titleGenerating: true, + error: 'Some error', + }; + + const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' }); + + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.titleGenerating).toBeUndefined(); + expect(parsed.feature.error).toBeUndefined(); + }); + + it('should support compact JSON (prettyPrint: false)', () => { + const prettyResult = exportService.exportFeatureData(sampleFeature, { + format: 'json', + prettyPrint: true, + }); + const compactResult = exportService.exportFeatureData(sampleFeature, { + format: 'json', + prettyPrint: false, + }); + + // Compact should have no newlines/indentation + expect(compactResult).not.toContain('\n'); + // Pretty should have newlines + expect(prettyResult).toContain('\n'); + }); + }); + + describe('exportFeature', () => { + it('should fetch and export feature by ID', async () => { + mockFeatureLoader.get.mockResolvedValue(sampleFeature); + + const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc'); + + expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc'); + const parsed = JSON.parse(result) as FeatureExport; + expect(parsed.feature.id).toBe(sampleFeature.id); + }); + + it('should throw when feature not found', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + + await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow( + 'Feature nonexistent not found' + ); + }); + }); + + describe('exportFeatures', () => { + const features: Feature[] = [ + { ...sampleFeature, id: 'feature-1', category: 'UI' }, + { ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' }, + { ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' }, + ]; + + it('should export all features', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath); + + const parsed = JSON.parse(result); + expect(parsed.count).toBe(3); + expect(parsed.features).toHaveLength(3); + }); + + it('should filter by category', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' }); + + const parsed = JSON.parse(result); + expect(parsed.count).toBe(2); + expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true); + }); + + it('should filter by status', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' }); + + const parsed = JSON.parse(result); + expect(parsed.count).toBe(1); + expect(parsed.features[0].feature.status).toBe('completed'); + }); + + it('should filter by feature IDs', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath, { + featureIds: ['feature-1', 'feature-3'], + }); + + const parsed = JSON.parse(result); + expect(parsed.count).toBe(2); + const ids = parsed.features.map((f: FeatureExport) => f.feature.id); + expect(ids).toContain('feature-1'); + expect(ids).toContain('feature-3'); + expect(ids).not.toContain('feature-2'); + }); + + it('should export to YAML format', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' }); + + expect(result).toContain('version:'); + expect(result).toContain('count:'); + expect(result).toContain('features:'); + }); + + it('should include metadata when provided', async () => { + mockFeatureLoader.getAll.mockResolvedValue(features); + + const result = await exportService.exportFeatures(testProjectPath, { + metadata: { projectName: 'TestProject' }, + }); + + const parsed = JSON.parse(result); + expect(parsed.metadata).toEqual({ projectName: 'TestProject' }); + }); + }); + + describe('parseImportData', () => { + it('should parse valid JSON', () => { + const json = JSON.stringify(sampleFeature); + const result = exportService.parseImportData(json); + + expect(result).toBeDefined(); + expect((result as Feature).id).toBe(sampleFeature.id); + }); + + it('should parse valid YAML', () => { + const yaml = ` +id: feature-yaml-123 +title: YAML Feature +category: Testing +description: A YAML feature +`; + const result = exportService.parseImportData(yaml); + + expect(result).toBeDefined(); + expect((result as Feature).id).toBe('feature-yaml-123'); + expect((result as Feature).title).toBe('YAML Feature'); + }); + + it('should return null for invalid data', () => { + const result = exportService.parseImportData('not valid {json} or yaml: ['); + + expect(result).toBeNull(); + }); + + it('should parse FeatureExport wrapper', () => { + const exportData: FeatureExport = { + version: '1.0.0', + feature: sampleFeature, + exportedAt: new Date().toISOString(), + }; + const json = JSON.stringify(exportData); + + const result = exportService.parseImportData(json) as FeatureExport; + + expect(result.version).toBe('1.0.0'); + expect(result.feature.id).toBe(sampleFeature.id); + }); + }); + + describe('detectFormat', () => { + it('should detect JSON format', () => { + const json = JSON.stringify({ id: 'test' }); + expect(exportService.detectFormat(json)).toBe('json'); + }); + + it('should detect YAML format', () => { + const yaml = ` +id: test +title: Test +`; + expect(exportService.detectFormat(yaml)).toBe('yaml'); + }); + + it('should detect YAML for plain text (YAML is very permissive)', () => { + // YAML parses any plain text as a string, so this is detected as valid YAML + // The actual validation happens in parseImportData which checks for required fields + expect(exportService.detectFormat('not valid {[')).toBe('yaml'); + }); + + it('should handle whitespace', () => { + const json = ' { "id": "test" } '; + expect(exportService.detectFormat(json)).toBe('json'); + }); + }); + + describe('importFeature', () => { + it('should import feature from raw Feature data', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockResolvedValue(sampleFeature); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + }); + + expect(result.success).toBe(true); + expect(result.featureId).toBe(sampleFeature.id); + expect(mockFeatureLoader.create).toHaveBeenCalled(); + }); + + it('should import feature from FeatureExport wrapper', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockResolvedValue(sampleFeature); + + const exportData: FeatureExport = { + version: '1.0.0', + feature: sampleFeature, + exportedAt: new Date().toISOString(), + }; + + const result = await exportService.importFeature(testProjectPath, { + data: exportData, + }); + + expect(result.success).toBe(true); + expect(result.featureId).toBe(sampleFeature.id); + }); + + it('should use custom ID when provided', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + id: data.id!, + })); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + newId: 'custom-id-123', + }); + + expect(result.success).toBe(true); + expect(result.featureId).toBe('custom-id-123'); + }); + + it('should fail when feature exists and overwrite is false', async () => { + mockFeatureLoader.get.mockResolvedValue(sampleFeature); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + overwrite: false, + }); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + `Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.` + ); + }); + + it('should overwrite when overwrite is true', async () => { + mockFeatureLoader.get.mockResolvedValue(sampleFeature); + mockFeatureLoader.update.mockResolvedValue(sampleFeature); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + overwrite: true, + }); + + expect(result.success).toBe(true); + expect(result.wasOverwritten).toBe(true); + expect(mockFeatureLoader.update).toHaveBeenCalled(); + }); + + it('should apply target category override', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + ...data, + })); + + await exportService.importFeature(testProjectPath, { + data: sampleFeature, + targetCategory: 'NewCategory', + }); + + const createCall = mockFeatureLoader.create.mock.calls[0]; + expect(createCall[1].category).toBe('NewCategory'); + }); + + it('should clear branch info when preserveBranchInfo is false', async () => { + const featureWithBranch: Feature = { + ...sampleFeature, + branchName: 'feature/test-branch', + }; + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...featureWithBranch, + ...data, + })); + + await exportService.importFeature(testProjectPath, { + data: featureWithBranch, + preserveBranchInfo: false, + }); + + const createCall = mockFeatureLoader.create.mock.calls[0]; + expect(createCall[1].branchName).toBeUndefined(); + }); + + it('should preserve branch info when preserveBranchInfo is true', async () => { + const featureWithBranch: Feature = { + ...sampleFeature, + branchName: 'feature/test-branch', + }; + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...featureWithBranch, + ...data, + })); + + await exportService.importFeature(testProjectPath, { + data: featureWithBranch, + preserveBranchInfo: true, + }); + + const createCall = mockFeatureLoader.create.mock.calls[0]; + expect(createCall[1].branchName).toBe('feature/test-branch'); + }); + + it('should warn and clear image paths', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockResolvedValue(sampleFeature); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + }); + + expect(result.warnings).toBeDefined(); + expect(result.warnings).toContainEqual(expect.stringContaining('image path')); + const createCall = mockFeatureLoader.create.mock.calls[0]; + expect(createCall[1].imagePaths).toEqual([]); + }); + + it('should warn and clear text file paths', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockResolvedValue(sampleFeature); + + const result = await exportService.importFeature(testProjectPath, { + data: sampleFeature, + }); + + expect(result.warnings).toBeDefined(); + expect(result.warnings).toContainEqual(expect.stringContaining('text file path')); + const createCall = mockFeatureLoader.create.mock.calls[0]; + expect(createCall[1].textFilePaths).toEqual([]); + }); + + it('should fail with validation error for missing required fields', async () => { + const invalidFeature = { + id: 'feature-invalid', + // Missing description, title, and category + } as Feature; + + const result = await exportService.importFeature(testProjectPath, { + data: invalidFeature, + }); + + expect(result.success).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true); + }); + + it('should generate ID when none provided', async () => { + const featureWithoutId = { + title: 'No ID Feature', + category: 'Testing', + description: 'Feature without ID', + } as Feature; + + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...featureWithoutId, + id: data.id!, + })); + + const result = await exportService.importFeature(testProjectPath, { + data: featureWithoutId, + }); + + expect(result.success).toBe(true); + expect(result.featureId).toBe('feature-mock-id'); + }); + }); + + describe('importFeatures', () => { + const bulkExport = { + version: '1.0.0', + exportedAt: new Date().toISOString(), + count: 2, + features: [ + { + version: '1.0.0', + feature: { ...sampleFeature, id: 'feature-1' }, + exportedAt: new Date().toISOString(), + }, + { + version: '1.0.0', + feature: { ...sampleFeature, id: 'feature-2' }, + exportedAt: new Date().toISOString(), + }, + ], + }; + + it('should import multiple features from JSON string', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + id: data.id!, + })); + + const results = await exportService.importFeatures( + testProjectPath, + JSON.stringify(bulkExport) + ); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(true); + }); + + it('should import multiple features from parsed data', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + id: data.id!, + })); + + const results = await exportService.importFeatures(testProjectPath, bulkExport); + + expect(results).toHaveLength(2); + expect(results.every((r) => r.success)).toBe(true); + }); + + it('should apply options to all features', async () => { + mockFeatureLoader.get.mockResolvedValue(null); + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + ...data, + })); + + await exportService.importFeatures(testProjectPath, bulkExport, { + targetCategory: 'ImportedCategory', + }); + + const createCalls = mockFeatureLoader.create.mock.calls; + expect(createCalls[0][1].category).toBe('ImportedCategory'); + expect(createCalls[1][1].category).toBe('ImportedCategory'); + }); + + it('should return error for invalid bulk format', async () => { + const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }'); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data')); + }); + + it('should handle partial failures', async () => { + mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists + + mockFeatureLoader.create.mockImplementation(async (_, data) => ({ + ...sampleFeature, + id: data.id!, + })); + + const results = await exportService.importFeatures(testProjectPath, bulkExport, { + overwrite: false, + }); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(false); // Exists without overwrite + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/feature-loader.test.ts b/jules_branch/apps/server/tests/unit/services/feature-loader.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3d0bbdc68d08dd81f1e1f22d85961d4dea58d22 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/feature-loader.test.ts @@ -0,0 +1,916 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FeatureLoader } from '@/services/feature-loader.js'; +import * as fs from 'fs/promises'; +import path from 'path'; + +vi.mock('fs/promises'); + +describe('feature-loader.ts', () => { + let loader: FeatureLoader; + const testProjectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + loader = new FeatureLoader(); + }); + + describe('getFeaturesDir', () => { + it('should return features directory path', () => { + const result = loader.getFeaturesDir(testProjectPath); + expect(result).toContain('test'); + expect(result).toContain('project'); + expect(result).toContain('.automaker'); + expect(result).toContain('features'); + }); + }); + + describe('getFeatureImagesDir', () => { + it('should return feature images directory path', () => { + const result = loader.getFeatureImagesDir(testProjectPath, 'feature-123'); + expect(result).toContain('features'); + expect(result).toContain('feature-123'); + expect(result).toContain('images'); + }); + }); + + describe('getFeatureDir', () => { + it('should return feature directory path', () => { + const result = loader.getFeatureDir(testProjectPath, 'feature-123'); + expect(result).toContain('features'); + expect(result).toContain('feature-123'); + }); + }); + + describe('getFeatureJsonPath', () => { + it('should return feature.json path', () => { + const result = loader.getFeatureJsonPath(testProjectPath, 'feature-123'); + expect(result).toContain('features'); + expect(result).toContain('feature-123'); + expect(result).toContain('feature.json'); + }); + }); + + describe('getAgentOutputPath', () => { + it('should return agent-output.md path', () => { + const result = loader.getAgentOutputPath(testProjectPath, 'feature-123'); + expect(result).toContain('features'); + expect(result).toContain('feature-123'); + expect(result).toContain('agent-output.md'); + }); + }); + + describe('generateFeatureId', () => { + it('should generate unique feature ID with timestamp', () => { + const id1 = loader.generateFeatureId(); + const id2 = loader.generateFeatureId(); + + expect(id1).toMatch(/^feature-\d+-[a-z0-9]+$/); + expect(id2).toMatch(/^feature-\d+-[a-z0-9]+$/); + expect(id1).not.toBe(id2); + }); + + it("should start with 'feature-'", () => { + const id = loader.generateFeatureId(); + expect(id).toMatch(/^feature-/); + }); + }); + + describe('getAll', () => { + it("should return empty array when features directory doesn't exist", async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + const result = await loader.getAll(testProjectPath); + + expect(result).toEqual([]); + }); + + it('should load all features from feature directories', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + { name: 'file.txt', isDirectory: () => false } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2', + category: 'backend', + description: 'Feature 2', + }) + ); + + const result = await loader.getAll(testProjectPath); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('feature-1'); + expect(result[1].id).toBe('feature-2'); + }); + + it('should skip features without id field', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + category: 'ui', + description: 'Missing ID', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2', + category: 'backend', + description: 'Feature 2', + }) + ); + + const result = await loader.getAll(testProjectPath); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('feature-2'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/WARN.*\[FeatureLoader\]/), + expect.stringContaining("missing required 'id' field") + ); + + consoleSpy.mockRestore(); + }); + + it('should skip features with missing feature.json', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + + vi.mocked(fs.readFile) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2', + category: 'backend', + description: 'Feature 2', + }) + ); + + const result = await loader.getAll(testProjectPath); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('feature-2'); + }); + + it('should handle malformed JSON gracefully', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + vi.mocked(fs.readFile).mockResolvedValue('invalid json{'); + + const result = await loader.getAll(testProjectPath); + + expect(result).toEqual([]); + // With recovery-enabled reads, warnings come from AtomicWriter and FeatureLoader + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/WARN.*\[AtomicWriter\]/), + expect.stringContaining('unavailable') + ); + + consoleSpy.mockRestore(); + }); + + it('should sort features by creation order (timestamp)', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-3', isDirectory: () => true } as any, + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-3000-xyz', + category: 'ui', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + category: 'ui', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + category: 'ui', + }) + ); + + const result = await loader.getAll(testProjectPath); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('feature-1000-abc'); + expect(result[1].id).toBe('feature-2000-def'); + expect(result[2].id).toBe('feature-3000-xyz'); + }); + }); + + describe('get', () => { + it('should return feature by ID', async () => { + const featureData = { + id: 'feature-123', + category: 'ui', + description: 'Test feature', + }; + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(featureData)); + + const result = await loader.get(testProjectPath, 'feature-123'); + + expect(result).toEqual(featureData); + }); + + it("should return null when feature doesn't exist", async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await loader.get(testProjectPath, 'feature-123'); + + expect(result).toBeNull(); + }); + + it('should return null on other errors (with recovery attempt)', async () => { + // With recovery-enabled reads, get() returns null instead of throwing + // because it attempts to recover from backups before giving up + vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); + + const result = await loader.get(testProjectPath, 'feature-123'); + expect(result).toBeNull(); + }); + }); + + describe('create', () => { + it('should create new feature', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const featureData = { + category: 'ui', + description: 'New feature', + }; + + const result = await loader.create(testProjectPath, featureData); + + expect(result).toMatchObject({ + category: 'ui', + description: 'New feature', + id: expect.stringMatching(/^feature-/), + }); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should use provided ID if given', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await loader.create(testProjectPath, { + id: 'custom-id', + category: 'ui', + description: 'Test', + }); + + expect(result.id).toBe('custom-id'); + }); + + it('should set default category if not provided', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await loader.create(testProjectPath, { + description: 'Test', + }); + + expect(result.category).toBe('Uncategorized'); + }); + }); + + describe('update', () => { + it('should update existing feature', async () => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + id: 'feature-123', + category: 'ui', + description: 'Old description', + }) + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const result = await loader.update(testProjectPath, 'feature-123', { + description: 'New description', + }); + + expect(result.description).toBe('New description'); + expect(result.category).toBe('ui'); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it("should throw if feature doesn't exist", async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + await expect(loader.update(testProjectPath, 'feature-123', {})).rejects.toThrow('not found'); + }); + }); + + describe('delete', () => { + it('should delete feature directory', async () => { + vi.mocked(fs.rm).mockResolvedValue(undefined); + + const result = await loader.delete(testProjectPath, 'feature-123'); + + expect(result).toBe(true); + expect(fs.rm).toHaveBeenCalledWith(expect.stringContaining('feature-123'), { + recursive: true, + force: true, + }); + }); + + it('should return false on error', async () => { + vi.mocked(fs.rm).mockRejectedValue(new Error('Permission denied')); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await loader.delete(testProjectPath, 'feature-123'); + + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringMatching(/ERROR.*\[FeatureLoader\]/), + expect.stringContaining('Failed to delete feature'), + expect.objectContaining({ message: 'Permission denied' }) + ); + consoleSpy.mockRestore(); + }); + }); + + describe('getAgentOutput', () => { + it('should return agent output content', async () => { + vi.mocked(fs.readFile).mockResolvedValue('Agent output content'); + + const result = await loader.getAgentOutput(testProjectPath, 'feature-123'); + + expect(result).toBe('Agent output content'); + }); + + it("should return null when file doesn't exist", async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValue(error); + + const result = await loader.getAgentOutput(testProjectPath, 'feature-123'); + + expect(result).toBeNull(); + }); + + it('should throw on other errors', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied')); + + await expect(loader.getAgentOutput(testProjectPath, 'feature-123')).rejects.toThrow( + 'Permission denied' + ); + }); + }); + + describe('saveAgentOutput', () => { + it('should save agent output to file', async () => { + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await loader.saveAgentOutput(testProjectPath, 'feature-123', 'Output content'); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('agent-output.md'), + 'Output content', + 'utf-8' + ); + }); + }); + + describe('deleteAgentOutput', () => { + it('should delete agent output file', async () => { + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + await loader.deleteAgentOutput(testProjectPath, 'feature-123'); + + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('agent-output.md')); + }); + + it('should handle missing file gracefully', async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.unlink).mockRejectedValue(error); + + // Should not throw + await expect( + loader.deleteAgentOutput(testProjectPath, 'feature-123') + ).resolves.toBeUndefined(); + }); + + it('should throw on other errors', async () => { + vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); + + await expect(loader.deleteAgentOutput(testProjectPath, 'feature-123')).rejects.toThrow( + 'Permission denied' + ); + }); + }); + + describe('findByTitle', () => { + it('should find feature by exact title match (case-insensitive)', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Logout Feature', + category: 'auth', + description: 'Logout implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + expect(result?.title).toBe('Login Feature'); + }); + + it('should return null when title is not found', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'Login Feature', + category: 'auth', + description: 'Login implementation', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature'); + + expect(result).toBeNull(); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findByTitle(testProjectPath, ''); + const result2 = await loader.findByTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should skip features without titles', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + // no title + category: 'auth', + description: 'Login implementation', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Login Feature', + category: 'auth', + description: 'Another login', + }) + ); + + const result = await loader.findByTitle(testProjectPath, 'Login Feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-2000-def'); + }); + }); + + describe('findDuplicateTitle', () => { + it('should find duplicate title', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, 'my feature'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should exclude specified feature ID from duplicate check', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should not find duplicate when excluding the feature that has the title + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-1000-abc' + ); + + expect(result).toBeNull(); + }); + + it('should find duplicate when title exists on different feature', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature 1', + }) + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-2000-def', + title: 'Other Feature', + category: 'ui', + description: 'Feature 2', + }) + ); + + // Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def + const result = await loader.findDuplicateTitle( + testProjectPath, + 'My Feature', + 'feature-2000-def' + ); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + + it('should return null for empty or whitespace title', async () => { + const result1 = await loader.findDuplicateTitle(testProjectPath, ''); + const result2 = await loader.findDuplicateTitle(testProjectPath, ' '); + + expect(result1).toBeNull(); + expect(result2).toBeNull(); + }); + + it('should handle titles with leading/trailing whitespace', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(fs.readdir).mockResolvedValue([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + + vi.mocked(fs.readFile).mockResolvedValueOnce( + JSON.stringify({ + id: 'feature-1000-abc', + title: 'My Feature', + category: 'ui', + description: 'Feature description', + }) + ); + + const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature '); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('feature-1000-abc'); + }); + }); + + describe('syncFeatureToAppSpec', () => { + const sampleAppSpec = ` + + Test Project + + Testing + + + + Existing Feature + Already implemented + + +`; + + const appSpecWithoutFeatures = ` + + Test Project + + Testing + +`; + + it('should add feature to app_spec.txt', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('app_spec.txt'), + expect.stringContaining('New Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('A new feature description'), + 'utf-8' + ); + }); + + it('should add feature with file locations', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature With Locations', + category: 'backend', + description: 'Feature with file locations', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [ + 'src/feature.ts', + 'src/utils/helper.ts', + ]); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/feature.ts'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('src/utils/helper.ts'), + 'utf-8' + ); + }); + + it('should return false when app_spec.txt does not exist', async () => { + const error: any = new Error('File not found'); + error.code = 'ENOENT'; + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should return false when feature already exists (duplicate)', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + + const feature = { + id: 'feature-5678-xyz', + title: 'Existing Feature', // Same name as existing feature + category: 'ui', + description: 'Different description', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(false); + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should use feature ID as fallback name when title is missing', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + category: 'ui', + description: 'Feature without title', + // No title property + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Feature: feature-1234-abc'), + 'utf-8' + ); + }); + + it('should handle app_spec without implemented_features section', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'First Feature', + category: 'ui', + description: 'First implemented feature', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining(''), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('First Feature'), + 'utf-8' + ); + }); + + it('should throw on non-ENOENT file read errors', async () => { + const error = new Error('Permission denied'); + vi.mocked(fs.readFile).mockRejectedValueOnce(error); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature description', + }; + + await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow( + 'Permission denied' + ); + }); + + it('should preserve existing features when adding a new one', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'New Feature', + category: 'ui', + description: 'A new feature', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature); + + // Verify both old and new features are in the output + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('Existing Feature'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('New Feature'), + 'utf-8' + ); + }); + + it('should escape special characters in feature name and description', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature with & "chars"', + category: 'ui', + description: 'Description with & "quotes"', + }; + + const result = await loader.syncFeatureToAppSpec(testProjectPath, feature); + + expect(result).toBe(true); + // The XML should have escaped characters + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('<special>'), + 'utf-8' + ); + expect(fs.writeFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining('&'), + 'utf-8' + ); + }); + + it('should not add empty file_locations array', async () => { + vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + const feature = { + id: 'feature-1234-abc', + title: 'Feature Without Locations', + category: 'ui', + description: 'No file locations', + }; + + await loader.syncFeatureToAppSpec(testProjectPath, feature, []); + + // File locations should not be included when array is empty + const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; + const writtenContent = writeCall[1] as string; + + // Count occurrences of file_locations - should only have the one from Existing Feature if any + // The new feature should not add file_locations + expect(writtenContent).toContain('Feature Without Locations'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/feature-state-manager.test.ts b/jules_branch/apps/server/tests/unit/services/feature-state-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0c3ea4b01bc5517ce16ef9d25ad79e056b1f64d --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -0,0 +1,1539 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import path from 'path'; +import { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { Feature } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; + +const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n'; +const PIPELINE_SUMMARY_HEADER_PREFIX = '### '; +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'; +import { pipelineService } from '@/services/pipeline-service.js'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + * Uses path.normalize (not path.resolve) to match path.join behavior in production code. + */ +const normalizePath = (p: string): string => path.normalize(p); + +// Mock dependencies +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + atomicWriteJson: vi.fn(), + readJsonWithRecovery: vi.fn(), + logRecoveryWarning: vi.fn(), + }; +}); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), + getFeaturesDir: vi.fn(), +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: vi.fn(), + })), +})); + +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + +describe('FeatureStateManager', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + let mockFeatureLoader: FeatureLoader; + + const mockFeature: Feature = { + id: 'feature-123', + name: 'Test Feature', + title: 'Test Feature Title', + description: 'A test feature', + status: 'pending', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Default mocks + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (getFeaturesDir as Mock).mockReturnValue('/project/.automaker/features'); + }); + + describe('loadFeature', () => { + it('should load feature from disk', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ data: mockFeature, recovered: false }); + + const feature = await manager.loadFeature('/project', 'feature-123'); + + expect(feature).toEqual(mockFeature); + expect(getFeatureDir).toHaveBeenCalledWith('/project', 'feature-123'); + expect(readJsonWithRecovery).toHaveBeenCalledWith( + normalizePath('/project/.automaker/features/feature-123/feature.json'), + null, + expect.objectContaining({ autoRestore: true }) + ); + }); + + it('should return null if feature does not exist', async () => { + (readJsonWithRecovery as Mock).mockRejectedValue(new Error('ENOENT')); + + const feature = await manager.loadFeature('/project', 'non-existent'); + + expect(feature).toBeNull(); + }); + + it('should return null if feature JSON is invalid', async () => { + // readJsonWithRecovery returns null as the default value when JSON is invalid + (readJsonWithRecovery as Mock).mockResolvedValue({ data: null, recovered: false }); + + const feature = await manager.loadFeature('/project', 'feature-123'); + + expect(feature).toBeNull(); + }); + }); + + describe('updateFeatureStatus', () => { + it('should update feature status and persist to disk', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'in_progress'); + + expect(atomicWriteJson).toHaveBeenCalled(); + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('in_progress'); + expect(savedFeature.updatedAt).toBeDefined(); + }); + + it('should set justFinishedAt when status is waiting_approval', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.justFinishedAt).toBeDefined(); + }); + + it('should clear justFinishedAt when status is not waiting_approval', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, justFinishedAt: '2024-01-01T00:00:00Z' }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'in_progress'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.justFinishedAt).toBeUndefined(); + }); + + it('should finalize in_progress tasks but keep pending tasks when moving to waiting_approval', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Already completed tasks stay completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + // pending tasks should remain pending (never started) + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal actual completed tasks count + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); + }); + + it('should finalize tasks when moving to verified status', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + status: 'in_progress', + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + currentTaskId: 'task-2', + tasksCompleted: 1, + tasks: [ + { id: 'task-1', title: 'Task 1', status: 'completed', description: 'First task' }, + { id: 'task-2', title: 'Task 2', status: 'in_progress', description: 'Second task' }, + { id: 'task-3', title: 'Task 3', status: 'pending', description: 'Third task' }, + ], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Already completed tasks stay completed + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + // in_progress tasks should be finalized to completed + expect(savedFeature.planSpec?.tasks?.[1].status).toBe('completed'); + // pending tasks should remain pending (never started) + expect(savedFeature.planSpec?.tasks?.[2].status).toBe('pending'); + // currentTaskId should be cleared + expect(savedFeature.planSpec?.currentTaskId).toBeUndefined(); + // tasksCompleted should equal actual completed tasks count + expect(savedFeature.planSpec?.tasksCompleted).toBe(2); + // justFinishedAt should be cleared for verified + expect(savedFeature.justFinishedAt).toBeUndefined(); + }); + + it('should handle waiting_approval without planSpec tasks gracefully', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.status).toBe('waiting_approval'); + expect(savedFeature.justFinishedAt).toBeDefined(); + }); + + it('should create notification for waiting_approval status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature }, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + featureId: 'feature-123', + }) + ); + }); + + it('should use feature.title as notification title for waiting_approval status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'My Awesome Feature Title', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + + 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 use feature.title as notification title for verified status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'My Awesome Feature Title', + message: 'Feature Verified', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + + 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 () => { + (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 () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step_1' }, + recovered: false, + source: 'main', + }); + + await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown'); + + // Should NOT call atomicWriteJson because pipeline status is preserved + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(isPipelineStatus('pipeline_step_1')).toBe(true); + }); + + it('should preserve pipeline_complete status', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_complete' }, + recovered: false, + source: 'main', + }); + + await manager.markFeatureInterrupted('/project', 'feature-123'); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + + it('should handle feature not found', async () => { + (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 preserve pipeline_* statuses during reset', async () => { + const pipelineFeature: Feature = { + ...mockFeature, + status: 'pipeline_testing', + planSpec: { status: 'approved', version: 1, reviewedByUser: true }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: pipelineFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + // Status should NOT be changed, but needsUpdate might be true if other things reset + // In this case, nothing else should be reset, so atomicWriteJson shouldn't be called + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + + 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(); + }); + + it('should accumulate summary with step header for pipeline features', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'First step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output` + ); + }); + + it('should append subsequent pipeline step summaries with separator', async () => { + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output` + ); + }); + + it('should normalize existing non-phase summary before appending pipeline step summary', async () => { + const existingSummary = 'Implemented authentication and settings management.'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes` + ); + }); + + it('should use fallback step name when pipeline step not found', async () => { + (pipelineService.getStep as Mock).mockResolvedValue(null); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output` + ); + }); + + it('should overwrite summary for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'New summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('New summary'); + }); + + it('should emit full accumulated summary for pipeline features', async () => { + const existingSummary = '### Code Review\n\nFirst step output'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output'); + + const expectedSummary = + '### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output'; + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'feature-123', + projectPath: '/project', + summary: expectedSummary, + }); + }); + + it('should skip accumulation for pipeline features when summary is empty', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: '' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Test output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Empty string is falsy, so should start fresh + expect(savedFeature.summary).toBe('### Testing\n\nTest output'); + }); + + it('should skip persistence when incoming summary is only whitespace', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t '); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + + it('should accumulate three pipeline steps in chronological order', async () => { + // Step 1: Code Review + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings'); + const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(afterStep1.summary).toBe('### Code Review\n\nReview findings'); + + // Step 2: Testing (summary from step 1 exists) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass'); + const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Step 3: Refinement (summaries from steps 1+2 exist) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished'); + const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Verify the full accumulated summary has all three steps in order + expect(afterStep3.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished` + ); + }); + + it('should replace existing step summary if called again for the same step', async () => { + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'feature-123', + 'Second review attempt (success)' + ); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should REPLACE "First review attempt" with "Second review attempt (success)" + // and NOT append it as a new section + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)` + ); + // Ensure it didn't duplicate the separator or header + expect( + savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g')) + ?.length + ).toBe(1); + expect( + savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length + ).toBe(1); + }); + + it('should replace last step summary without trailing separator', async () => { + // Test case: replacing the last step which has no separator after it + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass` + ); + }); + + it('should replace first step summary with separator after it', async () => { + // Test case: replacing the first step which has a separator after it + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass` + ); + }); + + it('should not match step header appearing in body text, only at section boundaries', async () => { + // Test case: body text contains "### Testing" which should NOT be matched + // Only headers at actual section boundaries should be replaced + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // The section replacement should only replace the actual Testing section at the boundary + // NOT the "### Testing" that appears in the body text + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results` + ); + }); + + it('should handle step name with special regex characters safely', async () => { + // Test case: step name contains characters that would break regex + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt` + ); + }); + + it('should handle step name with brackets safely', async () => { + // Test case: step name contains array-like syntax [0] + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt` + ); + }); + + it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => { + (pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => { + throw new Error('Config not found'); + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should use fallback: capitalize each word in the status suffix + expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`); + }); + + it('should handle pipelineService.getStep throwing an error gracefully', async () => { + (pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error')); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should use fallback: capitalize each word in the status suffix + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output` + ); + }); + + it('should handle summary content with markdown formatting', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const markdownSummary = + '## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```'; + + await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}` + ); + }); + + it('should persist before emitting event for pipeline summary accumulation', async () => { + const callOrder: string[] = []; + const existingSummary = '### Code Review\n\nFirst step output'; + + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + 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', 'Test results'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); + }); + + 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 update task status and summary 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: '' }], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateTaskStatus( + '/project', + 'feature-123', + 'task-1', + 'completed', + 'Task finished successfully' + ); + + // Verify persisted + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully'); + + // 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', + summary: 'Task finished successfully', + 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']); + }); + }); + + describe('handleAutoModeEventError', () => { + let subscribeCallback: (type: string, payload: unknown) => void; + + beforeEach(() => { + // Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0] + // subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0] + const mockCalls = (mockEvents.subscribe as Mock).mock.calls; + if (mockCalls.length > 0 && mockCalls[0].length > 0) { + subscribeCallback = mockCalls[0][0] as typeof subscribeCallback; + } + }); + + it('should ignore events with no type', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', {}); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should ignore non-error events', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should create auto_mode_error notification with gesture name as title when no featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Something went wrong', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + title: 'Auto Mode Error', + message: 'Something went wrong', + projectPath: '/project', + }) + ); + }); + + it('should use error field instead of message when available', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Some message', + error: 'The actual error', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + message: 'The actual error', + }) + ); + }); + + it('should use feature title as notification title for feature error with featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, title: 'Login Page Feature' }, + recovered: false, + source: 'main', + }); + + subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: false, + featureId: 'feature-123', + error: 'Build failed', + projectPath: '/project', + }); + + // Wait for async handleAutoModeEventError to complete + await vi.waitFor(() => { + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_error', + title: 'Login Page Feature', + message: 'Feature Failed: Build failed', + featureId: 'feature-123', + }) + ); + }); + }); + + it('should ignore auto_mode_feature_complete without passes=false', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle missing projectPath gracefully', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error occurred', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle notification service failures gracefully', async () => { + (getNotificationService as Mock).mockImplementation(() => { + throw new Error('Service unavailable'); + }); + + // Should not throw - the callback returns void so we just call it and wait for async work + subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error', + projectPath: '/project', + }); + + // Give async handleAutoModeEventError time to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + describe('destroy', () => { + it('should unsubscribe from event subscription', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + // Create a new manager to get a fresh subscription + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy + newManager.destroy(); + + // Verify unsubscribe was called + expect(unsubscribeFn).toHaveBeenCalled(); + }); + + it('should handle destroy being called multiple times', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy multiple times + newManager.destroy(); + newManager.destroy(); + + // Should only unsubscribe once + expect(unsubscribeFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/ideation-service.test.ts b/jules_branch/apps/server/tests/unit/services/ideation-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7004362a2eec311f8b805ac662c644c53c4ef99c --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/ideation-service.test.ts @@ -0,0 +1,932 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { IdeationService } from '@/services/ideation-service.js'; +import type { EventEmitter } from '@/lib/events.js'; +import type { SettingsService } from '@/services/settings-service.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import * as platform from '@automaker/platform'; +import * as utils from '@automaker/utils'; +import type { + CreateIdeaInput, + UpdateIdeaInput, + Idea, + IdeationSession, + StartSessionOptions, +} from '@automaker/types'; +import { ProviderFactory } from '@/providers/provider-factory.js'; + +// Create shared mock instances for assertions using vi.hoisted +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +const mockCreateChatOptions = vi.hoisted(() => + vi.fn(() => ({ + model: 'claude-sonnet-4-6', + systemPrompt: 'test prompt', + })) +); + +// Mock dependencies +vi.mock('@/lib/secure-fs.js'); +vi.mock('@automaker/platform'); +vi.mock('@automaker/utils', async () => { + const actual = await vi.importActual('@automaker/utils'); + return { + ...actual, + createLogger: vi.fn(() => mockLogger), + loadContextFiles: vi.fn(), + isAbortError: vi.fn(), + }; +}); +vi.mock('@/providers/provider-factory.js'); +vi.mock('@/lib/sdk-options.js', () => ({ + createChatOptions: mockCreateChatOptions, + validateWorkingDirectory: vi.fn(), +})); + +describe('IdeationService', () => { + let service: IdeationService; + let mockEvents: EventEmitter; + let mockSettingsService: SettingsService; + let mockFeatureLoader: FeatureLoader; + const testProjectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + + // Create mock event emitter + mockEvents = { + emit: vi.fn(), + on: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + } as unknown as EventEmitter; + + // Create mock settings service + mockSettingsService = { + getCredentials: vi.fn().mockResolvedValue({}), + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + // Create mock feature loader + mockFeatureLoader = { + getAll: vi.fn().mockResolvedValue([]), + } as unknown as FeatureLoader; + + // Mock platform functions + vi.mocked(platform.ensureIdeationDir).mockResolvedValue(undefined); + vi.mocked(platform.getIdeaDir).mockReturnValue( + '/test/project/.automaker/ideation/ideas/idea-123' + ); + vi.mocked(platform.getIdeaPath).mockReturnValue( + '/test/project/.automaker/ideation/ideas/idea-123/idea.json' + ); + vi.mocked(platform.getIdeasDir).mockReturnValue('/test/project/.automaker/ideation/ideas'); + vi.mocked(platform.getIdeationSessionPath).mockReturnValue( + '/test/project/.automaker/ideation/sessions/session-123.json' + ); + vi.mocked(platform.getIdeationSessionsDir).mockReturnValue( + '/test/project/.automaker/ideation/sessions' + ); + vi.mocked(platform.getIdeationAnalysisPath).mockReturnValue( + '/test/project/.automaker/ideation/analysis.json' + ); + + // Mock utils (already mocked above, but reset return values) + vi.mocked(utils.loadContextFiles).mockResolvedValue({ + formattedPrompt: 'Test context', + files: [], + }); + vi.mocked(utils.isAbortError).mockReturnValue(false); + + service = new IdeationService(mockEvents, mockSettingsService, mockFeatureLoader); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ============================================================================ + // Session Management Tests + // ============================================================================ + + describe('Session Management', () => { + describe('startSession', () => { + it('should create a new session with default options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + + expect(session).toBeDefined(); + expect(session.id).toMatch(/^session-/); + expect(session.projectPath).toBe(testProjectPath); + expect(session.status).toBe('active'); + expect(session.createdAt).toBeDefined(); + expect(session.updatedAt).toBeDefined(); + expect(platform.ensureIdeationDir).toHaveBeenCalledWith(testProjectPath); + expect(secureFs.writeFile).toHaveBeenCalled(); + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-started', { + sessionId: session.id, + projectPath: testProjectPath, + }); + }); + + it('should create session with custom options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const options: StartSessionOptions = { + promptCategory: 'features', + promptId: 'new-features', + }; + + const session = await service.startSession(testProjectPath, options); + + expect(session.promptCategory).toBe('features'); + expect(session.promptId).toBe('new-features'); + }); + + it('should send initial message if provided in options', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({ features: [] })); + + // Mock provider + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: 'AI response', + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const options: StartSessionOptions = { + initialMessage: 'Hello, AI!', + }; + + await service.startSession(testProjectPath, options); + + // Give time for the async message to process + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockProvider.executeQuery).toHaveBeenCalled(); + }); + }); + + describe('getSession', () => { + it('should return null for non-existent session', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getSession(testProjectPath, 'non-existent'); + + expect(result).toBeNull(); + }); + + it('should return active session from memory', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + const retrieved = await service.getSession(testProjectPath, session.id); + + expect(retrieved).toBeDefined(); + expect(retrieved?.id).toBe(session.id); + expect(retrieved?.messages).toEqual([]); + }); + + it('should load session from disk if not in memory', async () => { + const mockSession: IdeationSession = { + id: 'session-123', + projectPath: testProjectPath, + status: 'active', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const sessionData = { + session: mockSession, + messages: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(sessionData)); + + const result = await service.getSession(testProjectPath, 'session-123'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('session-123'); + }); + }); + + describe('stopSession', () => { + it('should stop an active session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + await service.stopSession(session.id); + + expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-ended', { + sessionId: session.id, + }); + }); + + it('should handle stopping non-existent session gracefully', async () => { + await expect(service.stopSession('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('isSessionRunning', () => { + it('should return false for non-existent session', () => { + expect(service.isSessionRunning('non-existent')).toBe(false); + }); + + it('should return false for idle session', async () => { + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const session = await service.startSession(testProjectPath); + expect(service.isSessionRunning(session.id)).toBe(false); + }); + }); + }); + + // ============================================================================ + // Ideas CRUD Tests + // ============================================================================ + + describe('Ideas CRUD', () => { + describe('createIdea', () => { + it('should create a new idea with required fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Test Idea', + description: 'This is a test idea', + category: 'features', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea).toBeDefined(); + expect(idea.id).toMatch(/^idea-/); + expect(idea.title).toBe('Test Idea'); + expect(idea.description).toBe('This is a test idea'); + expect(idea.category).toBe('features'); + expect(idea.status).toBe('raw'); + expect(idea.impact).toBe('medium'); + expect(idea.effort).toBe('medium'); + expect(secureFs.mkdir).toHaveBeenCalled(); + expect(secureFs.writeFile).toHaveBeenCalled(); + }); + + it('should create idea with all optional fields', async () => { + vi.mocked(secureFs.mkdir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const input: CreateIdeaInput = { + title: 'Full Idea', + description: 'Complete idea', + category: 'features', + status: 'refined', + impact: 'high', + effort: 'low', + conversationId: 'conv-123', + sourcePromptId: 'prompt-123', + userStories: ['Story 1', 'Story 2'], + notes: 'Additional notes', + }; + + const idea = await service.createIdea(testProjectPath, input); + + expect(idea.status).toBe('refined'); + expect(idea.impact).toBe('high'); + expect(idea.effort).toBe('low'); + expect(idea.conversationId).toBe('conv-123'); + expect(idea.sourcePromptId).toBe('prompt-123'); + expect(idea.userStories).toEqual(['Story 1', 'Story 2']); + expect(idea.notes).toBe('Additional notes'); + }); + }); + + describe('getIdeas', () => { + it('should return empty array when ideas directory does not exist', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toEqual([]); + }); + + it('should load all ideas from disk', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const idea1: Idea = { + id: 'idea-1', + title: 'Idea 1', + description: 'First idea', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const idea2: Idea = { + id: 'idea-2', + title: 'Idea 2', + description: 'Second idea', + category: 'bugs', + status: 'refined', + impact: 'high', + effort: 'low', + createdAt: '2024-01-02T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(idea1)) + .mockResolvedValueOnce(JSON.stringify(idea2)); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(2); + expect(ideas[0].id).toBe('idea-2'); // Sorted by updatedAt descending + expect(ideas[1].id).toBe('idea-1'); + }); + + it('should skip invalid idea files', async () => { + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'idea-1', isDirectory: () => true } as any, + { name: 'idea-2', isDirectory: () => true } as any, + ]); + + const validIdea: Idea = { + id: 'idea-1', + title: 'Valid Idea', + description: 'Valid', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(JSON.stringify(validIdea)) + .mockRejectedValueOnce(new Error('Invalid JSON')); + + const ideas = await service.getIdeas(testProjectPath); + + expect(ideas).toHaveLength(1); + expect(ideas[0].id).toBe('idea-1'); + }); + }); + + describe('getIdea', () => { + it('should return idea by id', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test Idea', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const idea = await service.getIdea(testProjectPath, 'idea-123'); + + expect(idea).toBeDefined(); + expect(idea?.id).toBe('idea-123'); + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const idea = await service.getIdea(testProjectPath, 'non-existent'); + + expect(idea).toBeNull(); + }); + }); + + describe('updateIdea', () => { + it('should update idea fields', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Original Title', + description: 'Original', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const updates: UpdateIdeaInput = { + title: 'Updated Title', + status: 'refined', + }; + + const updated = await service.updateIdea(testProjectPath, 'idea-123', updates); + + expect(updated).toBeDefined(); + expect(updated?.title).toBe('Updated Title'); + expect(updated?.status).toBe('refined'); + expect(updated?.description).toBe('Original'); // Unchanged + expect(updated?.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); // Should be updated + }); + + it('should return null for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const updated = await service.updateIdea(testProjectPath, 'non-existent', { + title: 'New Title', + }); + + expect(updated).toBeNull(); + }); + }); + + describe('deleteIdea', () => { + it('should delete idea directory', async () => { + vi.mocked(secureFs.rm).mockResolvedValue(undefined); + + await service.deleteIdea(testProjectPath, 'idea-123'); + + expect(secureFs.rm).toHaveBeenCalledWith( + expect.stringContaining('idea-123'), + expect.objectContaining({ recursive: true }) + ); + }); + + it('should handle non-existent idea gracefully', async () => { + vi.mocked(secureFs.rm).mockRejectedValue(new Error('ENOENT')); + + await expect(service.deleteIdea(testProjectPath, 'non-existent')).resolves.not.toThrow(); + }); + }); + + describe('archiveIdea', () => { + it('should set idea status to archived', async () => { + const existingIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Test', + category: 'features', + status: 'raw', + impact: 'medium', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea)); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + + const archived = await service.archiveIdea(testProjectPath, 'idea-123'); + + expect(archived).toBeDefined(); + expect(archived?.status).toBe('archived'); + }); + }); + }); + + // ============================================================================ + // Conversion Tests + // ============================================================================ + + describe('Idea to Feature Conversion', () => { + describe('convertToFeature', () => { + it('should convert idea to feature with basic fields', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Add Dark Mode', + description: 'Implement dark mode theme', + category: 'feature', + status: 'refined', + impact: 'high', + effort: 'medium', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature).toBeDefined(); + expect(feature.id).toMatch(/^feature-/); + expect(feature.title).toBe('Add Dark Mode'); + expect(feature.description).toBe('Implement dark mode theme'); + expect(feature.category).toBe('ui'); // features -> ui mapping + expect(feature.status).toBe('backlog'); + }); + + it('should include user stories in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + userStories: ['As a user, I want X', 'As a user, I want Y'], + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## User Stories'); + expect(feature.description).toContain('As a user, I want X'); + expect(feature.description).toContain('As a user, I want Y'); + }); + + it('should include notes in feature description', async () => { + const mockIdea: Idea = { + id: 'idea-123', + title: 'Test', + description: 'Base description', + category: 'features', + status: 'refined', + impact: 'medium', + effort: 'medium', + notes: 'Important implementation notes', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea)); + + const feature = await service.convertToFeature(testProjectPath, 'idea-123'); + + expect(feature.description).toContain('Base description'); + expect(feature.description).toContain('## Notes'); + expect(feature.description).toContain('Important implementation notes'); + }); + + it('should throw error for non-existent idea', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + await expect(service.convertToFeature(testProjectPath, 'non-existent')).rejects.toThrow( + 'Idea non-existent not found' + ); + }); + }); + }); + + // ============================================================================ + // Project Analysis Tests + // ============================================================================ + + describe('Project Analysis', () => { + describe('analyzeProject', () => { + it('should analyze project and generate suggestions', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue( + JSON.stringify({ + name: 'test-project', + dependencies: {}, + }) + ); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([]); + + const result = await service.analyzeProject(testProjectPath); + + expect(result).toBeDefined(); + expect(result.projectPath).toBe(testProjectPath); + expect(result.analyzedAt).toBeDefined(); + expect(result.suggestions).toBeDefined(); + expect(Array.isArray(result.suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-started', + }) + ); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-complete', + }) + ); + }); + + it('should emit error event on failure', async () => { + // Mock writeFile to fail (this is called after gatherProjectStructure and isn't caught) + vi.mocked(secureFs.readFile).mockResolvedValue( + JSON.stringify({ + name: 'test-project', + dependencies: {}, + }) + ); + vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed')); + + await expect(service.analyzeProject(testProjectPath)).rejects.toThrow(); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:analysis', + expect.objectContaining({ + type: 'ideation:analysis-error', + }) + ); + }); + }); + + describe('getCachedAnalysis', () => { + it('should return cached analysis if exists', async () => { + const mockAnalysis = { + projectPath: testProjectPath, + analyzedAt: '2024-01-01T00:00:00.000Z', + totalFiles: 10, + suggestions: [], + summary: 'Test summary', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockAnalysis)); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toEqual(mockAnalysis); + }); + + it('should return null if cache does not exist', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await service.getCachedAnalysis(testProjectPath); + + expect(result).toBeNull(); + }); + }); + }); + + // ============================================================================ + // Prompt Management Tests + // ============================================================================ + + describe('Prompt Management', () => { + describe('getPromptCategories', () => { + it('should return list of prompt categories', () => { + const categories = service.getPromptCategories(); + + expect(Array.isArray(categories)).toBe(true); + expect(categories.length).toBeGreaterThan(0); + expect(categories[0]).toHaveProperty('id'); + expect(categories[0]).toHaveProperty('name'); + }); + }); + + describe('getAllPrompts', () => { + it('should return all guided prompts', () => { + const prompts = service.getAllPrompts(); + + expect(Array.isArray(prompts)).toBe(true); + expect(prompts.length).toBeGreaterThan(0); + expect(prompts[0]).toHaveProperty('id'); + expect(prompts[0]).toHaveProperty('category'); + expect(prompts[0]).toHaveProperty('title'); + expect(prompts[0]).toHaveProperty('prompt'); + }); + }); + + describe('getPromptsByCategory', () => { + it('should return prompts filtered by category', () => { + const allPrompts = service.getAllPrompts(); + const firstCategory = allPrompts[0].category; + + const filtered = service.getPromptsByCategory(firstCategory); + + expect(Array.isArray(filtered)).toBe(true); + filtered.forEach((prompt) => { + expect(prompt.category).toBe(firstCategory); + }); + }); + + it('should return empty array for non-existent category', () => { + const filtered = service.getPromptsByCategory('non-existent-category' as any); + + expect(filtered).toEqual([]); + }); + }); + }); + + // ============================================================================ + // Suggestions Generation Tests + // ============================================================================ + + describe('Suggestion Generation', () => { + describe('generateSuggestions', () => { + it('should generate suggestions for a prompt', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([ + { + title: 'Add user authentication', + description: 'Implement auth', + category: 'security', + impact: 'high', + effort: 'high', + }, + ]), + }; + }, + }), + }; + + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + const firstPrompt = prompts[0]; + + const suggestions = await service.generateSuggestions( + testProjectPath, + firstPrompt.id, + 'features', + 5 + ); + + expect(Array.isArray(suggestions)).toBe(true); + expect(mockEvents.emit).toHaveBeenCalledWith( + 'ideation:suggestions', + expect.objectContaining({ + type: 'started', + }) + ); + }); + + it('should throw error for non-existent prompt', async () => { + await expect( + service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5) + ).rejects.toThrow('Prompt non-existent not found'); + }); + + it('should include app spec context when useAppSpec is enabled', async () => { + const mockAppSpec = ` + + Test Project + A test application for unit testing + + User authentication + Data visualization + + + + Login System + Basic auth with email/password + + + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + // First call returns app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockResolvedValueOnce(mockAppSpec) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).toContain('Test Project'); + expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing'); + expect(chatOptionsCall.systemPrompt).toContain('User authentication'); + expect(chatOptionsCall.systemPrompt).toContain('Login System'); + }); + + it('should exclude app spec context when useAppSpec is disabled', async () => { + const mockAppSpec = ` + + Hidden Project + This should not appear + + `; + + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: false, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }); + + // Verify createChatOptions was called with systemPrompt NOT containing app spec info + expect(mockCreateChatOptions).toHaveBeenCalled(); + const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0]; + expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project'); + expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear'); + }); + + it('should handle missing app spec file gracefully', async () => { + vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt'); + + const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; + enoentError.code = 'ENOENT'; + + // First call fails with ENOENT for app spec, subsequent calls return empty JSON + vi.mocked(secureFs.readFile) + .mockRejectedValueOnce(enoentError) + .mockResolvedValue(JSON.stringify({})); + + const mockProvider = { + executeQuery: vi.fn().mockReturnValue({ + async *[Symbol.asyncIterator]() { + yield { + type: 'result', + subtype: 'success', + result: JSON.stringify([{ title: 'Test', description: 'Test' }]), + }; + }, + }), + }; + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + + const prompts = service.getAllPrompts(); + + // Should not throw + await expect( + service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, { + useAppSpec: true, + useContextFiles: false, + useMemoryFiles: false, + useExistingFeatures: false, + useExistingIdeas: false, + }) + ).resolves.toBeDefined(); + + // Should not log warning for ENOENT + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/mcp-test-service.test.ts b/jules_branch/apps/server/tests/unit/services/mcp-test-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07c1cc0df5c89d42dee12402e2d7968719932321 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/mcp-test-service.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MCPServerConfig } from '@automaker/types'; + +// Skip this test suite - MCP SDK mocking is complex and these tests need integration tests +// Coverage will be handled by excluding this file from coverage thresholds +describe.skip('mcp-test-service.ts', () => {}); + +// Create mock client +const mockClient = { + connect: vi.fn(), + listTools: vi.fn(), + close: vi.fn(), +}; + +// Mock the MCP SDK modules before importing MCPTestService +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: vi.fn(() => mockClient), +})); + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(), +})); + +vi.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({ + SSEClientTransport: vi.fn(), +})); + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})); + +// Import after mocking +import { MCPTestService } from '@/services/mcp-test-service.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +describe.skip('mcp-test-service.ts - SDK tests', () => { + let mcpTestService: MCPTestService; + let mockSettingsService: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockSettingsService = { + getGlobalSettings: vi.fn(), + }; + + // Reset mock client defaults + mockClient.connect.mockResolvedValue(undefined); + mockClient.listTools.mockResolvedValue({ tools: [] }); + mockClient.close.mockResolvedValue(undefined); + + mcpTestService = new MCPTestService(mockSettingsService); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('testServer', () => { + describe('with stdio transport', () => { + it('should successfully test stdio server', async () => { + mockClient.listTools.mockResolvedValue({ + tools: [ + { name: 'tool1', description: 'Test tool 1' }, + { name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } }, + ], + }); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + args: ['server.js'], + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(true); + expect(result.tools).toHaveLength(2); + expect(result.tools?.[0].name).toBe('tool1'); + expect(result.tools?.[0].enabled).toBe(true); + expect(result.connectionTime).toBeGreaterThanOrEqual(0); + expect(result.serverInfo?.name).toBe('Test Server'); + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'node', + args: ['server.js'], + env: undefined, + }); + }); + + it('should throw error if command is missing for stdio', async () => { + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('Command is required for stdio transport'); + }); + + it('should pass env to stdio transport', async () => { + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { API_KEY: 'secret' }, + enabled: true, + }; + + await mcpTestService.testServer(config); + + expect(StdioClientTransport).toHaveBeenCalledWith({ + command: 'node', + args: ['server.js'], + env: { API_KEY: 'secret' }, + }); + }); + }); + + describe('with SSE transport', () => { + it('should successfully test SSE server', async () => { + const config: MCPServerConfig = { + id: 'sse-server', + name: 'SSE Server', + type: 'sse', + url: 'http://localhost:3000/sse', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(true); + expect(SSEClientTransport).toHaveBeenCalled(); + }); + + it('should throw error if URL is missing for SSE', async () => { + const config: MCPServerConfig = { + id: 'sse-server', + name: 'SSE Server', + type: 'sse', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('URL is required for SSE transport'); + }); + + it('should pass headers to SSE transport', async () => { + const config: MCPServerConfig = { + id: 'sse-server', + name: 'SSE Server', + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + enabled: true, + }; + + await mcpTestService.testServer(config); + + expect(SSEClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + requestInit: { headers: { Authorization: 'Bearer token' } }, + eventSourceInit: expect.any(Object), + }) + ); + }); + }); + + describe('with HTTP transport', () => { + it('should successfully test HTTP server', async () => { + const config: MCPServerConfig = { + id: 'http-server', + name: 'HTTP Server', + type: 'http', + url: 'http://localhost:3000/api', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(true); + expect(StreamableHTTPClientTransport).toHaveBeenCalled(); + }); + + it('should throw error if URL is missing for HTTP', async () => { + const config: MCPServerConfig = { + id: 'http-server', + name: 'HTTP Server', + type: 'http', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('URL is required for HTTP transport'); + }); + + it('should pass headers to HTTP transport', async () => { + const config: MCPServerConfig = { + id: 'http-server', + name: 'HTTP Server', + type: 'http', + url: 'http://localhost:3000/api', + headers: { 'X-API-Key': 'secret' }, + enabled: true, + }; + + await mcpTestService.testServer(config); + + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + requestInit: { headers: { 'X-API-Key': 'secret' } }, + }) + ); + }); + }); + + describe('error handling', () => { + it('should handle connection errors', async () => { + mockClient.connect.mockRejectedValue(new Error('Connection refused')); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('Connection refused'); + expect(result.connectionTime).toBeGreaterThanOrEqual(0); + }); + + it('should handle listTools errors', async () => { + mockClient.listTools.mockRejectedValue(new Error('Failed to list tools')); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to list tools'); + }); + + it('should handle non-Error thrown values', async () => { + mockClient.connect.mockRejectedValue('string error'); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(false); + expect(result.error).toBe('string error'); + }); + + it('should cleanup client on success', async () => { + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + await mcpTestService.testServer(config); + + expect(mockClient.close).toHaveBeenCalled(); + }); + + it('should cleanup client on error', async () => { + mockClient.connect.mockRejectedValue(new Error('Connection failed')); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + await mcpTestService.testServer(config); + + expect(mockClient.close).toHaveBeenCalled(); + }); + + it('should ignore cleanup errors', async () => { + mockClient.close.mockRejectedValue(new Error('Cleanup failed')); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + // Should not throw + const result = await mcpTestService.testServer(config); + + expect(result.success).toBe(true); + }); + }); + + describe('tool mapping', () => { + it('should map tools correctly with all fields', async () => { + mockClient.listTools.mockResolvedValue({ + tools: [ + { + name: 'complex-tool', + description: 'A complex tool', + inputSchema: { type: 'object', properties: { arg1: { type: 'string' } } }, + }, + ], + }); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.tools?.[0]).toEqual({ + name: 'complex-tool', + description: 'A complex tool', + inputSchema: { type: 'object', properties: { arg1: { type: 'string' } } }, + enabled: true, + }); + }); + + it('should handle empty tools array', async () => { + mockClient.listTools.mockResolvedValue({ tools: [] }); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.tools).toEqual([]); + }); + + it('should handle undefined tools', async () => { + mockClient.listTools.mockResolvedValue({}); + + const config: MCPServerConfig = { + id: 'test-server', + name: 'Test Server', + type: 'stdio', + command: 'node', + enabled: true, + }; + + const result = await mcpTestService.testServer(config); + + expect(result.tools).toEqual([]); + }); + }); + }); + + describe('testServerById', () => { + it('should test server found by ID', async () => { + const serverConfig: MCPServerConfig = { + id: 'server-1', + name: 'Server One', + type: 'stdio', + command: 'node', + enabled: true, + }; + + mockSettingsService.getGlobalSettings.mockResolvedValue({ + mcpServers: [serverConfig], + }); + + const result = await mcpTestService.testServerById('server-1'); + + expect(result.success).toBe(true); + expect(mockSettingsService.getGlobalSettings).toHaveBeenCalled(); + }); + + it('should return error if server not found', async () => { + mockSettingsService.getGlobalSettings.mockResolvedValue({ + mcpServers: [], + }); + + const result = await mcpTestService.testServerById('non-existent'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Server with ID "non-existent" not found'); + }); + + it('should return error if mcpServers is undefined', async () => { + mockSettingsService.getGlobalSettings.mockResolvedValue({}); + + const result = await mcpTestService.testServerById('server-1'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Server with ID "server-1" not found'); + }); + + it('should handle settings service errors', async () => { + mockSettingsService.getGlobalSettings.mockRejectedValue(new Error('Settings error')); + + const result = await mcpTestService.testServerById('server-1'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Settings error'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/ntfy-service.test.ts b/jules_branch/apps/server/tests/unit/services/ntfy-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a2cc1951c6ebcdd0e805a5cc9a8f74aaf4b565f --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/ntfy-service.test.ts @@ -0,0 +1,642 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NtfyService } from '../../../src/services/ntfy-service.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; + +// Mock global fetch +const originalFetch = global.fetch; + +describe('NtfyService', () => { + let service: NtfyService; + let mockFetch: ReturnType; + + beforeEach(() => { + service = new NtfyService(); + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + /** + * Create a valid endpoint config for testing + */ + function createEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: 'test-endpoint-id', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + + /** + * Create a basic context for testing + */ + function createContext() { + return { + featureId: 'feat-123', + featureName: 'Test Feature', + projectPath: '/test/project', + projectName: 'test-project', + timestamp: '2024-01-15T10:30:00.000Z', + eventType: 'feature_success', + }; + } + + describe('validateEndpoint', () => { + it('should return null for valid endpoint with no auth', () => { + const endpoint = createEndpoint(); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with basic auth', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with token auth', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_123456', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return error when serverUrl is missing', () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Server URL is required'); + }); + + it('should return error when serverUrl is invalid', () => { + const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Invalid server URL format'); + }); + + it('should return error when topic is missing', () => { + const endpoint = createEndpoint({ topic: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic is required'); + }); + + it('should return error when topic contains spaces', () => { + const endpoint = createEndpoint({ topic: 'invalid topic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when topic contains tabs', () => { + const endpoint = createEndpoint({ topic: 'invalid\ttopic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when basic auth is missing username', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: '', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when basic auth is missing password', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when token auth is missing token', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Access token is required for token authentication'); + }); + }); + + describe('sendNotification', () => { + it('should return error when endpoint is disabled', async () => { + const endpoint = createEndpoint({ enabled: false }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Endpoint is disabled'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return error when endpoint validation fails', async () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Server URL is required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should send notification with default values', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(options.headers['Title']).toContain('Feature Completed'); + expect(options.headers['Priority']).toBe('3'); + }); + + it('should send notification with custom title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + title: 'Custom Title', + body: 'Custom body message', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Custom Title'); + expect(options.body).toBe('Custom body message'); + }); + + it('should send notification with tags and emoji', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + tags: 'warning,skull', + emoji: 'tada', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada,warning,skull'); + }); + + it('should send notification with priority', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, { priority: 5 }, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Priority']).toBe('5'); + }); + + it('should send notification with click URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { clickUrl: 'https://example.com/feature/123' }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://example.com/feature/123'); + }); + + it('should use endpoint default tags and emoji when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultTags: 'default-tag', + defaultEmoji: 'rocket', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('rocket,default-tag'); + }); + + it('should use endpoint default click URL when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultClickUrl: 'https://default.example.com', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://default.example.com'); + }); + + it('should send notification with basic authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'basic', + username: 'testuser', + password: 'testpass', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + // Basic auth should be base64 encoded + const expectedAuth = Buffer.from('testuser:testpass').toString('base64'); + expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`); + }); + + it('should send notification with token authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_test_token_123', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123'); + }); + + it('should return error on HTTP error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden - invalid token'), + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toContain('403'); + expect(result.error).toContain('Forbidden'); + }); + + it('should return error on timeout', async () => { + mockFetch.mockImplementationOnce(() => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + throw error; + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Request timed out'); + }); + + it('should return error on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should handle server URL with trailing slash', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + }); + + it('should URL encode the topic', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ topic: 'test/topic#special' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('test%2Ftopic%23special'); + }); + }); + + describe('variable substitution', () => { + it('should substitute {{featureId}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Feature {{featureId}} completed' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature feat-123 completed'); + }); + + it('should substitute {{featureName}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'The feature "{{featureName}}" is done!' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('The feature "Test Feature" is done!'); + }); + + it('should substitute {{projectName}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: '[{{projectName}}] Event: {{eventType}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Event: feature_success'); + }); + + it('should substitute {{timestamp}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'Completed at: {{timestamp}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z'); + }); + + it('should substitute {{error}} in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Something went wrong', + }; + await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Error: Something went wrong'); + }); + + it('should substitute multiple variables', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Test Feature'); + expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z'); + }); + + it('should replace unknown variables with empty string', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Value: {{unknownVariable}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Value: '); + }); + }); + + describe('default title generation', () => { + it('should generate title with feature name for feature_success', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed: Test Feature'); + }); + + it('should generate title without feature name when missing', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed'); + }); + + it('should generate correct title for feature_created', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_created' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Created: Test Feature'); + }); + + it('should generate correct title for feature_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_error' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Failed: Test Feature'); + }); + + it('should generate correct title for auto_mode_complete', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'auto_mode_complete', + featureName: undefined, + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Complete'); + }); + + it('should generate correct title for auto_mode_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Error'); + }); + }); + + describe('default body generation', () => { + it('should generate body with feature info', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Feature: Test Feature'); + expect(options.body).toContain('ID: feat-123'); + expect(options.body).toContain('Project: test-project'); + expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z'); + }); + + it('should include error in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Build failed', + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Error: Build failed'); + }); + }); + + describe('emoji and tags handling', () => { + it('should handle emoji shortcode with colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada'); + }); + + it('should handle emoji without colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: 'warning' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('warning'); + }); + + it('should combine emoji and tags correctly', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'rotating_light', tags: 'urgent,alert' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + // Emoji comes first, then tags + expect(options.headers['Tags']).toBe('rotating_light,urgent,alert'); + }); + + it('should ignore emoji with spaces', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'multi word emoji', tags: 'test' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('test'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba847bd09a0bdfc8863ef8cd05d739f6a5e88047 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js'; +import type { Feature } from '@automaker/types'; + +describe('PipelineOrchestrator Prompts', () => { + const mockFeature: Feature = { + id: 'feature-123', + title: 'Test Feature', + description: 'A test feature', + status: 'in_progress', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tasks: [], + }; + + const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`; + + it('should include mandatory summary requirement in pipeline step prompt', () => { + const orchestrator = new PipelineOrchestrator( + null as any, // eventBus + null as any, // featureStateManager + null as any, // agentExecutor + null as any, // testRunnerService + null as any, // worktreeResolver + null as any, // concurrencyManager + null as any, // settingsService + null as any, // updateFeatureStatusFn + null as any, // loadContextFilesFn + mockBuildFeaturePrompt, + null as any, // executeFeatureFn + null as any // runAgentFn + ); + + const step = { + id: 'step1', + name: 'Code Review', + instructions: 'Review the code for quality.', + }; + + const prompt = orchestrator.buildPipelineStepPrompt( + step as any, + mockFeature, + 'Previous work context', + { implementationInstructions: '', playwrightVerificationInstructions: '' } + ); + + expect(prompt).toContain('## Pipeline Step: Code Review'); + expect(prompt).toContain('Review the code for quality.'); + expect(prompt).toContain( + '**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**' + ); + expect(prompt).toContain(''); + expect(prompt).toContain('## Summary: Code Review'); + expect(prompt).toContain(''); + expect(prompt).toContain('The and tags MUST be on their own lines.'); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..58822100d8f4a76719a43b3aa516820299204320 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for providerId passthrough in PipelineOrchestrator + * Verifies that feature.providerId is forwarded to runAgentFn in both + * executePipeline (step execution) and executeTestStep (test fix) contexts. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Feature, PipelineStep } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +// Mock merge-service +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn().mockResolvedValue({ success: true }), +})); + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock validateWorkingDirectory +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator - providerId passthrough', () => { + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockAgentExecutor: AgentExecutor; + let mockTestRunnerService: TestRunnerService; + let mockWorktreeResolver: WorktreeResolver; + let mockConcurrencyManager: ConcurrencyManager; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadContextFilesFn: vi.Mock; + let mockBuildFeaturePromptFn: BuildFeaturePromptFn; + let mockExecuteFeatureFn: ExecuteFeatureFn; + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + const testSteps: PipelineStep[] = [ + { + id: 'step-1', + name: 'Step 1', + order: 1, + instructions: 'Do step 1', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + ]; + + const createFeatureWithProvider = (providerId?: string): Feature => ({ + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_step-1', + branchName: 'feature/test-1', + providerId, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(createFeatureWithProvider()), + } as unknown as FeatureStateManager; + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue({ success: true }), + } as unknown as AgentExecutor; + + mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager; + + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' }); + mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content'); + mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + null, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + }); + + describe('executePipeline', () => { + it('should pass providerId to runAgentFn options when feature has providerId', async () => { + const feature = createFeatureWithProvider('moonshot-ai'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }); + + it('should pass undefined providerId when feature has no providerId', async () => { + const feature = createFeatureWithProvider(undefined); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', undefined); + }); + + it('should pass status alongside providerId in options', async () => { + const feature = createFeatureWithProvider('zhipu'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'zhipu'); + expect(options).toHaveProperty('status'); + }); + }); + + describe('executeTestStep', () => { + it('should pass providerId in test fix agent options when tests fail', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const feature = createFeatureWithProvider('custom-provider'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executeTestStep(context, 'npm test'); + + // The fix agent should receive providerId + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'custom-provider'); + }, 15000); + + it('should pass thinkingLevel in test fix agent options', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const feature = createFeatureWithProvider('moonshot-ai'); + feature.thinkingLevel = 'high'; + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executeTestStep(context, 'npm test'); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('thinkingLevel', 'high'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }, 15000); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..880d8debdcc1c29d12b2513e562dd3422afc161d --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts @@ -0,0 +1,302 @@ +/** + * Tests for status + providerId coexistence in PipelineOrchestrator options. + * + * During rebase onto upstream/v1.0.0rc, a merge conflict arose where + * upstream added `status: currentStatus` and the incoming branch added + * `providerId: feature.providerId`. The conflict resolution kept BOTH fields. + * + * This test validates that both fields coexist correctly in the options + * object passed to runAgentFn in both executePipeline and executeTestStep. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Feature, PipelineStep } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator - status and providerId coexistence', () => { + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + const testSteps: PipelineStep[] = [ + { + id: 'implement', + name: 'Implement Feature', + order: 1, + instructions: 'Implement the feature', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + ]; + + const createFeature = (overrides: Partial = {}): Feature => ({ + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_implement', + branchName: 'feature/test-1', + providerId: 'moonshot-ai', + thinkingLevel: 'medium', + reasoningEffort: 'high', + ...overrides, + }); + + const createContext = (feature: Feature): PipelineContext => ({ + projectPath: '/test/project', + featureId: feature.id, + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: feature.branchName ?? 'main', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + const mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + const mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(createFeature()), + } as unknown as FeatureStateManager; + + const mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + {} as AgentExecutor, + mockTestRunnerService, + { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver, + { + acquire: vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager, + null, + vi.fn().mockResolvedValue(undefined), + vi.fn().mockResolvedValue({ contextPrompt: 'test context' }), + vi.fn().mockReturnValue('Feature prompt content'), + vi.fn().mockResolvedValue(undefined), + mockRunAgentFn + ); + }); + + describe('executePipeline - options object', () => { + it('should pass both status and providerId in options', async () => { + const feature = createFeature({ providerId: 'moonshot-ai' }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }); + + it('should pass status even when providerId is undefined', async () => { + const feature = createFeature({ providerId: undefined }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', undefined); + }); + + it('should pass thinkingLevel and reasoningEffort alongside status and providerId', async () => { + const feature = createFeature({ + providerId: 'zhipu', + thinkingLevel: 'high', + reasoningEffort: 'medium', + }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', 'zhipu'); + expect(options).toHaveProperty('thinkingLevel', 'high'); + expect(options).toHaveProperty('reasoningEffort', 'medium'); + }); + }); + + describe('executeTestStep - options object', () => { + it('should pass both status and providerId in test fix agent options', async () => { + const feature = createFeature({ + status: 'running', + providerId: 'custom-provider', + }); + const context = createContext(feature); + + const mockTestRunner = orchestrator['testRunnerService'] as any; + vi.mocked(mockTestRunner.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + }) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }); + + await orchestrator.executeTestStep(context, 'npm test'); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'running'); + expect(options).toHaveProperty('providerId', 'custom-provider'); + }, 15000); + + it('should pass feature.status (not currentStatus) in test fix context', async () => { + const feature = createFeature({ + status: 'pipeline_test', + providerId: 'moonshot-ai', + }); + const context = createContext(feature); + + const mockTestRunner = orchestrator['testRunnerService'] as any; + vi.mocked(mockTestRunner.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + }) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }); + + await orchestrator.executeTestStep(context, 'npm test'); + + const options = mockRunAgentFn.mock.calls[0][7]; + // In test fix context, status should come from context.feature.status + expect(options).toHaveProperty('status', 'pipeline_test'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }, 15000); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4f342656167c2c84d84d3158ec91b18b8bc0dd6 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-orchestrator.test.ts @@ -0,0 +1,1141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Feature, PipelineStep, PipelineConfig } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type PipelineStatusInfo, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import { pipelineService } from '../../../src/services/pipeline-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +// Mock merge-service +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn(), +})); + +import { performMerge } from '../../../src/services/merge-service.js'; + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock validateWorkingDirectory +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockAgentExecutor: AgentExecutor; + let mockTestRunnerService: TestRunnerService; + let mockWorktreeResolver: WorktreeResolver; + let mockConcurrencyManager: ConcurrencyManager; + let mockSettingsService: SettingsService | null; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadContextFilesFn: vi.Mock; + let mockBuildFeaturePromptFn: BuildFeaturePromptFn; + let mockExecuteFeatureFn: ExecuteFeatureFn; + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + // Test data + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_step-1', + branchName: 'feature/test-1', + }; + + const testSteps: PipelineStep[] = [ + { + id: 'step-1', + name: 'Step 1', + order: 1, + instructions: 'Do step 1', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + { + id: 'step-2', + name: 'Step 2', + order: 2, + instructions: 'Do step 2', + colorClass: 'green', + createdAt: '', + updatedAt: '', + }, + ]; + + const testConfig: PipelineConfig = { + version: 1, + steps: testSteps, + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(testFeature), + } as unknown as FeatureStateManager; + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue({ success: true }), + } as unknown as AgentExecutor; + + mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager; + + mockSettingsService = null; + + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' }); + mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content'); + mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + // Default mocks for secureFs + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Re-setup platform mocks (clearAllMocks resets implementations) + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + + // Re-setup settings helpers mocks + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + mockSettingsService, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with all dependencies', () => { + expect(orchestrator).toBeInstanceOf(PipelineOrchestrator); + }); + + it('should accept null settingsService', () => { + const orch = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + null, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + expect(orch).toBeInstanceOf(PipelineOrchestrator); + }); + }); + + describe('buildPipelineStepPrompt', () => { + const taskPrompts = { + implementationInstructions: 'impl instructions', + playwrightVerificationInstructions: 'playwright instructions', + }; + + it('should include step name and instructions', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + '', + taskPrompts + ); + expect(prompt).toContain('## Pipeline Step: Step 1'); + expect(prompt).toContain('Do step 1'); + }); + + it('should include feature context from callback', () => { + orchestrator.buildPipelineStepPrompt(testSteps[0], testFeature, '', taskPrompts); + expect(mockBuildFeaturePromptFn).toHaveBeenCalledWith(testFeature, taskPrompts); + }); + + it('should include previous context when available', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + 'Previous work content', + taskPrompts + ); + expect(prompt).toContain('### Previous Work'); + expect(prompt).toContain('Previous work content'); + }); + + it('should omit previous context section when empty', () => { + const prompt = orchestrator.buildPipelineStepPrompt( + testSteps[0], + testFeature, + '', + taskPrompts + ); + expect(prompt).not.toContain('### Previous Work'); + }); + }); + + describe('detectPipelineStatus', () => { + beforeEach(() => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1'); + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig); + }); + + it('should return isPipeline false for non-pipeline status', async () => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(result.isPipeline).toBe(false); + expect(result.stepId).toBeNull(); + }); + + it('should return step info for valid pipeline status', async () => { + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(result.isPipeline).toBe(true); + expect(result.stepId).toBe('step-1'); + expect(result.stepIndex).toBe(0); + expect(result.step?.name).toBe('Step 1'); + }); + + it('should return stepIndex -1 when step not found in config', async () => { + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('nonexistent-step'); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_nonexistent' + ); + expect(result.isPipeline).toBe(true); + expect(result.stepIndex).toBe(-1); + expect(result.step).toBeNull(); + }); + + it('should return config null when no pipeline config exists', async () => { + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(null); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(result.isPipeline).toBe(true); + expect(result.config).toBeNull(); + expect(result.stepIndex).toBe(-1); + }); + }); + + describe('executeTestStep', () => { + const createTestContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + it('should return success when tests pass on first attempt', async () => { + const context = createTestContext(); + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(true); + expect(result.testsPassed).toBe(true); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(1); + }, 10000); + + it('should retry with agent fix when tests fail', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const context = createTestContext(); + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(true); + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2); + }, 15000); + + it('should fail after max attempts', async () => { + vi.mocked(mockTestRunnerService.getSession).mockReturnValue({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + // Use smaller maxTestAttempts to speed up test + const context = { ...createTestContext(), maxTestAttempts: 2 }; + const result = await orchestrator.executeTestStep(context, 'npm test'); + + expect(result.success).toBe(false); + expect(result.testsPassed).toBe(false); + expect(mockTestRunnerService.startTests).toHaveBeenCalledTimes(2); + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + }, 15000); + + it('should emit pipeline_test_failed event on each failure', async () => { + vi.mocked(mockTestRunnerService.getSession).mockReturnValue({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + // Use smaller maxTestAttempts to speed up test + const context = { ...createTestContext(), maxTestAttempts: 2 }; + await orchestrator.executeTestStep(context, 'npm test'); + + const testFailedCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_test_failed'); + expect(testFailedCalls.length).toBe(2); + }, 15000); + + it('should build test failure summary for agent', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + vi.mocked(mockTestRunnerService.getSessionOutput).mockReturnValue({ + success: true, + result: { output: 'FAIL test.spec.ts\nExpected 1 to be 2' }, + } as never); + + const context = createTestContext(); + await orchestrator.executeTestStep(context, 'npm test'); + + const fixPromptCall = vi.mocked(mockRunAgentFn).mock.calls[0]; + expect(fixPromptCall[2]).toContain('Test Failures'); + }, 15000); + }); + + describe('attemptMerge', () => { + const createMergeContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: '/test/worktree', + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockReset(); + }); + + it('should call performMerge with correct parameters', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('should return success on clean merge', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + + const context = createMergeContext(); + const result = await orchestrator.attemptMerge(context); + + expect(result.success).toBe(true); + expect(result.hasConflicts).toBeUndefined(); + }); + + it('should set merge_conflict status when hasConflicts is true', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'merge_conflict' + ); + }); + + it('should emit pipeline_merge_conflict event on conflict', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'pipeline_merge_conflict', + expect.objectContaining({ featureId: 'feature-1', branchName: 'feature/test-1' }) + ); + }); + + it('should emit auto_mode_feature_complete on success when isAutoMode is true', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ featureId: 'feature-1', passes: true }) + ); + }); + + it('should not emit auto_mode_feature_complete on success when isAutoMode is false', async () => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + const context = createMergeContext(); + await orchestrator.attemptMerge(context); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + + it('should return needsAgentResolution true on conflict', async () => { + vi.mocked(performMerge).mockResolvedValue({ + success: false, + hasConflicts: true, + error: 'Merge conflict', + }); + + const context = createMergeContext(); + const result = await orchestrator.attemptMerge(context); + + expect(result.needsAgentResolution).toBe(true); + }); + }); + + describe('buildTestFailureSummary', () => { + it('should extract pass/fail counts from test output', () => { + const scrollback = ` + PASS tests/passing.test.ts + FAIL tests/failing.test.ts + FAIL tests/another.test.ts + `; + + const summary = orchestrator.buildTestFailureSummary(scrollback); + expect(summary).toContain('1 passed'); + expect(summary).toContain('2 failed'); + }); + + it('should extract failed test names from output', () => { + const scrollback = ` + FAIL tests/auth.test.ts + FAIL tests/user.test.ts + `; + + const summary = orchestrator.buildTestFailureSummary(scrollback); + expect(summary).toContain('tests/auth.test.ts'); + expect(summary).toContain('tests/user.test.ts'); + }); + + it('should return concise summary for agent', () => { + const longOutput = 'x'.repeat(5000); + const summary = orchestrator.buildTestFailureSummary(longOutput); + + expect(summary.length).toBeLessThan(5000); + expect(summary).toContain('Output (last 2000 chars)'); + }); + }); + + describe('resumePipeline', () => { + const validPipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + it('should restart from beginning when no context file', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + await orchestrator.resumePipeline('/test/project', testFeature, true, validPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(mockExecuteFeatureFn).toHaveBeenCalled(); + }); + + it('should complete feature when step no longer exists and emit event when isAutoMode=true', async () => { + const invalidPipelineInfo: PipelineStatusInfo = { + ...validPipelineInfo, + stepIndex: -1, + step: null, + }; + + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ message: expect.stringContaining('no longer exists') }) + ); + }); + + it('should not emit feature_complete when step no longer exists and isAutoMode=false', async () => { + const invalidPipelineInfo: PipelineStatusInfo = { + ...validPipelineInfo, + stepIndex: -1, + step: null, + }; + + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + await orchestrator.resumePipeline('/test/project', testFeature, true, invalidPipelineInfo); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_feature_complete'); + expect(completeCalls.length).toBe(0); + }); + }); + + describe('resumeFromStep', () => { + it('should filter out excluded steps', async () => { + const featureWithExclusions: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('pipeline_step-2'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-2'); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithExclusions, + true, + 0, + testConfig + ); + + expect(mockRunAgentFn).toHaveBeenCalled(); + }); + + it('should complete feature when all remaining steps excluded and emit event when isAutoMode=true', async () => { + const featureWithAllExcluded: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1', 'step-2'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithAllExcluded, + true, + 0, + testConfig + ); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ message: expect.stringContaining('excluded') }) + ); + }); + + it('should acquire running feature slot before execution', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith( + expect.objectContaining({ featureId: 'feature-1', allowReuse: true }) + ); + }); + + it('should release slot on completion', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1'); + }); + + it('should release slot on error', async () => { + mockRunAgentFn.mockRejectedValue(new Error('Test error')); + + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1'); + }); + }); + + describe('executePipeline', () => { + const createPipelineContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + }); + + it('should execute steps in sequence', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(2); + }); + + it('should emit pipeline_step_started for each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + const startedCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_step_started'); + expect(startedCalls.length).toBe(2); + }); + + it('should emit pipeline_step_complete after each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + const completeCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'pipeline_step_complete'); + expect(completeCalls.length).toBe(2); + }); + + it('should update feature status to pipeline_{stepId} for each step', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'pipeline_step-2' + ); + }); + + it('should respect abort signal between steps', async () => { + const context = createPipelineContext(); + mockRunAgentFn.mockImplementation(async () => { + context.abortController.abort(); + }); + + await expect(orchestrator.executePipeline(context)).rejects.toThrow( + 'Pipeline execution aborted' + ); + }); + + it('should call attemptMerge after successful completion', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/project', // Falls back to projectPath when worktreePath is null + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + }); + + describe('AutoModeService integration (delegation verification)', () => { + describe('executePipeline delegation', () => { + const createPipelineContext = (): PipelineContext => ({ + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: '/test/worktree', + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.mocked(performMerge).mockResolvedValue({ success: true }); + }); + + it('builds PipelineContext with correct fields from executeFeature', async () => { + const context = createPipelineContext(); + await orchestrator.executePipeline(context); + + // Verify all context fields were used correctly + expect(context.projectPath).toBe('/test/project'); + expect(context.featureId).toBe('feature-1'); + expect(context.steps).toHaveLength(2); + expect(context.workDir).toBe('/test/project'); + expect(context.worktreePath).toBe('/test/worktree'); + expect(context.branchName).toBe('feature/test-1'); + expect(context.autoLoadClaudeMd).toBe(true); + expect(context.testAttempts).toBe(0); + expect(context.maxTestAttempts).toBe(5); + }); + + it('passes worktreePath when worktree exists', async () => { + const context = createPipelineContext(); + context.worktreePath = '/test/custom-worktree'; + + await orchestrator.executePipeline(context); + + // Merge should receive the worktree path + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1', + '/test/custom-worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('passes branchName from feature', async () => { + const context = createPipelineContext(); + context.branchName = 'feature/custom-branch'; + context.feature = { ...testFeature, branchName: 'feature/custom-branch' }; + + await orchestrator.executePipeline(context); + + expect(performMerge).toHaveBeenCalledWith( + '/test/project', + 'feature/custom-branch', + '/test/worktree', + 'main', + { deleteWorktreeAndBranch: false }, + expect.anything() + ); + }); + + it('passes testAttempts and maxTestAttempts', async () => { + const context = createPipelineContext(); + context.testAttempts = 2; + context.maxTestAttempts = 10; + + // These values would be used by executeTestStep if called + expect(context.testAttempts).toBe(2); + expect(context.maxTestAttempts).toBe(10); + }); + }); + + describe('detectPipelineStatus delegation', () => { + beforeEach(() => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(true); + vi.mocked(pipelineService.getStepIdFromStatus).mockReturnValue('step-1'); + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue(testConfig); + }); + + it('returns pipelineInfo from orchestrator for pipeline status', async () => { + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'pipeline_step-1' + ); + + expect(result.isPipeline).toBe(true); + expect(result.stepId).toBe('step-1'); + expect(result.stepIndex).toBe(0); + expect(result.config).toEqual(testConfig); + }); + + it('returns isPipeline false for non-pipeline status', async () => { + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + + const result = await orchestrator.detectPipelineStatus( + '/test/project', + 'feature-1', + 'in_progress' + ); + + expect(result.isPipeline).toBe(false); + expect(result.stepId).toBeNull(); + expect(result.config).toBeNull(); + }); + }); + + describe('resumePipeline delegation', () => { + const validPipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + it('builds resumeContext with autoLoadClaudeMd setting', async () => { + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // Verify autoLoadClaudeMd was fetched + expect(getAutoLoadClaudeMdSetting).toHaveBeenCalledWith( + '/test/project', + null, + '[AutoMode]' + ); + }); + + it('passes useWorktrees flag to orchestrator', async () => { + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // When useWorktrees is true, it should look for worktree + expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1' + ); + }); + + it('sets maxTestAttempts to 5', async () => { + // The default maxTestAttempts is 5 as per CONTEXT.md + await orchestrator.resumeFromStep('/test/project', testFeature, true, 0, testConfig); + + // Execution should proceed with maxTestAttempts = 5 + expect(mockRunAgentFn).toHaveBeenCalled(); + }); + }); + }); + + describe('edge cases', () => { + describe('abort signal handling', () => { + it('handles abort signal during step execution', async () => { + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature: testFeature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + // Abort during first step + mockRunAgentFn.mockImplementationOnce(async () => { + context.abortController.abort(); + }); + + await expect(orchestrator.executePipeline(context)).rejects.toThrow( + 'Pipeline execution aborted' + ); + }); + }); + + describe('context file handling', () => { + it('handles missing context file during resume', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + + const pipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'step-1', + stepIndex: 0, + totalSteps: 2, + step: testSteps[0], + config: testConfig, + }; + + await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo); + + // Should restart from beginning when no context + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + expect(mockExecuteFeatureFn).toHaveBeenCalled(); + }); + }); + + describe('step deletion handling', () => { + it('handles deleted step during resume', async () => { + const pipelineInfo: PipelineStatusInfo = { + isPipeline: true, + stepId: 'deleted-step', + stepIndex: -1, + totalSteps: 2, + step: null, + config: testConfig, + }; + + await orchestrator.resumePipeline('/test/project', testFeature, true, pipelineInfo); + + // Should complete feature when step no longer exists + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('handles all steps excluded during resume and emits event when isAutoMode=true', async () => { + const featureWithAllExcluded: Feature = { + ...testFeature, + excludedPipelineSteps: ['step-1', 'step-2'], + }; + + vi.mocked(pipelineService.getNextStatus).mockReturnValue('verified'); + vi.mocked(pipelineService.isPipelineStatus).mockReturnValue(false); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue({ + featureId: 'feature-1', + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: true, + startTime: Date.now(), + leaseCount: 1, + }); + + await orchestrator.resumeFromStep( + '/test/project', + featureWithAllExcluded, + true, + 0, + testConfig + ); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + message: expect.stringContaining('excluded'), + }) + ); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-service.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..66297afe8b615cc3a39ed214772af03177fd1428 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-service.test.ts @@ -0,0 +1,1221 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { PipelineService } from '@/services/pipeline-service.js'; +import type { PipelineConfig, PipelineStep } from '@automaker/types'; + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), +})); + +// Mock ensureAutomakerDir +vi.mock('@automaker/platform', () => ({ + ensureAutomakerDir: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import { ensureAutomakerDir } from '@automaker/platform'; + +describe('pipeline-service.ts', () => { + let testProjectDir: string; + let pipelineService: PipelineService; + + beforeEach(async () => { + testProjectDir = path.join(os.tmpdir(), `pipeline-test-${Date.now()}`); + await fs.mkdir(testProjectDir, { recursive: true }); + pipelineService = new PipelineService(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + try { + await fs.rm(testProjectDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getPipelineConfig', () => { + it('should return default config when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValue(error); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(config).toEqual({ + version: 1, + steps: [], + }); + }); + + it('should read and return existing config', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json'); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(secureFs.readFile).toHaveBeenCalledWith(configPath, 'utf-8'); + expect(config).toEqual(existingConfig); + }); + + it('should merge with defaults for missing properties', async () => { + const partialConfig = { + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const configPath = path.join(testProjectDir, '.automaker', 'pipeline.json'); + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(partialConfig) as any); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + expect(config.version).toBe(1); + expect(config.steps).toHaveLength(1); + }); + + it('should handle read errors gracefully', async () => { + const error = new Error('Read error'); + vi.mocked(secureFs.readFile).mockRejectedValue(error); + + const config = await pipelineService.getPipelineConfig(testProjectDir); + + // Should return default config on error + expect(config).toEqual({ + version: 1, + steps: [], + }); + }); + }); + + describe('savePipelineConfig', () => { + it('should save config to file', async () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Test Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.savePipelineConfig(testProjectDir, config); + + expect(ensureAutomakerDir).toHaveBeenCalledWith(testProjectDir); + expect(secureFs.writeFile).toHaveBeenCalled(); + expect(secureFs.rename).toHaveBeenCalled(); + }); + + it('should use atomic write pattern', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.savePipelineConfig(testProjectDir, config); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const tempPath = writeCall[0] as string; + expect(tempPath).toContain('.tmp.'); + expect(tempPath).toContain('pipeline.json'); + }); + + it('should clean up temp file on write error', async () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed')); + vi.mocked(secureFs.unlink).mockResolvedValue(undefined); + + await expect(pipelineService.savePipelineConfig(testProjectDir, config)).rejects.toThrow( + 'Write failed' + ); + + expect(secureFs.unlink).toHaveBeenCalled(); + }); + }); + + describe('addStep', () => { + it('should add a new step to config', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValue(error); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 0, + instructions: 'Do something', + colorClass: 'blue', + }; + + const newStep = await pipelineService.addStep(testProjectDir, stepData); + + expect(newStep.name).toBe('New Step'); + expect(newStep.id).toMatch(/^step_/); + expect(newStep.createdAt).toBeDefined(); + expect(newStep.updatedAt).toBeDefined(); + expect(newStep.createdAt).toBe(newStep.updatedAt); + }); + + it('should normalize order values after adding step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 5, // Out of order + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 10, // Out of order + instructions: 'Do something', + colorClass: 'red', + }; + + await pipelineService.addStep(testProjectDir, stepData); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + }); + + it('should sort steps by order before normalizing', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 2, + instructions: 'Do something', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 0, + instructions: 'Do something else', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const stepData = { + name: 'New Step', + order: 1, + instructions: 'Do something', + colorClass: 'red', + }; + + await pipelineService.addStep(testProjectDir, stepData); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + // Should be sorted: step2 (order 0), newStep (order 1), step1 (order 2) + expect(savedConfig.steps[0].id).toBe('step2'); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + expect(savedConfig.steps[2].id).toBe('step1'); + expect(savedConfig.steps[2].order).toBe(2); + }); + }); + + describe('updateStep', () => { + it('should update an existing step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Old Name', + order: 0, + instructions: 'Old instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const updates = { + name: 'New Name', + instructions: 'New instructions', + }; + + const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', updates); + + expect(updatedStep.name).toBe('New Name'); + expect(updatedStep.instructions).toBe('New instructions'); + expect(updatedStep.id).toBe('step1'); + expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z'); + expect(updatedStep.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should throw error if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect( + pipelineService.updateStep(testProjectDir, 'nonexistent', { name: 'New' }) + ).rejects.toThrow('Pipeline step not found: nonexistent'); + }); + + it('should preserve createdAt when updating', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + const updatedStep = await pipelineService.updateStep(testProjectDir, 'step1', { + name: 'Updated', + }); + + expect(updatedStep.createdAt).toBe('2024-01-01T00:00:00.000Z'); + }); + }); + + describe('deleteStep', () => { + it('should delete an existing step', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.deleteStep(testProjectDir, 'step1'); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps).toHaveLength(1); + expect(savedConfig.steps[0].id).toBe('step2'); + expect(savedConfig.steps[0].order).toBe(0); // Normalized + }); + + it('should throw error if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect(pipelineService.deleteStep(testProjectDir, 'nonexistent')).rejects.toThrow( + 'Pipeline step not found: nonexistent' + ); + }); + + it('should normalize order values after deletion', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 5, // Out of order + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 10, // Out of order + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.deleteStep(testProjectDir, 'step2'); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps).toHaveLength(2); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].order).toBe(1); + }); + }); + + describe('reorderSteps', () => { + it('should reorder steps according to stepIds array', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step3', 'step1', 'step2']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].id).toBe('step3'); + expect(savedConfig.steps[0].order).toBe(0); + expect(savedConfig.steps[1].id).toBe('step1'); + expect(savedConfig.steps[1].order).toBe(1); + expect(savedConfig.steps[2].id).toBe('step2'); + expect(savedConfig.steps[2].order).toBe(2); + }); + + it('should update updatedAt timestamp for reordered steps', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step2', 'step1']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + expect(savedConfig.steps[0].updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + expect(savedConfig.steps[1].updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); + }); + + it('should throw error if step ID not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + await expect( + pipelineService.reorderSteps(testProjectDir, ['step1', 'nonexistent']) + ).rejects.toThrow('Pipeline step not found: nonexistent'); + }); + + it('should allow partial reordering (filtering steps)', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + vi.mocked(ensureAutomakerDir).mockResolvedValue(undefined); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.rename).mockResolvedValue(undefined); + + await pipelineService.reorderSteps(testProjectDir, ['step1']); + + const writeCall = vi.mocked(secureFs.writeFile).mock.calls[0]; + const savedConfig = JSON.parse(writeCall[1] as string) as PipelineConfig; + // Should only keep step1, effectively filtering out step2 + expect(savedConfig.steps).toHaveLength(1); + expect(savedConfig.steps[0].id).toBe('step1'); + expect(savedConfig.steps[0].order).toBe(0); + }); + }); + + describe('getNextStatus', () => { + it('should return waiting_approval when no pipeline and skipTests is true', () => { + const nextStatus = pipelineService.getNextStatus('in_progress', null, true); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should return verified when no pipeline and skipTests is false', () => { + const nextStatus = pipelineService.getNextStatus('in_progress', null, false); + expect(nextStatus).toBe('verified'); + }); + + it('should return first pipeline step when coming from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should go to next pipeline step when in middle of pipeline', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false); + expect(nextStatus).toBe('pipeline_step2'); + }); + + it('should go to final status when completing last pipeline step', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false); + expect(nextStatus).toBe('verified'); + }); + + it('should go to waiting_approval when completing last step with skipTests', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should handle invalid pipeline step ID gracefully', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_nonexistent', config, false); + expect(nextStatus).toBe('verified'); + }); + + it('should preserve other statuses unchanged', () => { + const config: PipelineConfig = { + version: 1, + steps: [], + }; + + expect(pipelineService.getNextStatus('backlog', config, false)).toBe('backlog'); + expect(pipelineService.getNextStatus('waiting_approval', config, false)).toBe( + 'waiting_approval' + ); + expect(pipelineService.getNextStatus('verified', config, false)).toBe('verified'); + expect(pipelineService.getNextStatus('completed', config, false)).toBe('completed'); + }); + + it('should sort steps by order when determining next status', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false); + expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2 + }); + + describe('with exclusions', () => { + it('should skip excluded step when coming from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']); + expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2 + }); + + it('should skip excluded step when moving between steps', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + ]); + expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3 + }); + + it('should go to final status when all remaining steps are excluded', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + ]); + expect(nextStatus).toBe('verified'); // No more steps after exclusion + }); + + it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']); + expect(nextStatus).toBe('waiting_approval'); + }); + + it('should go to final status when all steps are excluded from in_progress', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('verified'); + }); + + it('should handle empty exclusions array like no exclusions', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should handle undefined exclusions like no exclusions', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should skip multiple excluded steps in sequence', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step4', + name: 'Step 4', + order: 3, + instructions: 'Instructions', + colorClass: 'yellow', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Exclude step2 and step3 + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step2', + 'step3', + ]); + expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3 + }); + + it('should handle exclusion of non-existent step IDs gracefully', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Exclude a non-existent step - should have no effect + const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [ + 'nonexistent', + ]); + expect(nextStatus).toBe('pipeline_step1'); + }); + + it('should find next valid step when current step becomes excluded mid-flow', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step3', + name: 'Step 3', + order: 2, + instructions: 'Instructions', + colorClass: 'red', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Feature is at step1 but step1 is now excluded - should find next valid step + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('pipeline_step3'); + }); + + it('should go to final status when current step is excluded and no steps remain', () => { + const config: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + { + id: 'step2', + name: 'Step 2', + order: 1, + instructions: 'Instructions', + colorClass: 'green', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + // Feature is at step1 but both steps are excluded + const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [ + 'step1', + 'step2', + ]); + expect(nextStatus).toBe('verified'); + }); + }); + }); + + describe('getStep', () => { + it('should return step by ID', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [ + { + id: 'step1', + name: 'Step 1', + order: 0, + instructions: 'Instructions', + colorClass: 'blue', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const step = await pipelineService.getStep(testProjectDir, 'step1'); + + expect(step).not.toBeNull(); + expect(step?.id).toBe('step1'); + expect(step?.name).toBe('Step 1'); + }); + + it('should return null if step not found', async () => { + const existingConfig: PipelineConfig = { + version: 1, + steps: [], + }; + + vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingConfig) as any); + + const step = await pipelineService.getStep(testProjectDir, 'nonexistent'); + + expect(step).toBeNull(); + }); + }); + + describe('isPipelineStatus', () => { + it('should return true for pipeline statuses', () => { + expect(pipelineService.isPipelineStatus('pipeline_step1')).toBe(true); + expect(pipelineService.isPipelineStatus('pipeline_abc123')).toBe(true); + }); + + it('should return false for non-pipeline statuses', () => { + expect(pipelineService.isPipelineStatus('in_progress')).toBe(false); + expect(pipelineService.isPipelineStatus('waiting_approval')).toBe(false); + expect(pipelineService.isPipelineStatus('verified')).toBe(false); + expect(pipelineService.isPipelineStatus('backlog')).toBe(false); + expect(pipelineService.isPipelineStatus('completed')).toBe(false); + }); + }); + + describe('getStepIdFromStatus', () => { + it('should extract step ID from pipeline status', () => { + expect(pipelineService.getStepIdFromStatus('pipeline_step1')).toBe('step1'); + expect(pipelineService.getStepIdFromStatus('pipeline_abc123')).toBe('abc123'); + }); + + it('should return null for non-pipeline statuses', () => { + expect(pipelineService.getStepIdFromStatus('in_progress')).toBeNull(); + expect(pipelineService.getStepIdFromStatus('waiting_approval')).toBeNull(); + expect(pipelineService.getStepIdFromStatus('verified')).toBeNull(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts b/jules_branch/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..23668a8be0cb21990606402efaa14c0e6266650d --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts @@ -0,0 +1,598 @@ +/** + * Integration tests for pipeline summary accumulation across multiple steps. + * + * These tests verify the end-to-end behavior where: + * 1. Each pipeline step produces a summary via agent-executor → callbacks.saveFeatureSummary() + * 2. FeatureStateManager.saveFeatureSummary() accumulates summaries with step headers + * 3. The emitted auto_mode_summary event contains the full accumulated summary + * 4. The UI can use feature.summary (accumulated) instead of extractSummary() (last-only) + */ + +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 { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { pipelineService } from '@/services/pipeline-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(); + 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(), + })), +})); + +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + +describe('Pipeline Summary Accumulation (Integration)', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + + const baseFeature: Feature = { + id: 'pipeline-feature-1', + name: 'Pipeline Feature', + title: 'Pipeline Feature Title', + description: 'A feature going through pipeline steps', + status: 'pipeline_step1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + const mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + }); + + describe('multi-step pipeline summary accumulation', () => { + it('should accumulate summaries across three pipeline steps in chronological order', async () => { + // --- Step 1: Implementation --- + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Changes\n- Added auth module\n- Created user service' + ); + + const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(step1Feature.summary).toBe( + '### Implementation\n\n## Changes\n- Added auth module\n- Created user service' + ); + + // --- Step 2: Code Review --- + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Feature.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Review Findings\n- Style issues fixed\n- Added error handling' + ); + + const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // --- Step 3: Testing --- + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step3' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step3', summary: step2Feature.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Test Results\n- 42 tests pass\n- 98% coverage' + ); + + const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Verify the full accumulated summary has all three steps separated by --- + const expectedSummary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '- Created user service', + '', + '---', + '', + '### Code Review', + '', + '## Review Findings', + '- Style issues fixed', + '- Added error handling', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + '- 98% coverage', + ].join('\n'); + + expect(finalFeature.summary).toBe(expectedSummary); + }); + + it('should emit the full accumulated summary in auto_mode_summary event', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 1 output'); + + // Verify the event was emitted with correct data + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'pipeline-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output', + }); + + // Step 2 (with accumulated summary from step 1) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_step2', + summary: '### Implementation\n\nStep 1 output', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 2 output'); + + // The event should contain the FULL accumulated summary, not just step 2 + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'pipeline-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output', + }); + }); + }); + + describe('edge cases in pipeline accumulation', () => { + it('should normalize a legacy implementation summary before appending pipeline output', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_step2', + summary: 'Implemented authentication and settings updates.', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Reviewed and approved'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + '### Implementation\n\nImplemented authentication and settings updates.\n\n---\n\n### Code Review\n\nReviewed and approved' + ); + }); + + it('should skip persistence when a pipeline step summary is empty', async () => { + const existingSummary = '### Step 1\n\nFirst step output'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step 2', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + // Empty summary should be ignored to avoid persisting blank sections. + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', ''); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + + it('should handle pipeline step name lookup failure with fallback', async () => { + (pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => { + throw new Error('Pipeline config not loaded'); + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_code_review', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Review output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Fallback: capitalize words from status suffix + expect(savedFeature.summary).toBe('### Code Review\n\nReview output'); + }); + + it('should handle summary with special markdown characters in pipeline mode', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const markdownSummary = [ + '## Changes Made', + '- Fixed **critical bug** in `parser.ts`', + '- Added `validateInput()` function', + '', + '```typescript', + 'const x = 1;', + '```', + '', + '| Column | Value |', + '|--------|-------|', + '| Tests | Pass |', + ].join('\n'); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', markdownSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe(`### Implementation\n\n${markdownSummary}`); + // Verify markdown is preserved + expect(savedFeature.summary).toContain('```typescript'); + expect(savedFeature.summary).toContain('| Column | Value |'); + }); + + it('should correctly handle rapid sequential pipeline steps without data loss', async () => { + // Simulate 5 rapid pipeline steps + const stepConfigs = [ + { name: 'Planning', status: 'pipeline_step1', content: 'Plan created' }, + { name: 'Implementation', status: 'pipeline_step2', content: 'Code written' }, + { name: 'Code Review', status: 'pipeline_step3', content: 'Review complete' }, + { name: 'Testing', status: 'pipeline_step4', content: 'All tests pass' }, + { name: 'Refinement', status: 'pipeline_step5', content: 'Code polished' }, + ]; + + let currentSummary: string | undefined = undefined; + + for (const step of stepConfigs) { + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: step.name, + id: step.status.replace('pipeline_', ''), + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: step.status, summary: currentSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', step.content); + + currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + } + + // Final summary should contain all 5 steps + expect(currentSummary).toContain('### Planning'); + expect(currentSummary).toContain('Plan created'); + expect(currentSummary).toContain('### Implementation'); + expect(currentSummary).toContain('Code written'); + expect(currentSummary).toContain('### Code Review'); + expect(currentSummary).toContain('Review complete'); + expect(currentSummary).toContain('### Testing'); + expect(currentSummary).toContain('All tests pass'); + expect(currentSummary).toContain('### Refinement'); + expect(currentSummary).toContain('Code polished'); + + // Verify there are exactly 4 separators (between 5 steps) + const separatorCount = (currentSummary!.match(/\n\n---\n\n/g) || []).length; + expect(separatorCount).toBe(4); + }); + }); + + describe('UI summary display logic', () => { + it('should emit accumulated summary that UI can display directly (no extractSummary needed)', async () => { + // This test verifies the UI can use feature.summary directly + // without needing to call extractSummary() which only returns the last entry + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First step'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second step'); + + const emittedEvent = (mockEvents.emit as Mock).mock.calls[0][1]; + const accumulatedSummary = emittedEvent.summary; + + // The accumulated summary should contain BOTH steps + expect(accumulatedSummary).toContain('### Implementation'); + expect(accumulatedSummary).toContain('First step'); + expect(accumulatedSummary).toContain('### Testing'); + expect(accumulatedSummary).toContain('Second step'); + }); + + it('should handle single-step pipeline (no accumulation needed)', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Single step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('### Implementation\n\nSingle step output'); + + // No separator should be present for single step + expect(savedFeature.summary).not.toContain('---'); + }); + + it('should preserve chronological order of summaries', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Alpha', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Beta', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary }, + recovered: false, + source: 'main', + }); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second'); + + const finalSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Verify order: Alpha should come before Beta + const alphaIndex = finalSummary!.indexOf('### Alpha'); + const betaIndex = finalSummary!.indexOf('### Beta'); + expect(alphaIndex).toBeLessThan(betaIndex); + }); + }); + + describe('non-pipeline features', () => { + it('should overwrite summary for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'in_progress', // Non-pipeline status + summary: 'Old summary', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'New summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('New summary'); + }); + + it('should not add step headers for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'in_progress', // Non-pipeline status + summary: undefined, + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Simple summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('Simple summary'); + expect(savedFeature.summary).not.toContain('###'); + }); + }); + + describe('summary content edge cases', () => { + it('should handle summary with unicode characters', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const unicodeSummary = 'Test results: ✅ 42 passed, ❌ 0 failed, 🎉 100% coverage'; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', unicodeSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('✅'); + expect(savedFeature.summary).toContain('❌'); + expect(savedFeature.summary).toContain('🎉'); + }); + + it('should handle very long summary content', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + // Generate a very long summary (10KB+) + const longContent = 'This is a line of content.\n'.repeat(500); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', longContent); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary!.length).toBeGreaterThan(10000); + }); + + it('should handle summary with markdown tables', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const tableSummary = ` +## Test Results + +| Test Suite | Passed | Failed | Skipped | +|------------|--------|--------|---------| +| Unit | 42 | 0 | 2 | +| Integration| 15 | 0 | 0 | +| E2E | 8 | 1 | 0 | +`; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', tableSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('| Test Suite |'); + expect(savedFeature.summary).toContain('| Unit | 42 |'); + }); + + it('should handle summary with nested markdown headers', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const nestedSummary = ` +## Main Changes +### Backend +- Added API endpoints +### Frontend +- Created components +#### Deep nesting +- Minor fix +`; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', nestedSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('### Backend'); + expect(savedFeature.summary).toContain('### Frontend'); + expect(savedFeature.summary).toContain('#### Deep nesting'); + }); + }); + + describe('persistence and event ordering', () => { + it('should persist summary BEFORE emitting event', async () => { + const callOrder: string[] = []; + + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockImplementation(async () => { + callOrder.push('persist'); + }); + (mockEvents.emit as Mock).mockImplementation(() => { + callOrder.push('emit'); + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); + + it('should not emit event if persistence fails (error is caught silently)', async () => { + // Note: saveFeatureSummary catches errors internally and logs them + // It does NOT re-throw, so the method completes successfully even on error + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockRejectedValue(new Error('Disk full')); + + // Method completes without throwing (error is logged internally) + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary'); + + // Event should NOT be emitted since persistence failed + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/plan-approval-service.test.ts b/jules_branch/apps/server/tests/unit/services/plan-approval-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5379755a6b5dba5fb639c999d3f1ec650dd58da --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/plan-approval-service.test.ts @@ -0,0 +1,470 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { PlanApprovalService } from '@/services/plan-approval-service.js'; +import type { TypedEventBus } from '@/services/typed-event-bus.js'; +import type { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { SettingsService } from '@/services/settings-service.js'; +import type { Feature } from '@automaker/types'; + +describe('PlanApprovalService', () => { + let service: PlanApprovalService; + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockSettingsService: SettingsService | null; + + beforeEach(() => { + vi.useFakeTimers(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + getUnderlyingEmitter: vi.fn(), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + loadFeature: vi.fn(), + updateFeatureStatus: vi.fn(), + updateFeaturePlanSpec: vi.fn(), + } as unknown as FeatureStateManager; + + mockSettingsService = { + getProjectSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + service = new PlanApprovalService(mockEventBus, mockFeatureStateManager, mockSettingsService); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // Helper to flush pending promises + const flushPromises = () => vi.runAllTimersAsync(); + + describe('waitForApproval', () => { + it('should create pending entry and return Promise', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Flush async operations so the approval is registered + await vi.advanceTimersByTimeAsync(0); + + expect(service.hasPendingApproval('feature-1')).toBe(true); + expect(approvalPromise).toBeInstanceOf(Promise); + }); + + it('should timeout and reject after configured period', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush the async initialization + await vi.advanceTimersByTimeAsync(0); + + // Advance time by 30 minutes + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval timed out after 30 minutes - feature execution cancelled' + ); + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should use configured timeout from project settings', async () => { + // Configure 10 minute timeout + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: 10 * 60 * 1000, + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush the async initialization + await vi.advanceTimersByTimeAsync(0); + + // Advance time by 10 minutes - should timeout + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval timed out after 10 minutes - feature execution cancelled' + ); + }); + + it('should fall back to default timeout when settings service is null', async () => { + // Create service without settings service + const serviceNoSettings = new PlanApprovalService( + mockEventBus, + mockFeatureStateManager, + null + ); + + const approvalPromise = serviceNoSettings.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + // Flush async + await vi.advanceTimersByTimeAsync(0); + + // Advance by 29 minutes - should not timeout yet + await vi.advanceTimersByTimeAsync(29 * 60 * 1000); + expect(serviceNoSettings.hasPendingApproval('feature-1')).toBe(true); + + // Advance by 1 more minute (total 30) - should timeout + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + + await expect(approvalPromise).rejects.toThrow('Plan approval timed out'); + }); + }); + + describe('resolveApproval', () => { + it('should resolve Promise correctly when approved=true', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.resolveApproval('feature-1', true, { + editedPlan: 'Updated plan', + feedback: 'Looks good!', + }); + + expect(result).toEqual({ success: true }); + + const approval = await approvalPromise; + expect(approval).toEqual({ + approved: true, + editedPlan: 'Updated plan', + feedback: 'Looks good!', + }); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should resolve Promise correctly when approved=false', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + const result = await service.resolveApproval('feature-1', false, { + feedback: 'Need more details', + }); + + expect(result).toEqual({ success: true }); + + const approval = await approvalPromise; + expect(approval).toEqual({ + approved: false, + editedPlan: undefined, + feedback: 'Need more details', + }); + }); + + it('should emit plan_rejected event when rejected with feedback', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', false, { + feedback: 'Need changes', + }); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('plan_rejected', { + featureId: 'feature-1', + projectPath: '/project', + feedback: 'Need changes', + }); + }); + + it('should update planSpec status to approved when approved', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', true, { + editedPlan: 'New plan content', + }); + + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'approved', + reviewedByUser: true, + content: 'New plan content', + }) + ); + }); + + it('should update planSpec status to rejected when rejected', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + await service.resolveApproval('feature-1', false); + + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'rejected', + reviewedByUser: true, + }) + ); + }); + + it('should clear timeout on normal resolution (no double-fire)', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + // Advance 10 minutes then resolve + await vi.advanceTimersByTimeAsync(10 * 60 * 1000); + await service.resolveApproval('feature-1', true); + + const approval = await approvalPromise; + expect(approval.approved).toBe(true); + + // Advance past the 30 minute mark - should NOT reject + await vi.advanceTimersByTimeAsync(25 * 60 * 1000); + + // If timeout wasn't cleared, we'd see issues + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return error when no pending approval and no recovery possible', async () => { + const result = await service.resolveApproval('non-existent', true); + + expect(result).toEqual({ + success: false, + error: 'No pending approval for feature non-existent', + }); + }); + }); + + describe('recovery path', () => { + it('should return needsRecovery=true when planSpec.status is generated and approved', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'in_progress', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'generated', + version: 1, + reviewedByUser: false, + content: 'Original plan', + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + // No pending approval in Map, but feature has generated planSpec + const result = await service.resolveApproval('feature-1', true, { + projectPath: '/project', + editedPlan: 'Edited plan', + }); + + expect(result).toEqual({ success: true, needsRecovery: true }); + + // Should update planSpec + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'approved', + content: 'Edited plan', + }) + ); + }); + + it('should handle recovery rejection correctly', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'in_progress', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'generated', + version: 1, + reviewedByUser: false, + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + const result = await service.resolveApproval('feature-1', false, { + projectPath: '/project', + feedback: 'Rejected via recovery', + }); + + expect(result).toEqual({ success: true }); // No needsRecovery for rejections + + // Should update planSpec to rejected + expect(mockFeatureStateManager.updateFeaturePlanSpec).toHaveBeenCalledWith( + '/project', + 'feature-1', + expect.objectContaining({ + status: 'rejected', + reviewedByUser: true, + }) + ); + + // Should update feature status to backlog + expect(mockFeatureStateManager.updateFeatureStatus).toHaveBeenCalledWith( + '/project', + 'feature-1', + 'backlog' + ); + + // Should emit plan_rejected event + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('plan_rejected', { + featureId: 'feature-1', + projectPath: '/project', + feedback: 'Rejected via recovery', + }); + }); + + it('should not trigger recovery when planSpec.status is not generated', async () => { + const mockFeature: Feature = { + id: 'feature-1', + name: 'Test Feature', + title: 'Test Feature', + description: 'Test', + status: 'pending', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + planSpec: { + status: 'pending', // Not 'generated' + version: 1, + reviewedByUser: false, + }, + }; + + vi.mocked(mockFeatureStateManager.loadFeature).mockResolvedValue(mockFeature); + + const result = await service.resolveApproval('feature-1', true, { + projectPath: '/project', + }); + + expect(result).toEqual({ + success: false, + error: 'No pending approval for feature feature-1', + }); + }); + }); + + describe('cancelApproval', () => { + it('should reject pending Promise with cancellation error', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + service.cancelApproval('feature-1'); + + await expect(approvalPromise).rejects.toThrow( + 'Plan approval cancelled - feature was stopped' + ); + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should clear timeout on cancellation', async () => { + const approvalPromise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + service.cancelApproval('feature-1'); + + // Verify rejection happened + await expect(approvalPromise).rejects.toThrow(); + + // Advance past timeout - should not cause any issues + await vi.advanceTimersByTimeAsync(35 * 60 * 1000); + + // No additional errors should occur + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should do nothing when no pending approval exists', () => { + // Should not throw + expect(() => service.cancelApproval('non-existent')).not.toThrow(); + }); + }); + + describe('hasPendingApproval', () => { + it('should return true when approval is pending', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + + expect(service.hasPendingApproval('feature-1')).toBe(true); + }); + + it('should return false when no approval is pending', () => { + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return false after approval is resolved', async () => { + service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + await service.resolveApproval('feature-1', true); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + + it('should return false after approval is cancelled', async () => { + const promise = service.waitForApproval('feature-1', '/project'); + await vi.advanceTimersByTimeAsync(0); + service.cancelApproval('feature-1'); + + // Consume the rejection + await promise.catch(() => {}); + + expect(service.hasPendingApproval('feature-1')).toBe(false); + }); + }); + + describe('getTimeoutMs (via waitForApproval behavior)', () => { + it('should return configured value from project settings', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: 5 * 60 * 1000, // 5 minutes + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should not timeout at 4 minutes + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + expect(service.hasPendingApproval('feature-1')).toBe(true); + + // Should timeout at 5 minutes + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 5 minutes'); + }); + + it('should return default when settings service throws', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockRejectedValue(new Error('Failed')); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should use default 30 minute timeout + await vi.advanceTimersByTimeAsync(29 * 60 * 1000); + expect(service.hasPendingApproval('feature-1')).toBe(true); + + await vi.advanceTimersByTimeAsync(1 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 30 minutes'); + }); + + it('should return default when planApprovalTimeoutMs is invalid', async () => { + vi.mocked(mockSettingsService!.getProjectSettings).mockResolvedValue({ + planApprovalTimeoutMs: -1, // Invalid + } as never); + + const approvalPromise = service.waitForApproval('feature-1', '/project'); + // Attach catch to prevent unhandled rejection warning (will be properly asserted below) + approvalPromise.catch(() => {}); + await vi.advanceTimersByTimeAsync(0); + + // Should use default 30 minute timeout + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + await expect(approvalPromise).rejects.toThrow('timed out after 30 minutes'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/recovery-service.test.ts b/jules_branch/apps/server/tests/unit/services/recovery-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd99fc088dc0e02e5baedb0e4b190a009af39555 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/recovery-service.test.ts @@ -0,0 +1,792 @@ +/** + * Unit tests for RecoveryService + * + * Tests crash recovery and feature resumption functionality: + * - Execution state persistence (save/load/clear) + * - Context detection (agent-output.md exists) + * - Feature resumption flow (pipeline vs non-pipeline) + * - Interrupted feature detection and batch resumption + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import { RecoveryService, DEFAULT_EXECUTION_STATE } from '@/services/recovery-service.js'; +import type { Feature } from '@automaker/types'; + +/** + * Helper to normalize paths for cross-platform test compatibility. + * Uses path.normalize (not path.resolve) to match path.join behavior in production code. + */ +const normalizePath = (p: string): string => path.normalize(p); + +// Mock dependencies +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + readJsonWithRecovery: vi.fn().mockResolvedValue({ data: null, wasRecovered: false }), + logRecoveryWarning: vi.fn(), + DEFAULT_BACKUP_COUNT: 5, +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: (projectPath: string, featureId: string) => + `${projectPath}/.automaker/features/${featureId}`, + getFeaturesDir: (projectPath: string) => `${projectPath}/.automaker/features`, + getExecutionStatePath: (projectPath: string) => `${projectPath}/.automaker/execution-state.json`, + ensureAutomakerDir: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn().mockRejectedValue(new Error('ENOENT')), + readFile: vi.fn().mockRejectedValue(new Error('ENOENT')), + writeFile: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + readdir: vi.fn().mockResolvedValue([]), +})); + +vi.mock('@/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + resumeFeatureTemplate: 'Resume: {{featurePrompt}}\n\nPrevious context:\n{{previousContext}}', + }, + }), +})); + +describe('recovery-service.ts', () => { + // Import mocked modules for access in tests + let secureFs: typeof import('@/lib/secure-fs.js'); + let utils: typeof import('@automaker/utils'); + + // Mock dependencies + const mockEventBus = { + emitAutoModeEvent: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }; + + const mockConcurrencyManager = { + getAllRunning: vi.fn().mockReturnValue([]), + getRunningFeature: vi.fn().mockReturnValue(null), + acquire: vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + abortController: new AbortController(), + projectPath: '/test/project', + isAutoMode: false, + startTime: Date.now(), + leaseCount: 1, + })), + release: vi.fn(), + getRunningCountForWorktree: vi.fn().mockReturnValue(0), + }; + + const mockSettingsService = null; + + // Callback mocks - initialize empty, set up in beforeEach + let mockExecuteFeature: ReturnType; + let mockLoadFeature: ReturnType; + let mockDetectPipelineStatus: ReturnType; + let mockResumePipeline: ReturnType; + let mockIsFeatureRunning: ReturnType; + let mockAcquireRunningFeature: ReturnType; + let mockReleaseRunningFeature: ReturnType; + + let service: RecoveryService; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + secureFs = await import('@/lib/secure-fs.js'); + utils = await import('@automaker/utils'); + + // Reset secure-fs mocks to default behavior + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + vi.mocked(secureFs.writeFile).mockResolvedValue(undefined); + vi.mocked(secureFs.unlink).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([]); + + // Reset all callback mocks with default implementations + mockExecuteFeature = vi.fn().mockResolvedValue(undefined); + mockLoadFeature = vi.fn().mockResolvedValue(null); + mockDetectPipelineStatus = vi.fn().mockResolvedValue({ + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }); + mockResumePipeline = vi.fn().mockResolvedValue(undefined); + mockIsFeatureRunning = vi.fn().mockReturnValue(false); + mockAcquireRunningFeature = vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + abortController: new AbortController(), + })); + mockReleaseRunningFeature = vi.fn(); + + service = new RecoveryService( + mockEventBus as any, + mockConcurrencyManager as any, + mockSettingsService, + mockExecuteFeature, + mockLoadFeature, + mockDetectPipelineStatus, + mockResumePipeline, + mockIsFeatureRunning, + mockAcquireRunningFeature, + mockReleaseRunningFeature + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('DEFAULT_EXECUTION_STATE', () => { + it('has correct default values', () => { + expect(DEFAULT_EXECUTION_STATE).toEqual({ + version: 1, + autoLoopWasRunning: false, + maxConcurrency: expect.any(Number), + projectPath: '', + branchName: null, + runningFeatureIds: [], + savedAt: '', + }); + }); + }); + + describe('saveExecutionStateForProject', () => { + it('writes correct JSON to execution state path', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/test/project' }, + { featureId: 'feature-2', projectPath: '/test/project' }, + { featureId: 'feature-3', projectPath: '/other/project' }, + ]); + + await service.saveExecutionStateForProject('/test/project', 'feature-branch', 3); + + expect(secureFs.writeFile).toHaveBeenCalledWith( + '/test/project/.automaker/execution-state.json', + expect.any(String), + 'utf-8' + ); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent).toMatchObject({ + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 3, + projectPath: '/test/project', + branchName: 'feature-branch', + runningFeatureIds: ['feature-1', 'feature-2'], + }); + expect(writtenContent.savedAt).toBeDefined(); + }); + + it('filters running features by project path', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/project-a' }, + { featureId: 'feature-2', projectPath: '/project-b' }, + ]); + + await service.saveExecutionStateForProject('/project-a', null, 2); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent.runningFeatureIds).toEqual(['feature-1']); + }); + + it('handles null branch name for main worktree', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([]); + await service.saveExecutionStateForProject('/test/project', null, 1); + + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent.branchName).toBeNull(); + }); + }); + + describe('saveExecutionState (legacy)', () => { + it('saves execution state with legacy format', async () => { + mockConcurrencyManager.getAllRunning.mockReturnValue([ + { featureId: 'feature-1', projectPath: '/test' }, + ]); + + await service.saveExecutionState('/test/project', true, 5); + + expect(secureFs.writeFile).toHaveBeenCalled(); + const writtenContent = JSON.parse(vi.mocked(secureFs.writeFile).mock.calls[0][1] as string); + expect(writtenContent).toMatchObject({ + autoLoopWasRunning: true, + maxConcurrency: 5, + branchName: null, // Legacy uses main worktree + }); + }); + }); + + describe('loadExecutionState', () => { + it('parses JSON correctly when file exists', async () => { + const mockState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 4, + projectPath: '/test/project', + branchName: 'dev', + runningFeatureIds: ['f1', 'f2'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(mockState)); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(mockState); + }); + + it('returns default state on ENOENT error', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readFile).mockRejectedValueOnce(error); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(DEFAULT_EXECUTION_STATE); + }); + + it('returns default state on other errors and logs', async () => { + vi.mocked(secureFs.readFile).mockRejectedValueOnce(new Error('Permission denied')); + + const result = await service.loadExecutionState('/test/project'); + + expect(result).toEqual(DEFAULT_EXECUTION_STATE); + }); + }); + + describe('clearExecutionState', () => { + it('removes execution state file', async () => { + await service.clearExecutionState('/test/project'); + + expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json'); + }); + + it('does not throw on ENOENT error', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.unlink).mockRejectedValueOnce(error); + + await expect(service.clearExecutionState('/test/project')).resolves.not.toThrow(); + }); + + it('logs error on other failures', async () => { + vi.mocked(secureFs.unlink).mockRejectedValueOnce(new Error('Permission denied')); + + await expect(service.clearExecutionState('/test/project')).resolves.not.toThrow(); + }); + }); + + describe('contextExists', () => { + it('returns true when agent-output.md exists', async () => { + vi.mocked(secureFs.access).mockResolvedValueOnce(undefined); + + const result = await service.contextExists('/test/project', 'feature-1'); + + expect(result).toBe(true); + expect(secureFs.access).toHaveBeenCalledWith( + normalizePath('/test/project/.automaker/features/feature-1/agent-output.md') + ); + }); + + it('returns false when agent-output.md is missing', async () => { + vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await service.contextExists('/test/project', 'feature-1'); + + expect(result).toBe(false); + }); + }); + + describe('resumeFeature', () => { + const mockFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + status: 'in_progress', + }; + + beforeEach(() => { + mockLoadFeature.mockResolvedValue(mockFeature); + }); + + it('skips if feature already running (idempotent)', async () => { + mockIsFeatureRunning.mockReturnValueOnce(true); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockLoadFeature).not.toHaveBeenCalled(); + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('detects pipeline status for feature', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockDetectPipelineStatus).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + }); + + it('delegates to resumePipeline for pipeline features', async () => { + const pipelineInfo = { + isPipeline: true, + stepId: 'test', + stepIndex: 1, + totalSteps: 3, + step: { + id: 'test', + name: 'Test Step', + command: 'npm test', + type: 'test' as const, + order: 1, + }, + config: null, + }; + mockDetectPipelineStatus.mockResolvedValueOnce(pipelineInfo); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockResumePipeline).toHaveBeenCalledWith( + '/test/project', + mockFeature, + false, + pipelineInfo + ); + expect(mockExecuteFeature).not.toHaveBeenCalled(); + }); + + it('calls executeFeature with continuation prompt when context exists', async () => { + // Reset settings-helpers mock before this test + const settingsHelpers = await import('@/lib/settings-helpers.js'); + vi.mocked(settingsHelpers.getPromptCustomization).mockResolvedValue({ + taskExecution: { + resumeFeatureTemplate: + 'Resume: {{featurePrompt}}\n\nPrevious context:\n{{previousContext}}', + implementationInstructions: '', + playwrightVerificationInstructions: '', + }, + } as any); + + vi.mocked(secureFs.access).mockResolvedValueOnce(undefined); + vi.mocked(secureFs.readFile).mockResolvedValueOnce('Previous agent output content'); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_resuming', + expect.objectContaining({ + featureId: 'feature-1', + hasContext: true, + }) + ); + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + false, + false, + undefined, + expect.objectContaining({ + continuationPrompt: expect.stringContaining('Previous agent output content'), + _calledInternally: true, + }) + ); + }); + + it('calls executeFeature fresh when no context', async () => { + vi.mocked(secureFs.access).mockRejectedValueOnce(new Error('ENOENT')); + + await service.resumeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_resuming', + expect.objectContaining({ + featureId: 'feature-1', + hasContext: false, + }) + ); + expect(mockExecuteFeature).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + false, + false, + undefined, + expect.objectContaining({ + _calledInternally: true, + }) + ); + }); + + it('releases running feature in finally block', async () => { + mockLoadFeature.mockRejectedValueOnce(new Error('Feature not found')); + + await expect(service.resumeFeature('/test/project', 'feature-1')).rejects.toThrow(); + + expect(mockReleaseRunningFeature).toHaveBeenCalledWith('feature-1'); + }); + + it('throws error if feature not found', async () => { + mockLoadFeature.mockResolvedValueOnce(null); + + await expect(service.resumeFeature('/test/project', 'feature-1')).rejects.toThrow( + 'Feature feature-1 not found' + ); + }); + + it('acquires running feature with allowReuse when calledInternally', async () => { + vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT')); + await service.resumeFeature('/test/project', 'feature-1', false, true); + + expect(mockAcquireRunningFeature).toHaveBeenCalledWith({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: false, + allowReuse: true, + }); + }); + }); + + describe('resumeInterruptedFeatures', () => { + it('finds features with in_progress status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1'], + }) + ); + }); + + it('finds features with interrupted status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'interrupted' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'interrupted', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1'], + }) + ); + }); + + it('finds features with pipeline_* status', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'pipeline_test' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'pipeline_test', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ id: 'feature-1', status: 'pipeline_test' }), + ]), + }) + ); + }); + + it('finds reconciled features using execution state (ready/backlog from previously running)', async () => { + // Simulate execution state with previously running feature IDs + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 2, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1', 'feature-2'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + { name: 'feature-2', isDirectory: () => true } as any, + { name: 'feature-3', isDirectory: () => true } as any, + ]); + // feature-1 was reconciled from in_progress to ready + // feature-2 was reconciled from in_progress to backlog + // feature-3 is in backlog but was NOT previously running + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-2', title: 'Feature 2', status: 'backlog' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-3', title: 'Feature 3', status: 'backlog' }, + wasRecovered: false, + }); + + mockLoadFeature + .mockResolvedValueOnce({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }) + .mockResolvedValueOnce({ + id: 'feature-2', + title: 'Feature 2', + status: 'backlog', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should resume feature-1 and feature-2 (from execution state) but NOT feature-3 + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + featureIds: ['feature-1', 'feature-2'], + }) + ); + }); + + it('clears execution state after successful resume', async () => { + // Simulate execution state + const executionState = { + version: 1, + autoLoopWasRunning: true, + maxConcurrency: 1, + projectPath: '/test/project', + branchName: null, + runningFeatureIds: ['feature-1'], + savedAt: '2026-01-27T12:00:00Z', + }; + vi.mocked(secureFs.readFile).mockResolvedValueOnce(JSON.stringify(executionState)); + + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'ready' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'ready', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should clear execution state after resuming + expect(secureFs.unlink).toHaveBeenCalledWith('/test/project/.automaker/execution-state.json'); + }); + + it('distinguishes features with/without context', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-with', isDirectory: () => true } as any, + { name: 'feature-without', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-with', title: 'With Context', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-without', title: 'Without Context', status: 'in_progress' }, + wasRecovered: false, + }); + + // First feature has context, second doesn't + vi.mocked(secureFs.access) + .mockResolvedValueOnce(undefined) // feature-with has context + .mockRejectedValueOnce(new Error('ENOENT')); // feature-without doesn't + + mockLoadFeature + .mockResolvedValueOnce({ + id: 'feature-with', + title: 'With Context', + status: 'in_progress', + description: 'Test', + }) + .mockResolvedValueOnce({ + id: 'feature-without', + title: 'Without Context', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + features: expect.arrayContaining([ + expect.objectContaining({ id: 'feature-with', hasContext: true }), + expect.objectContaining({ id: 'feature-without', hasContext: false }), + ]), + }) + ); + }); + + it('emits auto_mode_resuming_features event', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }); + + mockLoadFeature.mockResolvedValue({ + id: 'feature-1', + title: 'Feature 1', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.objectContaining({ + message: expect.stringContaining('interrupted feature'), + projectPath: '/test/project', + }) + ); + }); + + it('skips features already running (idempotent)', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'in_progress' }, + wasRecovered: false, + }); + + mockIsFeatureRunning.mockReturnValue(true); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should emit event but not actually resume + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.anything() + ); + // But resumeFeature should exit early due to isFeatureRunning check + expect(mockLoadFeature).not.toHaveBeenCalled(); + }); + + it('handles ENOENT for features directory gracefully', async () => { + const error = new Error('Directory not found') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(secureFs.readdir).mockRejectedValueOnce(error); + + await expect(service.resumeInterruptedFeatures('/test/project')).resolves.not.toThrow(); + }); + + it('continues with other features when one fails', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-fail', isDirectory: () => true } as any, + { name: 'feature-success', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery) + .mockResolvedValueOnce({ + data: { id: 'feature-fail', title: 'Fail', status: 'in_progress' }, + wasRecovered: false, + }) + .mockResolvedValueOnce({ + data: { id: 'feature-success', title: 'Success', status: 'in_progress' }, + wasRecovered: false, + }); + + // First feature throws during resume, second succeeds + mockLoadFeature.mockRejectedValueOnce(new Error('Resume failed')).mockResolvedValueOnce({ + id: 'feature-success', + title: 'Success', + status: 'in_progress', + description: 'Test', + }); + + await service.resumeInterruptedFeatures('/test/project'); + + // Should still attempt to resume the second feature + expect(mockLoadFeature).toHaveBeenCalledTimes(2); + }); + + it('logs info when no interrupted features found', async () => { + vi.mocked(secureFs.readdir).mockResolvedValueOnce([ + { name: 'feature-1', isDirectory: () => true } as any, + ]); + vi.mocked(utils.readJsonWithRecovery).mockResolvedValueOnce({ + data: { id: 'feature-1', title: 'Feature 1', status: 'completed' }, + wasRecovered: false, + }); + + await service.resumeInterruptedFeatures('/test/project'); + + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_resuming_features', + expect.anything() + ); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/settings-service.test.ts b/jules_branch/apps/server/tests/unit/services/settings-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4188cd9de8615376901f55110d9abea371776990 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/settings-service.test.ts @@ -0,0 +1,1045 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; +import { SettingsService } from '@/services/settings-service.js'; +import { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_CREDENTIALS, + DEFAULT_PROJECT_SETTINGS, + SETTINGS_VERSION, + CREDENTIALS_VERSION, + PROJECT_SETTINGS_VERSION, + type GlobalSettings, + type Credentials, + type ProjectSettings, +} from '@/types/settings.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; + +describe('settings-service.ts', () => { + let testDataDir: string; + let testProjectDir: string; + let settingsService: SettingsService; + + /** + * Helper to create a test ntfy endpoint with sensible defaults + */ + function createTestNtfyEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + + beforeEach(async () => { + testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`); + testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`); + await fs.mkdir(testDataDir, { recursive: true }); + await fs.mkdir(testProjectDir, { recursive: true }); + settingsService = new SettingsService(testDataDir); + }); + + afterEach(async () => { + try { + await fs.rm(testDataDir, { recursive: true, force: true }); + await fs.rm(testProjectDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getGlobalSettings', () => { + it('should return default settings when file does not exist', async () => { + const settings = await settingsService.getGlobalSettings(); + expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS); + }); + + it('should read and return existing settings', async () => { + const customSettings: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'light', + sidebarOpen: false, + maxConcurrency: 5, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.theme).toBe('light'); + expect(settings.sidebarOpen).toBe(false); + expect(settings.maxConcurrency).toBe(5); + }); + + it('should merge with defaults for missing properties', async () => { + const partialSettings = { + version: SETTINGS_VERSION, + theme: 'dark', + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.theme).toBe('dark'); + expect(settings.sidebarOpen).toBe(DEFAULT_GLOBAL_SETTINGS.sidebarOpen); + expect(settings.maxConcurrency).toBe(DEFAULT_GLOBAL_SETTINGS.maxConcurrency); + }); + + it('should merge keyboard shortcuts deeply', async () => { + const customSettings: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + keyboardShortcuts: { + ...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, + board: 'B', + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.keyboardShortcuts.board).toBe('B'); + expect(settings.keyboardShortcuts.agent).toBe( + DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent + ); + }); + }); + + describe('updateGlobalSettings', () => { + it('should create settings file with updates', async () => { + const updates: Partial = { + theme: 'light', + sidebarOpen: false, + }; + + const updated = await settingsService.updateGlobalSettings(updates); + + expect(updated.theme).toBe('light'); + expect(updated.sidebarOpen).toBe(false); + expect(updated.version).toBe(SETTINGS_VERSION); + + const settingsPath = path.join(testDataDir, 'settings.json'); + const fileContent = await fs.readFile(settingsPath, 'utf-8'); + const saved = JSON.parse(fileContent); + expect(saved.theme).toBe('light'); + expect(saved.sidebarOpen).toBe(false); + }); + + it('should merge updates with existing settings', async () => { + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'dark', + maxConcurrency: 3, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updates: Partial = { + theme: 'light', + }; + + const updated = await settingsService.updateGlobalSettings(updates); + + expect(updated.theme).toBe('light'); + expect(updated.maxConcurrency).toBe(3); // Preserved from initial + }); + + it('should deep merge keyboard shortcuts', async () => { + const updates: Partial = { + keyboardShortcuts: { + board: 'B', + }, + }; + + const updated = await settingsService.updateGlobalSettings(updates); + + expect(updated.keyboardShortcuts.board).toBe('B'); + expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent); + }); + + it('should not overwrite non-empty projects with an empty array (data loss guard)', async () => { + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'solarized' as GlobalSettings['theme'], + projects: [ + { + id: 'proj1', + name: 'Project 1', + path: '/tmp/project-1', + lastOpened: new Date().toISOString(), + }, + ] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + projects: [], + theme: 'light', + } as any); + + expect(updated.projects.length).toBe(1); + expect((updated.projects as any)[0]?.id).toBe('proj1'); + // Theme should be preserved in the same request if it attempted to wipe projects + expect(updated.theme).toBe('solarized'); + }); + + it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Ntfy', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // The empty array should be ignored - existing endpoints should be preserved + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow adding new ntfyEndpoints to existing list', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'First Endpoint', + topic: 'first-topic', + }); + const endpoint2 = createTestNtfyEndpoint({ + id: 'endpoint-2', + name: 'Second Endpoint', + serverUrl: 'https://ntfy.example.com', + topic: 'second-topic', + authType: 'token', + token: 'test-token', + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [endpoint1, endpoint2] as any, + }); + + // Both endpoints should be present + expect(updated.ntfyEndpoints?.length).toBe(2); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2'); + }); + + it('should allow updating ntfyEndpoints with non-empty array', async () => { + const originalEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Original Name', + topic: 'original-topic', + }); + const updatedEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Updated Name', + topic: 'updated-topic', + enabled: false, + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [originalEndpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [updatedEndpoint] as any, + }); + + // The update should go through with the new values + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name'); + expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic'); + expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false); + }); + + it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => { + // Start with no endpoints (default state) + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2)); + + // Trying to set empty array should be fine when there are no existing endpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // Empty array should be set (no data loss because there was nothing to lose) + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + + it('should preserve ntfyEndpoints while updating other settings', async () => { + const endpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Endpoint', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'dark', + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Update theme without sending ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + theme: 'light', + }); + + // Theme should be updated + expect(updated.theme).toBe('light'); + // ntfyEndpoints should be preserved from existing settings + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow clearing ntfyEndpoints with escape hatch flag', async () => { + const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Use escape hatch to intentionally clear ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + __allowEmptyNtfyEndpoints: true, + } as any); + + // The empty array should be applied because escape hatch was used + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + + it('should create data directory if it does not exist', async () => { + const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`); + const newService = new SettingsService(newDataDir); + + await newService.updateGlobalSettings({ theme: 'light' }); + + const stats = await fs.stat(newDataDir); + expect(stats.isDirectory()).toBe(true); + + await fs.rm(newDataDir, { recursive: true, force: true }); + }); + }); + + describe('hasGlobalSettings', () => { + it('should return false when settings file does not exist', async () => { + const exists = await settingsService.hasGlobalSettings(); + expect(exists).toBe(false); + }); + + it('should return true when settings file exists', async () => { + await settingsService.updateGlobalSettings({ theme: 'light' }); + const exists = await settingsService.hasGlobalSettings(); + expect(exists).toBe(true); + }); + }); + + describe('getCredentials', () => { + it('should return default credentials when file does not exist', async () => { + const credentials = await settingsService.getCredentials(); + expect(credentials).toEqual(DEFAULT_CREDENTIALS); + }); + + it('should read and return existing credentials', async () => { + const customCredentials: Credentials = { + ...DEFAULT_CREDENTIALS, + apiKeys: { + anthropic: 'sk-test-key', + }, + }; + const credentialsPath = path.join(testDataDir, 'credentials.json'); + await fs.writeFile(credentialsPath, JSON.stringify(customCredentials, null, 2)); + + const credentials = await settingsService.getCredentials(); + expect(credentials.apiKeys.anthropic).toBe('sk-test-key'); + }); + + it('should merge with defaults for missing api keys', async () => { + const partialCredentials = { + version: CREDENTIALS_VERSION, + apiKeys: { + anthropic: 'sk-test', + }, + }; + const credentialsPath = path.join(testDataDir, 'credentials.json'); + await fs.writeFile(credentialsPath, JSON.stringify(partialCredentials, null, 2)); + + const credentials = await settingsService.getCredentials(); + expect(credentials.apiKeys.anthropic).toBe('sk-test'); + }); + }); + + describe('updateCredentials', () => { + it('should create credentials file with updates', async () => { + const updates: Partial = { + apiKeys: { + anthropic: 'sk-test-key', + }, + }; + + const updated = await settingsService.updateCredentials(updates); + + expect(updated.apiKeys.anthropic).toBe('sk-test-key'); + expect(updated.version).toBe(CREDENTIALS_VERSION); + + const credentialsPath = path.join(testDataDir, 'credentials.json'); + const fileContent = await fs.readFile(credentialsPath, 'utf-8'); + const saved = JSON.parse(fileContent); + expect(saved.apiKeys.anthropic).toBe('sk-test-key'); + }); + + it('should merge updates with existing credentials', async () => { + const initial: Credentials = { + ...DEFAULT_CREDENTIALS, + apiKeys: { + anthropic: 'sk-initial', + }, + }; + const credentialsPath = path.join(testDataDir, 'credentials.json'); + await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2)); + + const updates: Partial = { + apiKeys: { + anthropic: 'sk-updated', + }, + }; + + const updated = await settingsService.updateCredentials(updates); + + expect(updated.apiKeys.anthropic).toBe('sk-updated'); + }); + + it('should deep merge api keys', async () => { + const initial: Credentials = { + ...DEFAULT_CREDENTIALS, + apiKeys: { + anthropic: 'sk-anthropic', + }, + }; + const credentialsPath = path.join(testDataDir, 'credentials.json'); + await fs.writeFile(credentialsPath, JSON.stringify(initial, null, 2)); + + const updates: Partial = { + apiKeys: { + anthropic: 'sk-updated-anthropic', + }, + }; + + const updated = await settingsService.updateCredentials(updates); + + expect(updated.apiKeys.anthropic).toBe('sk-updated-anthropic'); + }); + }); + + describe('getMaskedCredentials', () => { + it('should return masked credentials for empty keys', async () => { + const masked = await settingsService.getMaskedCredentials(); + expect(masked.anthropic.configured).toBe(false); + expect(masked.anthropic.masked).toBe(''); + }); + + it('should mask keys correctly', async () => { + await settingsService.updateCredentials({ + apiKeys: { + anthropic: 'sk-ant-api03-1234567890abcdef', + }, + }); + + const masked = await settingsService.getMaskedCredentials(); + expect(masked.anthropic.configured).toBe(true); + expect(masked.anthropic.masked).toBe('sk-a...cdef'); + }); + + it('should handle short keys', async () => { + await settingsService.updateCredentials({ + apiKeys: { + anthropic: 'short', + }, + }); + + const masked = await settingsService.getMaskedCredentials(); + expect(masked.anthropic.configured).toBe(true); + expect(masked.anthropic.masked).toBe(''); + }); + }); + + describe('hasCredentials', () => { + it('should return false when credentials file does not exist', async () => { + const exists = await settingsService.hasCredentials(); + expect(exists).toBe(false); + }); + + it('should return true when credentials file exists', async () => { + await settingsService.updateCredentials({ + apiKeys: { anthropic: 'test' }, + }); + const exists = await settingsService.hasCredentials(); + expect(exists).toBe(true); + }); + }); + + describe('getProjectSettings', () => { + it('should return default settings when file does not exist', async () => { + const settings = await settingsService.getProjectSettings(testProjectDir); + expect(settings).toEqual(DEFAULT_PROJECT_SETTINGS); + }); + + it('should read and return existing project settings', async () => { + const customSettings: ProjectSettings = { + ...DEFAULT_PROJECT_SETTINGS, + theme: 'light', + useWorktrees: true, + }; + const automakerDir = path.join(testProjectDir, '.automaker'); + await fs.mkdir(automakerDir, { recursive: true }); + const settingsPath = path.join(automakerDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(customSettings, null, 2)); + + const settings = await settingsService.getProjectSettings(testProjectDir); + expect(settings.theme).toBe('light'); + expect(settings.useWorktrees).toBe(true); + }); + + it('should merge with defaults for missing properties', async () => { + const partialSettings = { + version: PROJECT_SETTINGS_VERSION, + theme: 'dark', + }; + const automakerDir = path.join(testProjectDir, '.automaker'); + await fs.mkdir(automakerDir, { recursive: true }); + const settingsPath = path.join(automakerDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(partialSettings, null, 2)); + + const settings = await settingsService.getProjectSettings(testProjectDir); + expect(settings.theme).toBe('dark'); + expect(settings.version).toBe(PROJECT_SETTINGS_VERSION); + }); + }); + + describe('updateProjectSettings', () => { + it('should create project settings file with updates', async () => { + const updates: Partial = { + theme: 'light', + useWorktrees: true, + }; + + const updated = await settingsService.updateProjectSettings(testProjectDir, updates); + + expect(updated.theme).toBe('light'); + expect(updated.useWorktrees).toBe(true); + expect(updated.version).toBe(PROJECT_SETTINGS_VERSION); + + const automakerDir = path.join(testProjectDir, '.automaker'); + const settingsPath = path.join(automakerDir, 'settings.json'); + const fileContent = await fs.readFile(settingsPath, 'utf-8'); + const saved = JSON.parse(fileContent); + expect(saved.theme).toBe('light'); + expect(saved.useWorktrees).toBe(true); + }); + + it('should merge updates with existing project settings', async () => { + const initial: ProjectSettings = { + ...DEFAULT_PROJECT_SETTINGS, + theme: 'dark', + useWorktrees: false, + }; + const automakerDir = path.join(testProjectDir, '.automaker'); + await fs.mkdir(automakerDir, { recursive: true }); + const settingsPath = path.join(automakerDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updates: Partial = { + theme: 'light', + }; + + const updated = await settingsService.updateProjectSettings(testProjectDir, updates); + + expect(updated.theme).toBe('light'); + expect(updated.useWorktrees).toBe(false); // Preserved + }); + + it('should deep merge board background', async () => { + const initial: ProjectSettings = { + ...DEFAULT_PROJECT_SETTINGS, + boardBackground: { + imagePath: '/path/to/image.jpg', + cardOpacity: 0.8, + columnOpacity: 0.9, + columnBorderEnabled: true, + cardGlassmorphism: false, + cardBorderEnabled: true, + cardBorderOpacity: 0.5, + hideScrollbar: false, + }, + }; + const automakerDir = path.join(testProjectDir, '.automaker'); + await fs.mkdir(automakerDir, { recursive: true }); + const settingsPath = path.join(automakerDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updates: Partial = { + boardBackground: { + cardOpacity: 0.9, + }, + }; + + const updated = await settingsService.updateProjectSettings(testProjectDir, updates); + + expect(updated.boardBackground?.imagePath).toBe('/path/to/image.jpg'); + expect(updated.boardBackground?.cardOpacity).toBe(0.9); + expect(updated.boardBackground?.columnOpacity).toBe(0.9); + }); + + it('should create .automaker directory if it does not exist', async () => { + const newProjectDir = path.join(os.tmpdir(), `new-project-${Date.now()}`); + + await settingsService.updateProjectSettings(newProjectDir, { theme: 'light' }); + + const automakerDir = path.join(newProjectDir, '.automaker'); + const stats = await fs.stat(automakerDir); + expect(stats.isDirectory()).toBe(true); + + await fs.rm(newProjectDir, { recursive: true, force: true }); + }); + }); + + describe('hasProjectSettings', () => { + it('should return false when project settings file does not exist', async () => { + const exists = await settingsService.hasProjectSettings(testProjectDir); + expect(exists).toBe(false); + }); + + it('should return true when project settings file exists', async () => { + await settingsService.updateProjectSettings(testProjectDir, { theme: 'light' }); + const exists = await settingsService.hasProjectSettings(testProjectDir); + expect(exists).toBe(true); + }); + }); + + describe('migrateFromLocalStorage', () => { + it('should migrate global settings from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + theme: 'light', + sidebarOpen: false, + maxConcurrency: 5, + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedGlobalSettings).toBe(true); + expect(result.migratedCredentials).toBe(false); + expect(result.migratedProjectCount).toBe(0); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.theme).toBe('light'); + expect(settings.sidebarOpen).toBe(false); + expect(settings.maxConcurrency).toBe(5); + }); + + it('should migrate credentials from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + apiKeys: { + anthropic: 'sk-test-key', + }, + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedCredentials).toBe(true); + + const credentials = await settingsService.getCredentials(); + expect(credentials.apiKeys.anthropic).toBe('sk-test-key'); + }); + + it('should migrate project settings from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + projects: [ + { + id: 'proj1', + name: 'Project 1', + path: testProjectDir, + theme: 'light', + }, + ], + boardBackgroundByProject: { + [testProjectDir]: { + imagePath: '/path/to/image.jpg', + cardOpacity: 0.8, + columnOpacity: 0.9, + columnBorderEnabled: true, + cardGlassmorphism: false, + cardBorderEnabled: true, + cardBorderOpacity: 0.5, + hideScrollbar: false, + }, + }, + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedProjectCount).toBe(1); + + const projectSettings = await settingsService.getProjectSettings(testProjectDir); + expect(projectSettings.theme).toBe('light'); + expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg'); + }); + + it('should migrate ntfyEndpoints from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Ntfy Server', + serverUrl: 'https://ntfy.sh', + topic: 'my-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedGlobalSettings).toBe(true); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server'); + expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic'); + }); + + it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + eventHooks: [ + { + id: 'hook-1', + name: 'Test Hook', + eventType: 'feature:started', + enabled: true, + actions: [], + }, + ], + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + const settings = await settingsService.getGlobalSettings(); + expect(settings.eventHooks?.length).toBe(1); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should handle direct localStorage values', async () => { + const localStorageData = { + 'automaker:lastProjectDir': '/path/to/project', + 'file-browser-recent-folders': JSON.stringify(['/path1', '/path2']), + 'worktree-panel-collapsed': 'true', + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + const settings = await settingsService.getGlobalSettings(); + expect(settings.lastProjectDir).toBe('/path/to/project'); + expect(settings.recentFolders).toEqual(['/path1', '/path2']); + expect(settings.worktreePanelCollapsed).toBe(true); + }); + + it('should handle invalid JSON gracefully', async () => { + const localStorageData = { + 'automaker-storage': 'invalid json', + 'file-browser-recent-folders': 'invalid json', + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + // Skip on Windows as chmod doesn't work the same way (CI runs on Linux) + it.skipIf(process.platform === 'win32')( + 'should handle migration errors gracefully', + async () => { + // Create a read-only directory to cause write errors + const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`); + await fs.mkdir(readOnlyDir, { recursive: true }); + await fs.chmod(readOnlyDir, 0o444); + + const readOnlyService = new SettingsService(readOnlyDir); + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { theme: 'light' }, + }), + }; + + const result = await readOnlyService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + + await fs.chmod(readOnlyDir, 0o755); + await fs.rm(readOnlyDir, { recursive: true, force: true }); + } + ); + }); + + describe('getDataDir', () => { + it('should return the data directory path', () => { + const dataDir = settingsService.getDataDir(); + expect(dataDir).toBe(testDataDir); + }); + }); + + describe('phase model migration (v2 -> v3)', () => { + it('should migrate string phase models to PhaseModelEntry format', async () => { + // Simulate v2 format with string phase models + const v2Settings = { + version: 2, + theme: 'dark', + phaseModels: { + enhancementModel: 'sonnet', + fileDescriptionModel: 'haiku', + imageDescriptionModel: 'haiku', + validationModel: 'sonnet', + specGenerationModel: 'opus', + featureGenerationModel: 'sonnet', + backlogPlanningModel: 'sonnet', + projectAnalysisModel: 'sonnet', + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v2Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Verify all phase models are now PhaseModelEntry objects + // Legacy aliases are migrated to canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ model: 'claude-opus' }); + expect(settings.version).toBe(SETTINGS_VERSION); + }); + + it('should preserve PhaseModelEntry objects during migration', async () => { + // Simulate v3 format (already has PhaseModelEntry objects) + const v3Settings = { + version: 3, + theme: 'dark', + phaseModels: { + enhancementModel: { model: 'sonnet', thinkingLevel: 'high' }, + fileDescriptionModel: { model: 'haiku' }, + imageDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, + validationModel: { model: 'sonnet' }, + specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' }, + featureGenerationModel: { model: 'sonnet' }, + backlogPlanningModel: { model: 'sonnet', thinkingLevel: 'medium' }, + projectAnalysisModel: { model: 'sonnet' }, + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v3Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Verify PhaseModelEntry objects are preserved with thinkingLevel + // Legacy aliases are migrated to canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ + model: 'claude-sonnet', + thinkingLevel: 'high', + }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'ultrathink', + }); + expect(settings.phaseModels.backlogPlanningModel).toEqual({ + model: 'claude-sonnet', + thinkingLevel: 'medium', + }); + }); + + it('should handle mixed format (some string, some object)', async () => { + // Edge case: mixed format (shouldn't happen but handle gracefully) + const mixedSettings = { + version: 2, + theme: 'dark', + phaseModels: { + enhancementModel: 'sonnet', // string + fileDescriptionModel: { model: 'haiku', thinkingLevel: 'low' }, // object + imageDescriptionModel: 'haiku', // string + validationModel: { model: 'opus' }, // object without thinkingLevel + specGenerationModel: 'opus', + featureGenerationModel: 'sonnet', + backlogPlanningModel: 'sonnet', + projectAnalysisModel: 'sonnet', + }, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(mixedSettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Strings should be converted to objects with canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.imageDescriptionModel).toEqual({ model: 'claude-haiku' }); + // Objects should be preserved with migrated IDs + expect(settings.phaseModels.fileDescriptionModel).toEqual({ + model: 'claude-haiku', + thinkingLevel: 'low', + }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); + }); + + it('should migrate legacy enhancementModel/validationModel fields', async () => { + // Simulate v1 format with legacy fields + const v1Settings = { + version: 1, + theme: 'dark', + enhancementModel: 'haiku', + validationModel: 'opus', + // No phaseModels object + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(v1Settings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Legacy fields should be migrated to phaseModels with canonical IDs + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.validationModel).toEqual({ model: 'claude-opus' }); + // Other fields should use defaults (canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'adaptive', + }); + }); + + it('should use default phase models when none are configured', async () => { + // Simulate empty settings + const emptySettings = { + version: 1, + theme: 'dark', + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(emptySettings, null, 2)); + + const settings = await settingsService.getGlobalSettings(); + + // Should use DEFAULT_PHASE_MODELS (with canonical IDs) - specGenerationModel includes thinkingLevel from DEFAULT_PHASE_MODELS + expect(settings.phaseModels.enhancementModel).toEqual({ model: 'claude-sonnet' }); + expect(settings.phaseModels.fileDescriptionModel).toEqual({ model: 'claude-haiku' }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'adaptive', + }); + }); + + it('should deep merge phaseModels on update', async () => { + // Create initial settings with some phase models + await settingsService.updateGlobalSettings({ + phaseModels: { + enhancementModel: { model: 'sonnet', thinkingLevel: 'high' }, + }, + }); + + // Update with a different phase model + await settingsService.updateGlobalSettings({ + phaseModels: { + specGenerationModel: { model: 'opus', thinkingLevel: 'ultrathink' }, + }, + }); + + const settings = await settingsService.getGlobalSettings(); + + // Both should be preserved (models migrated to canonical format) + expect(settings.phaseModels.enhancementModel).toEqual({ + model: 'claude-sonnet', + thinkingLevel: 'high', + }); + expect(settings.phaseModels.specGenerationModel).toEqual({ + model: 'claude-opus', + thinkingLevel: 'ultrathink', + }); + }); + }); + + describe('atomicWriteJson', () => { + // Skip on Windows as chmod doesn't work the same way (CI runs on Linux) + it.skipIf(process.platform === 'win32')( + 'should handle write errors and clean up temp file', + async () => { + // Create a read-only directory to cause write errors + const readOnlyDir = path.join(os.tmpdir(), `readonly-${Date.now()}`); + await fs.mkdir(readOnlyDir, { recursive: true }); + await fs.chmod(readOnlyDir, 0o444); + + const readOnlyService = new SettingsService(readOnlyDir); + + await expect(readOnlyService.updateGlobalSettings({ theme: 'light' })).rejects.toThrow(); + + await fs.chmod(readOnlyDir, 0o755); + await fs.rm(readOnlyDir, { recursive: true, force: true }); + } + ); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/spec-parser.test.ts b/jules_branch/apps/server/tests/unit/services/spec-parser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..205e92c3be4212621bbae7032d311f4c82810ef3 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/spec-parser.test.ts @@ -0,0 +1,840 @@ +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')).toEqual({ + id: 'T001', + summary: undefined, + }); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toEqual({ + id: 'T042', + summary: undefined, + }); + }); + + it('should handle marker with summary', () => { + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toEqual({ + id: 'T001', + summary: 'User model created', + }); + }); + + 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)).toEqual({ + id: 'T003', + summary: 'Database setup complete', + }); + }); + + it('should find marker in the middle of a stream with trailing text', () => { + const streamText = + 'The implementation is complete! [TASK_COMPLETE] T001: Added user model and tests. Now let me check the next task...'; + expect(detectTaskCompleteMarker(streamText)).toEqual({ + id: 'T001', + summary: 'Added user model and tests. Now let me check the next task...', + }); + }); + + it('should find marker in the middle of a stream with multiple tasks and return the FIRST match', () => { + const streamText = + '[TASK_COMPLETE] T001: Task one done. Continuing... [TASK_COMPLETE] T002: Task two done. Moving on...'; + expect(detectTaskCompleteMarker(streamText)).toEqual({ + id: 'T001', + summary: 'Task one done. Continuing...', + }); + }); + + 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(); + }); + + it('should allow brackets in summary text', () => { + // Regression test: summaries containing array[index] syntax should not be truncated + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T001: Supports array[index] access syntax') + ).toEqual({ + id: 'T001', + summary: 'Supports array[index] access syntax', + }); + }); + + it('should handle summary with multiple brackets', () => { + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T042: Fixed bug in data[0].items[key] mapping') + ).toEqual({ + id: 'T042', + summary: 'Fixed bug in data[0].items[key] mapping', + }); + }); + + it('should stop at newline in summary', () => { + const result = detectTaskCompleteMarker( + '[TASK_COMPLETE] T001: First line\nSecond line without marker' + ); + expect(result).toEqual({ + id: 'T001', + summary: 'First line', + }); + }); + + it('should stop at next TASK_START marker', () => { + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T001: Summary text[TASK_START] T002') + ).toEqual({ + id: 'T001', + summary: 'Summary text', + }); + }); + }); + + describe('detectPhaseCompleteMarker', () => { + it('should detect phase complete marker and return phase number', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2')).toBe(2); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 10')).toBe(10); + }); + + it('should handle marker with description', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 1 complete')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] Phase 2: Foundation done')).toBe(2); + }); + + it('should return null when no marker present', () => { + expect(detectPhaseCompleteMarker('No marker here')).toBeNull(); + expect(detectPhaseCompleteMarker('')).toBeNull(); + }); + + it('should be case-insensitive', () => { + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] phase 1')).toBe(1); + expect(detectPhaseCompleteMarker('[PHASE_COMPLETE] PHASE 2')).toBe(2); + }); + + it('should find marker in accumulated text', () => { + const accumulated = ` +Finishing up the phase... + +All tasks complete: +[PHASE_COMPLETE] Phase 2 complete + +Starting Phase 3... +`; + expect(detectPhaseCompleteMarker(accumulated)).toBe(2); + }); + + it('should not confuse with task markers', () => { + expect(detectPhaseCompleteMarker('[TASK_COMPLETE] T001')).toBeNull(); + }); + }); + + describe('detectSpecFallback', () => { + it('should detect spec with tasks block and acceptance criteria', () => { + const content = ` +## Acceptance Criteria +- GIVEN a user, WHEN they login, THEN they see the dashboard + +\`\`\`tasks +- [ ] T001: Create login form | File: src/Login.tsx +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with task lines and problem statement', () => { + const content = ` +## Problem Statement +Users cannot currently log in to the application. + +## Implementation Plan +- [ ] T001: Add authentication endpoint +- [ ] T002: Create login UI +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Goal section (lite planning mode)', () => { + const content = ` +**Goal**: Implement user authentication + +**Solution**: Use JWT tokens for session management + +- [ ] T001: Setup auth middleware +- [ ] T002: Create token service +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with User Story format', () => { + const content = ` +## User Story +As a user, I want to reset my password, so that I can regain access. + +## Technical Context +This will modify the auth module. + +\`\`\`tasks +- [ ] T001: Add reset endpoint +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Overview section', () => { + const content = ` +## Overview +This feature adds dark mode support. + +\`\`\`tasks +- [ ] T001: Add theme toggle +\`\`\` +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with Summary section', () => { + const content = ` +## Summary +Adding a new dashboard component. + +- [ ] T001: Create dashboard layout +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation plan', () => { + const content = ` +## Implementation Plan +We will add the feature in two phases. + +- [ ] T001: Phase 1 setup +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation steps', () => { + const content = ` +## Implementation Steps +Follow these steps: + +- [ ] T001: Step one +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should detect spec with implementation approach', () => { + const content = ` +## Implementation Approach +We will use a modular approach. + +- [ ] T001: Create modules +`; + expect(detectSpecFallback(content)).toBe(true); + }); + + it('should NOT detect spec without task structure', () => { + const content = ` +## Problem Statement +Users cannot log in. + +## Acceptance Criteria +- GIVEN a user, WHEN they try to login, THEN it works +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect spec without spec content sections', () => { + const content = ` +Here are some tasks: + +- [ ] T001: Do something +- [ ] T002: Do another thing +`; + expect(detectSpecFallback(content)).toBe(false); + }); + + it('should NOT detect random text as spec', () => { + expect(detectSpecFallback('Just some random text')).toBe(false); + expect(detectSpecFallback('')).toBe(false); + }); + + it('should handle case-insensitive matching for spec sections', () => { + const content = ` +## ACCEPTANCE CRITERIA +All caps section header + +- [ ] T001: Task +`; + expect(detectSpecFallback(content)).toBe(true); + }); + }); + + describe('extractSummary', () => { + describe('explicit tags', () => { + it('should extract content from summary tags', () => { + const text = 'Some preamble This is the summary content more text'; + expect(extractSummary(text)).toBe('This is the summary content'); + }); + + it('should use last match to avoid stale summaries', () => { + const text = ` +Old stale summary + +More agent output... + +Fresh new summary +`; + expect(extractSummary(text)).toBe('Fresh new summary'); + }); + + it('should handle multiline summary content', () => { + const text = `First line +Second line +Third line`; + expect(extractSummary(text)).toBe('First line\nSecond line\nThird line'); + }); + + it('should trim whitespace from summary', () => { + const text = ' trimmed content '; + expect(extractSummary(text)).toBe('trimmed content'); + }); + }); + + describe('## Summary section (markdown)', () => { + it('should extract from ## Summary section', () => { + const text = ` +## Summary + +This is a summary paragraph. + +## Other Section +More content. +`; + expect(extractSummary(text)).toBe('This is a summary paragraph.'); + }); + + it('should truncate long summaries to 500 chars', () => { + const longContent = 'A'.repeat(600); + const text = ` +## Summary + +${longContent} + +## Next Section +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); // 500 + '...' + expect(result!.endsWith('...')).toBe(true); + }); + + it('should use last match for ## Summary', () => { + const text = ` +## Summary + +Old summary content. + +## Summary + +New summary content. +`; + expect(extractSummary(text)).toBe('New summary content.'); + }); + + it('should stop at next markdown header', () => { + const text = ` +## Summary + +Summary content here. + +## Implementation +Implementation details. +`; + expect(extractSummary(text)).toBe('Summary content here.'); + }); + + it('should include ### subsections within the summary (not cut off at ### Root Cause)', () => { + const text = ` +## Summary + +Overview of changes. + +### Root Cause +The bug was caused by X. + +### Fix Applied +Changed Y to Z. + +## Other Section +More content. +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result).toContain('Overview of changes.'); + expect(result).toContain('### Root Cause'); + expect(result).toContain('The bug was caused by X.'); + expect(result).toContain('### Fix Applied'); + expect(result).toContain('Changed Y to Z.'); + expect(result).not.toContain('## Other Section'); + }); + + it('should include ### subsections and stop at next ## header', () => { + const text = ` +## Summary + +Brief intro. + +### Changes +- File A modified +- File B added + +### Notes +Important context. + +## Implementation +Details here. +`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result).toContain('Brief intro.'); + expect(result).toContain('### Changes'); + expect(result).toContain('### Notes'); + expect(result).not.toContain('## Implementation'); + }); + }); + + describe('**Goal**: section (lite planning mode)', () => { + it('should extract from **Goal**: section', () => { + const text = '**Goal**: Implement user authentication\n**Approach**: Use JWT'; + expect(extractSummary(text)).toBe('Implement user authentication'); + }); + + it('should use last match for **Goal**:', () => { + const text = ` +**Goal**: Old goal + +More output... + +**Goal**: New goal +`; + expect(extractSummary(text)).toBe('New goal'); + }); + + it('should handle inline goal', () => { + const text = '1. **Goal**: Add login functionality'; + expect(extractSummary(text)).toBe('Add login functionality'); + }); + }); + + describe('**Problem**: section (spec/full modes)', () => { + it('should extract from **Problem**: section', () => { + const text = ` +**Problem**: Users cannot log in to the application + +**Solution**: Add authentication +`; + expect(extractSummary(text)).toBe('Users cannot log in to the application'); + }); + + it('should extract from **Problem Statement**: section', () => { + const text = ` +**Problem Statement**: Users need password reset functionality + +1. Create reset endpoint +`; + expect(extractSummary(text)).toBe('Users need password reset functionality'); + }); + + it('should truncate long problem descriptions', () => { + const longProblem = 'X'.repeat(600); + const text = `**Problem**: ${longProblem}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(503); + }); + }); + + describe('**Solution**: section (fallback)', () => { + it('should extract from **Solution**: section as fallback', () => { + const text = '**Solution**: Use JWT for authentication\n1. Install package'; + expect(extractSummary(text)).toBe('Use JWT for authentication'); + }); + + it('should truncate solution to 300 chars', () => { + const longSolution = 'Y'.repeat(400); + const text = `**Solution**: ${longSolution}`; + const result = extractSummary(text); + expect(result).not.toBeNull(); + expect(result!.length).toBeLessThanOrEqual(303); + }); + }); + + describe('priority order', () => { + it('should prefer over ## Summary', () => { + const text = ` +## Summary + +Markdown summary + +Tagged summary +`; + expect(extractSummary(text)).toBe('Tagged summary'); + }); + + it('should prefer ## Summary over **Goal**:', () => { + const text = ` +**Goal**: Goal content + +## Summary + +Summary section content. +`; + expect(extractSummary(text)).toBe('Summary section content.'); + }); + + it('should prefer **Goal**: over **Problem**:', () => { + const text = ` +**Problem**: Problem description + +**Goal**: Goal description +`; + expect(extractSummary(text)).toBe('Goal description'); + }); + + it('should prefer **Problem**: over **Solution**:', () => { + const text = ` +**Solution**: Solution description + +**Problem**: Problem description +`; + expect(extractSummary(text)).toBe('Problem description'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(extractSummary('')).toBeNull(); + }); + + it('should return null when no summary pattern found', () => { + expect(extractSummary('Random text without any summary patterns')).toBeNull(); + }); + + it('should include all paragraphs in ## Summary section', () => { + const text = ` +## Summary + +First paragraph of summary. + +Second paragraph of summary. + +## Other +`; + const result = extractSummary(text); + expect(result).toContain('First paragraph of summary.'); + expect(result).toContain('Second paragraph of summary.'); + }); + }); + + describe('pipeline accumulated output (multiple tags)', () => { + it('should return only the LAST summary tag from accumulated pipeline output', () => { + // Documents WHY the UI needs server-side feature.summary: + // When pipeline steps accumulate raw output in agent-output.md, each step + // writes its own tag. extractSummary takes only the LAST match, + // losing all previous steps' summaries. + const accumulatedOutput = ` +## Step 1: Code Review + +Some review output... + + +## Code Review Summary +- Found 3 issues +- Suggested 2 improvements + + +--- + +## Follow-up Session + +## Step 2: Testing + +Running tests... + + +## Testing Summary +- All 15 tests pass +- Coverage at 92% + +`; + const result = extractSummary(accumulatedOutput); + // Only the LAST summary tag is returned - the Code Review summary is lost + expect(result).toBe('## Testing Summary\n- All 15 tests pass\n- Coverage at 92%'); + expect(result).not.toContain('Code Review'); + }); + + it('should return only the LAST summary from three pipeline steps', () => { + const accumulatedOutput = ` +Step 1: Implementation complete + +--- + +## Follow-up Session + +Step 2: Code review findings + +--- + +## Follow-up Session + +Step 3: All tests passing +`; + const result = extractSummary(accumulatedOutput); + expect(result).toBe('Step 3: All tests passing'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle accumulated output where only one step has a summary tag', () => { + const accumulatedOutput = ` +## Step 1: Implementation +Some raw output without summary tags... + +--- + +## Follow-up Session + +## Step 2: Testing + + +## Test Results +- All tests pass + +`; + const result = extractSummary(accumulatedOutput); + expect(result).toBe('## Test Results\n- All tests pass'); + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/terminal-service.test.ts b/jules_branch/apps/server/tests/unit/services/terminal-service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9258433cd78db4c3f203dadcb7107d608129147 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/terminal-service.test.ts @@ -0,0 +1,643 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TerminalService, getTerminalService } from '@/services/terminal-service.js'; +import * as pty from 'node-pty'; +import * as os from 'os'; +import * as path from 'path'; +import * as platform from '@automaker/platform'; +import * as secureFs from '@/lib/secure-fs.js'; + +vi.mock('node-pty'); +vi.mock('os'); +vi.mock('@automaker/platform', async () => { + const actual = await vi.importActual('@automaker/platform'); + return { + ...actual, + systemPathExists: vi.fn(), + systemPathReadFileSync: vi.fn(), + getWslVersionPath: vi.fn(), + getShellPaths: vi.fn(), // Mock shell paths for cross-platform testing + isAllowedSystemPath: vi.fn(() => true), // Allow all paths in tests + }; +}); +vi.mock('@/lib/secure-fs.js'); + +describe('terminal-service.ts', () => { + let service: TerminalService; + let mockPtyProcess: any; + + // Shell paths for each platform (matching system-paths.ts) + const linuxShellPaths = [ + '/bin/zsh', + '/bin/bash', + '/bin/sh', + '/usr/bin/zsh', + '/usr/bin/bash', + '/usr/bin/sh', + '/usr/local/bin/zsh', + '/usr/local/bin/bash', + '/opt/homebrew/bin/zsh', + '/opt/homebrew/bin/bash', + 'zsh', + 'bash', + 'sh', + ]; + + const windowsShellPaths = [ + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe', + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Windows\\System32\\cmd.exe', + 'pwsh.exe', + 'pwsh', + 'powershell.exe', + 'powershell', + 'cmd.exe', + 'cmd', + ]; + + beforeEach(() => { + vi.clearAllMocks(); + service = new TerminalService(); + + // Mock PTY process + mockPtyProcess = { + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + }; + + vi.mocked(pty.spawn).mockReturnValue(mockPtyProcess); + vi.mocked(os.homedir).mockReturnValue('/home/user'); + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.arch).mockReturnValue('x64'); + + // Default mocks for system paths and secureFs + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue(''); + vi.mocked(platform.getWslVersionPath).mockReturnValue('/proc/version'); + vi.mocked(platform.getShellPaths).mockReturnValue(linuxShellPaths); // Default to Linux paths + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + }); + + afterEach(() => { + service.cleanup(); + }); + + describe('detectShell', () => { + it('should detect PowerShell Core on Windows when available', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); + expect(result.args).toEqual([]); + }); + + it('should fall back to PowerShell on Windows if Core not available', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'); + expect(result.args).toEqual([]); + }); + + it('should fall back to cmd.exe on Windows if no PowerShell', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(platform.getShellPaths).mockReturnValue(windowsShellPaths); + vi.mocked(platform.systemPathExists).mockReturnValue(false); + + const result = service.detectShell(); + + expect(result.shell).toBe('cmd.exe'); + expect(result.args).toEqual([]); + }); + + it('should detect user shell on macOS', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/zsh' }); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/zsh'); + expect(result.args).toEqual(['--login']); + }); + + it('should fall back to zsh on macOS if user shell not available', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/zsh'; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/zsh'); + expect(result.args).toEqual(['--login']); + }); + + it('should fall back to bash on macOS if zsh not available', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); + // zsh not available, but bash is + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/bash'; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); + }); + + it('should detect user shell on Linux', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); + }); + + it('should fall back to bash on Linux if user shell not available', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); + vi.mocked(platform.systemPathExists).mockImplementation((path: string) => { + return path === '/bin/bash'; + }); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); + }); + + it('should fall back to sh on Linux if bash not available', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); + vi.mocked(platform.systemPathExists).mockReturnValue(false); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/sh'); + expect(result.args).toEqual([]); + }); + + it('should detect WSL and use appropriate shell', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); + + const result = service.detectShell(); + + expect(result.shell).toBe('/bin/bash'); + expect(result.args).toEqual(['--login']); + }); + }); + + describe('isWSL', () => { + it('should return true if /proc/version contains microsoft', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue( + 'Linux version 5.10.0-microsoft-standard-WSL2' + ); + + expect(service.isWSL()).toBe(true); + }); + + it('should return true if /proc/version contains wsl', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockReturnValue('Linux version 5.10.0-wsl2'); + + expect(service.isWSL()).toBe(true); + }); + + it('should return true if WSL_DISTRO_NAME is set', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(false); + vi.spyOn(process, 'env', 'get').mockReturnValue({ WSL_DISTRO_NAME: 'Ubuntu' }); + + expect(service.isWSL()).toBe(true); + }); + + it('should return true if WSLENV is set', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(false); + vi.spyOn(process, 'env', 'get').mockReturnValue({ WSLENV: 'PATH/l' }); + + expect(service.isWSL()).toBe(true); + }); + + it('should return false if not in WSL', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(false); + vi.spyOn(process, 'env', 'get').mockReturnValue({}); + + expect(service.isWSL()).toBe(false); + }); + + it('should return false if error reading /proc/version', () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(platform.systemPathReadFileSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + expect(service.isWSL()).toBe(false); + }); + }); + + describe('getPlatformInfo', () => { + it('should return platform information', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(os.arch).mockReturnValue('x64'); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const info = service.getPlatformInfo(); + + expect(info.platform).toBe('linux'); + expect(info.arch).toBe('x64'); + expect(info.defaultShell).toBe('/bin/bash'); + expect(typeof info.isWSL).toBe('boolean'); + }); + }); + + describe('createSession', () => { + it('should create a new terminal session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession({ + cwd: '/test/dir', + cols: 100, + rows: 30, + }); + + expect(session).not.toBeNull(); + expect(session!.id).toMatch(/^term-/); + expect(session!.cwd).toBe(path.resolve('/test/dir')); + expect(session!.shell).toBe('/bin/bash'); + expect(pty.spawn).toHaveBeenCalledWith( + '/bin/bash', + ['--login'], + expect.objectContaining({ + cwd: path.resolve('/test/dir'), + cols: 100, + rows: 30, + }) + ); + }); + + it('should use default cols and rows if not provided', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + await service.createSession(); + + expect(pty.spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + cols: 80, + rows: 24, + }) + ); + }); + + it('should fall back to home directory if cwd does not exist', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockRejectedValue(new Error('ENOENT')); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession({ + cwd: '/nonexistent', + }); + + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); + }); + + it('should fall back to home directory if cwd is not a directory', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => false } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession({ + cwd: '/file.txt', + }); + + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('/home/user'); + }); + + it('should fix double slashes in path', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession({ + cwd: '//test/dir', + }); + + expect(session).not.toBeNull(); + expect(session!.cwd).toBe(path.resolve('/test/dir')); + }); + + it('should preserve WSL UNC paths', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession({ + cwd: '//wsl$/Ubuntu/home', + }); + + expect(session).not.toBeNull(); + expect(session!.cwd).toBe('//wsl$/Ubuntu/home'); + }); + + it('should handle data events from PTY', async () => { + vi.useFakeTimers(); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const dataCallback = vi.fn(); + service.onData(dataCallback); + + await service.createSession(); + + // Simulate data event + const onDataHandler = mockPtyProcess.onData.mock.calls[0][0]; + onDataHandler('test data'); + + // Wait for throttled output + vi.advanceTimersByTime(20); + + expect(dataCallback).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should handle exit events from PTY', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const exitCallback = vi.fn(); + service.onExit(exitCallback); + + const session = await service.createSession(); + + // Simulate exit event + const onExitHandler = mockPtyProcess.onExit.mock.calls[0][0]; + onExitHandler({ exitCode: 0 }); + + expect(session).not.toBeNull(); + expect(exitCallback).toHaveBeenCalledWith(session!.id, 0); + expect(service.getSession(session!.id)).toBeUndefined(); + }); + }); + + describe('write', () => { + it('should write data to existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession(); + const result = service.write(session!.id, 'ls\n'); + + expect(result).toBe(true); + expect(mockPtyProcess.write).toHaveBeenCalledWith('ls\n'); + }); + + it('should return false for non-existent session', () => { + const result = service.write('nonexistent', 'data'); + + expect(result).toBe(false); + expect(mockPtyProcess.write).not.toHaveBeenCalled(); + }); + }); + + describe('resize', () => { + it('should resize existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); + + expect(result).toBe(true); + expect(mockPtyProcess.resize).toHaveBeenCalledWith(120, 40); + }); + + it('should return false for non-existent session', () => { + const result = service.resize('nonexistent', 120, 40); + + expect(result).toBe(false); + expect(mockPtyProcess.resize).not.toHaveBeenCalled(); + }); + + it('should handle resize errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + mockPtyProcess.resize.mockImplementation(() => { + throw new Error('Resize failed'); + }); + + const session = await service.createSession(); + const result = service.resize(session!.id, 120, 40); + + expect(result).toBe(false); + }); + }); + + describe('killSession', () => { + it('should kill existing session', async () => { + vi.useFakeTimers(); + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession(); + const result = service.killSession(session!.id); + + expect(result).toBe(true); + expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGTERM'); + + // Session is removed after SIGKILL timeout (1 second) + vi.advanceTimersByTime(1000); + + expect(mockPtyProcess.kill).toHaveBeenCalledWith('SIGKILL'); + expect(service.getSession(session!.id)).toBeUndefined(); + + vi.useRealTimers(); + }); + + it('should return false for non-existent session', () => { + const result = service.killSession('nonexistent'); + + expect(result).toBe(false); + }); + + it('should handle kill errors', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + mockPtyProcess.kill.mockImplementation(() => { + throw new Error('Kill failed'); + }); + + const session = await service.createSession(); + const result = service.killSession(session!.id); + + expect(result).toBe(false); + }); + }); + + describe('getSession', () => { + it('should return existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession(); + const retrieved = service.getSession(session!.id); + + expect(retrieved).toBe(session); + }); + + it('should return undefined for non-existent session', () => { + const retrieved = service.getSession('nonexistent'); + + expect(retrieved).toBeUndefined(); + }); + }); + + describe('getScrollback', () => { + it('should return scrollback buffer for existing session', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session = await service.createSession(); + session!.scrollbackBuffer = 'test scrollback'; + + const scrollback = service.getScrollback(session!.id); + + expect(scrollback).toBe('test scrollback'); + }); + + it('should return null for non-existent session', () => { + const scrollback = service.getScrollback('nonexistent'); + + expect(scrollback).toBeNull(); + }); + }); + + describe('getAllSessions', () => { + it('should return all active sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session1 = await service.createSession({ cwd: '/dir1' }); + const session2 = await service.createSession({ cwd: '/dir2' }); + + const sessions = service.getAllSessions(); + + expect(sessions).toHaveLength(2); + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(sessions[0].id).toBe(session1!.id); + expect(sessions[1].id).toBe(session2!.id); + expect(sessions[0].cwd).toBe(path.resolve('/dir1')); + expect(sessions[1].cwd).toBe(path.resolve('/dir2')); + }); + + it('should return empty array if no sessions', () => { + const sessions = service.getAllSessions(); + + expect(sessions).toEqual([]); + }); + }); + + describe('onData and onExit', () => { + it('should allow subscribing and unsubscribing from data events', () => { + const callback = vi.fn(); + const unsubscribe = service.onData(callback); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + + it('should allow subscribing and unsubscribing from exit events', () => { + const callback = vi.fn(); + const unsubscribe = service.onExit(callback); + + expect(typeof unsubscribe).toBe('function'); + + unsubscribe(); + }); + }); + + describe('cleanup', () => { + it('should clean up all sessions', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + + const session1 = await service.createSession(); + const session2 = await service.createSession(); + + service.cleanup(); + + expect(session1).not.toBeNull(); + expect(session2).not.toBeNull(); + expect(service.getSession(session1!.id)).toBeUndefined(); + expect(service.getSession(session2!.id)).toBeUndefined(); + expect(service.getAllSessions()).toHaveLength(0); + }); + + it('should handle cleanup errors gracefully', async () => { + vi.mocked(platform.systemPathExists).mockReturnValue(true); + vi.mocked(secureFs.stat).mockResolvedValue({ isDirectory: () => true } as any); + vi.spyOn(process, 'env', 'get').mockReturnValue({ SHELL: '/bin/bash' }); + mockPtyProcess.kill.mockImplementation(() => { + throw new Error('Kill failed'); + }); + + await service.createSession(); + + expect(() => service.cleanup()).not.toThrow(); + }); + }); + + describe('getTerminalService', () => { + it('should return singleton instance', () => { + const instance1 = getTerminalService(); + const instance2 = getTerminalService(); + + expect(instance1).toBe(instance2); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/typed-event-bus.test.ts b/jules_branch/apps/server/tests/unit/services/typed-event-bus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..85c5b20286da86893e15bc38b0cce56ac0f25dfb --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/typed-event-bus.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { EventEmitter, EventCallback, EventType } from '../../../src/lib/events.js'; + +/** + * Create a mock EventEmitter for testing + */ +function createMockEventEmitter(): EventEmitter & { + emitCalls: Array<{ type: EventType; payload: unknown }>; + subscribers: Set; +} { + const subscribers = new Set(); + const emitCalls: Array<{ type: EventType; payload: unknown }> = []; + + return { + emitCalls, + subscribers, + emit(type: EventType, payload: unknown) { + emitCalls.push({ type, payload }); + // Also call subscribers to simulate real behavior + for (const callback of subscribers) { + callback(type, payload); + } + }, + subscribe(callback: EventCallback) { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; +} + +describe('TypedEventBus', () => { + let mockEmitter: ReturnType; + let eventBus: TypedEventBus; + + beforeEach(() => { + mockEmitter = createMockEventEmitter(); + eventBus = new TypedEventBus(mockEmitter); + }); + + describe('constructor', () => { + it('should wrap an EventEmitter', () => { + expect(eventBus).toBeInstanceOf(TypedEventBus); + }); + + it('should store the underlying emitter', () => { + expect(eventBus.getUnderlyingEmitter()).toBe(mockEmitter); + }); + }); + + describe('emit', () => { + it('should pass events directly to the underlying emitter', () => { + const payload = { test: 'data' }; + eventBus.emit('feature:created', payload); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'feature:created', + payload: { test: 'data' }, + }); + }); + + it('should handle various event types', () => { + eventBus.emit('feature:updated', { id: '1' }); + eventBus.emit('agent:streaming', { chunk: 'data' }); + eventBus.emit('error', { message: 'error' }); + + expect(mockEmitter.emitCalls).toHaveLength(3); + expect(mockEmitter.emitCalls[0].type).toBe('feature:updated'); + expect(mockEmitter.emitCalls[1].type).toBe('agent:streaming'); + expect(mockEmitter.emitCalls[2].type).toBe('error'); + }); + }); + + describe('emitAutoModeEvent', () => { + it('should wrap events in auto-mode:event format', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('auto-mode:event'); + }); + + it('should include event type in payload', () => { + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_started'); + }); + + it('should spread additional data into payload', () => { + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ + type: 'auto_mode_feature_start', + featureId: 'feat-1', + featureName: 'Test Feature', + projectPath: '/project', + }); + }); + + it('should handle empty data object', () => { + eventBus.emitAutoModeEvent('auto_mode_idle', {}); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload).toEqual({ type: 'auto_mode_idle' }); + }); + + it('should preserve exact event format for frontend compatibility', () => { + // This test verifies the exact format that the frontend expects + eventBus.emitAutoModeEvent('auto_mode_progress', { + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }); + + expect(mockEmitter.emitCalls[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_progress', + featureId: 'feat-123', + progress: 50, + message: 'Processing...', + }, + }); + }); + + it('should handle all standard auto-mode event types', () => { + const eventTypes = [ + 'auto_mode_started', + 'auto_mode_stopped', + 'auto_mode_idle', + 'auto_mode_error', + 'auto_mode_paused_failures', + 'auto_mode_feature_start', + 'auto_mode_feature_complete', + 'auto_mode_feature_resuming', + 'auto_mode_progress', + 'auto_mode_tool', + 'auto_mode_task_started', + 'auto_mode_task_complete', + 'planning_started', + 'plan_approval_required', + 'plan_approved', + 'plan_rejected', + ] as const; + + for (const eventType of eventTypes) { + eventBus.emitAutoModeEvent(eventType, { test: true }); + } + + expect(mockEmitter.emitCalls).toHaveLength(eventTypes.length); + mockEmitter.emitCalls.forEach((call, index) => { + expect(call.type).toBe('auto-mode:event'); + const payload = call.payload as Record; + expect(payload.type).toBe(eventTypes[index]); + }); + }); + + it('should allow custom event types (string extensibility)', () => { + eventBus.emitAutoModeEvent('custom_event_type', { custom: 'data' }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('custom_event_type'); + }); + }); + + describe('subscribe', () => { + it('should pass subscriptions to the underlying emitter', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + expect(mockEmitter.subscribers.has(callback)).toBe(true); + + unsubscribe(); + + expect(mockEmitter.subscribers.has(callback)).toBe(false); + }); + + it('should receive events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emit('feature:created', { id: '1' }); + + expect(callback).toHaveBeenCalledWith('feature:created', { id: '1' }); + }); + + it('should receive auto-mode events when subscribed', () => { + const callback = vi.fn(); + eventBus.subscribe(callback); + + eventBus.emitAutoModeEvent('auto_mode_started', { projectPath: '/test' }); + + expect(callback).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_started', + projectPath: '/test', + }); + }); + + it('should not receive events after unsubscribe', () => { + const callback = vi.fn(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emit('event1', {}); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + + eventBus.emit('event2', {}); + expect(callback).toHaveBeenCalledTimes(1); // Still 1, not called again + }); + }); + + describe('getUnderlyingEmitter', () => { + it('should return the wrapped EventEmitter', () => { + const emitter = eventBus.getUnderlyingEmitter(); + expect(emitter).toBe(mockEmitter); + }); + + it('should allow direct access for special cases', () => { + const emitter = eventBus.getUnderlyingEmitter(); + + // Verify we can use it directly + emitter.emit('direct:event', { direct: true }); + + expect(mockEmitter.emitCalls).toHaveLength(1); + expect(mockEmitter.emitCalls[0].type).toBe('direct:event'); + }); + }); + + describe('integration with real EventEmitter pattern', () => { + it('should produce the exact payload format used by AutoModeService', () => { + // This test documents the exact format that was in AutoModeService.emitAutoModeEvent + // before extraction, ensuring backward compatibility + + const receivedEvents: Array<{ type: EventType; payload: unknown }> = []; + + eventBus.subscribe((type, payload) => { + receivedEvents.push({ type, payload }); + }); + + // Simulate the exact call pattern from AutoModeService + eventBus.emitAutoModeEvent('auto_mode_feature_start', { + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }); + + expect(receivedEvents).toHaveLength(1); + expect(receivedEvents[0]).toEqual({ + type: 'auto-mode:event', + payload: { + type: 'auto_mode_feature_start', + featureId: 'abc-123', + featureName: 'Add user authentication', + projectPath: '/home/user/project', + }, + }); + }); + + it('should handle complex nested data in events', () => { + eventBus.emitAutoModeEvent('auto_mode_tool', { + featureId: 'feat-1', + tool: { + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }, + timestamp: 1234567890, + }); + + const payload = mockEmitter.emitCalls[0].payload as Record; + expect(payload.type).toBe('auto_mode_tool'); + expect(payload.tool).toEqual({ + name: 'write_file', + input: { + path: '/src/index.ts', + content: 'const x = 1;', + }, + }); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/services/worktree-resolver.test.ts b/jules_branch/apps/server/tests/unit/services/worktree-resolver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ba1396e2c5837cb6c2447bc6fa8016227063408 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/services/worktree-resolver.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { WorktreeResolver, type WorktreeInfo } from '@/services/worktree-resolver.js'; +import { exec } from 'child_process'; +import path from 'path'; + +// Mock child_process +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +/** + * Helper to normalize paths for cross-platform test compatibility. + * On Windows, path.resolve('/Users/dev/project') returns 'C:\Users\dev\project' (with current drive). + * This helper ensures test expectations match the actual platform behavior. + */ +const normalizePath = (p: string): string => path.resolve(p); + +// Create promisified mock helper +const mockExecAsync = ( + impl: (cmd: string, options?: { cwd?: string }) => Promise<{ stdout: string; stderr: string }> +) => { + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + options: { cwd?: string } | undefined, + callback: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + impl(cmd, options) + .then((result) => callback(null, result)) + .catch((error) => callback(error, { stdout: '', stderr: '' })); + } + ); +}; + +describe('WorktreeResolver', () => { + let resolver: WorktreeResolver; + + beforeEach(() => { + vi.clearAllMocks(); + resolver = new WorktreeResolver(); + }); + + describe('getCurrentBranch', () => { + it('should return branch name when on a branch', async () => { + mockExecAsync(async () => ({ stdout: 'main\n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('main'); + }); + + it('should return null on detached HEAD (empty output)', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const branch = await resolver.getCurrentBranch('/not/a/git/repo'); + + expect(branch).toBeNull(); + }); + + it('should trim whitespace from branch name', async () => { + mockExecAsync(async () => ({ stdout: ' feature-branch \n', stderr: '' })); + + const branch = await resolver.getCurrentBranch('/test/project'); + + expect(branch).toBe('feature-branch'); + }); + + it('should use provided projectPath as cwd', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: 'main\n', stderr: '' }; + }); + + await resolver.getCurrentBranch('/custom/path'); + + expect(capturedCwd).toBe('/custom/path'); + }); + }); + + describe('findWorktreeForBranch', () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + it('should find worktree by branch name', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should normalize refs/heads and trim when resolving target branch', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch( + '/Users/dev/project', + ' refs/heads/feature-x ' + ); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should normalize remote-style target branch names', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'origin/feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should return null when branch not found', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const path = await resolver.findWorktreeForBranch('/Users/dev/project', 'non-existent'); + + expect(path).toBeNull(); + }); + + it('should return null when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const path = await resolver.findWorktreeForBranch('/not/a/repo', 'main'); + + expect(path).toBeNull(); + }); + + it('should find main worktree', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'main'); + + expect(result).toBe(normalizePath('/Users/dev/project')); + }); + + it('should handle porcelain output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/feature-relative +branch refs/heads/feature-relative +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'feature-relative'); + + // Should resolve to absolute path (platform-specific) + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-relative')); + }); + + it('should use projectPath as cwd for git command', async () => { + let capturedCwd: string | undefined; + mockExecAsync(async (cmd, options) => { + capturedCwd = options?.cwd; + return { stdout: porcelainOutput, stderr: '' }; + }); + + await resolver.findWorktreeForBranch('/custom/project', 'main'); + + expect(capturedCwd).toBe('/custom/project'); + }); + }); + + describe('listWorktrees', () => { + it('should list all worktrees with metadata', async () => { + const porcelainOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x + +worktree /Users/dev/project/.worktrees/feature-y +branch refs/heads/feature-y +`; + + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(3); + expect(worktrees[0]).toEqual({ + path: normalizePath('/Users/dev/project'), + branch: 'main', + isMain: true, + }); + expect(worktrees[1]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/feature-x'), + branch: 'feature-x', + isMain: false, + }); + expect(worktrees[2]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/feature-y'), + branch: 'feature-y', + isMain: false, + }); + }); + + it('should return empty array when git command fails', async () => { + mockExecAsync(async () => { + throw new Error('Not a git repository'); + }); + + const worktrees = await resolver.listWorktrees('/not/a/repo'); + + expect(worktrees).toEqual([]); + }); + + it('should handle detached HEAD worktrees', async () => { + const porcelainWithDetached = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/detached-wt +detached +`; + + mockExecAsync(async () => ({ stdout: porcelainWithDetached, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1]).toEqual({ + path: normalizePath('/Users/dev/project/.worktrees/detached-wt'), + branch: null, // Detached HEAD has no branch + isMain: false, + }); + }); + + it('should mark only first worktree as main', async () => { + const multipleWorktrees = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/wt1 +branch refs/heads/branch1 + +worktree /Users/dev/project/.worktrees/wt2 +branch refs/heads/branch2 +`; + + mockExecAsync(async () => ({ stdout: multipleWorktrees, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[0].isMain).toBe(true); + expect(worktrees[1].isMain).toBe(false); + expect(worktrees[2].isMain).toBe(false); + }); + + it('should resolve relative paths to absolute', async () => { + const relativePathOutput = `worktree /Users/dev/project +branch refs/heads/main + +worktree .worktrees/relative-wt +branch refs/heads/relative-branch +`; + + mockExecAsync(async () => ({ stdout: relativePathOutput, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees[1].path).toBe(normalizePath('/Users/dev/project/.worktrees/relative-wt')); + }); + + it('should handle single worktree (main only)', async () => { + const singleWorktree = `worktree /Users/dev/project +branch refs/heads/main +`; + + mockExecAsync(async () => ({ stdout: singleWorktree, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(1); + expect(worktrees[0]).toEqual({ + path: normalizePath('/Users/dev/project'), + branch: 'main', + isMain: true, + }); + }); + + it('should handle empty git worktree list output', async () => { + mockExecAsync(async () => ({ stdout: '', stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toEqual([]); + }); + + it('should handle output without trailing newline', async () => { + const noTrailingNewline = `worktree /Users/dev/project +branch refs/heads/main + +worktree /Users/dev/project/.worktrees/feature-x +branch refs/heads/feature-x`; + + mockExecAsync(async () => ({ stdout: noTrailingNewline, stderr: '' })); + + const worktrees = await resolver.listWorktrees('/Users/dev/project'); + + expect(worktrees).toHaveLength(2); + expect(worktrees[1].branch).toBe('feature-x'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/types/pipeline-types.test.ts b/jules_branch/apps/server/tests/unit/types/pipeline-types.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d978cc4f558c67fb93687b21a5530812d63d56ef --- /dev/null +++ b/jules_branch/apps/server/tests/unit/types/pipeline-types.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { isPipelineStatus } from '@automaker/types'; + +describe('isPipelineStatus', () => { + it('should return true for valid pipeline statuses', () => { + expect(isPipelineStatus('pipeline_step1')).toBe(true); + expect(isPipelineStatus('pipeline_testing')).toBe(true); + expect(isPipelineStatus('pipeline_code_review')).toBe(true); + expect(isPipelineStatus('pipeline_complete')).toBe(true); + }); + + it('should return true for pipeline_ prefix with any non-empty suffix', () => { + expect(isPipelineStatus('pipeline_')).toBe(false); // Empty suffix is invalid + expect(isPipelineStatus('pipeline_123')).toBe(true); + expect(isPipelineStatus('pipeline_step_abc_123')).toBe(true); + }); + + it('should return false for non-pipeline statuses', () => { + expect(isPipelineStatus('in_progress')).toBe(false); + expect(isPipelineStatus('backlog')).toBe(false); + expect(isPipelineStatus('ready')).toBe(false); + expect(isPipelineStatus('interrupted')).toBe(false); + expect(isPipelineStatus('waiting_approval')).toBe(false); + expect(isPipelineStatus('verified')).toBe(false); + expect(isPipelineStatus('completed')).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isPipelineStatus(null)).toBe(false); + expect(isPipelineStatus(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isPipelineStatus('')).toBe(false); + }); + + it('should return false for partial matches', () => { + expect(isPipelineStatus('pipeline')).toBe(false); + expect(isPipelineStatus('pipelin_step1')).toBe(false); + expect(isPipelineStatus('Pipeline_step1')).toBe(false); + expect(isPipelineStatus('PIPELINE_step1')).toBe(false); + }); + + it('should return false for pipeline prefix embedded in longer string', () => { + expect(isPipelineStatus('not_pipeline_step1')).toBe(false); + expect(isPipelineStatus('my_pipeline_step')).toBe(false); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts b/jules_branch/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..67187c16a44119201147c01ea4c0fa652003045b --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts @@ -0,0 +1,563 @@ +/** + * End-to-end integration tests for agent output summary display flow. + * + * These tests validate the complete flow from: + * 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary) + * 2. Event emission with accumulated summary (auto_mode_summary event) + * 3. UI-side summary retrieval (feature.summary via API) + * 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary) + * + * The tests simulate what happens when: + * - A feature goes through multiple pipeline steps + * - Each step produces a summary + * - The server accumulates all summaries + * - The UI displays the accumulated summary + */ + +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 { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { pipelineService } from '@/services/pipeline-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(); + 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(), + })), +})); + +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + +// ============================================================================ +// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts) +// ============================================================================ + +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + if (!summary || !summary.trim()) return phaseSummaries; + + const sections = summary.split(/\n\n---\n\n/); + for (const section of sections) { + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + return phaseSummaries; +} + +function extractSummary(rawOutput: string): string | null { + if (!rawOutput || !rawOutput.trim()) return null; + + const regexesToTry: Array<{ + regex: RegExp; + processor: (m: RegExpMatchArray) => string; + }> = [ + { regex: /([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, + { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, + ]; + + for (const { regex, processor } of regexesToTry) { + const matches = [...rawOutput.matchAll(regex)]; + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + return processor(lastMatch).trim(); + } + } + return null; +} + +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) return false; + return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0; +} + +/** + * Returns the first summary candidate that contains non-whitespace content. + * Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts + */ +function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + return null; +} + +// ============================================================================ +// Unit tests for helper functions +// ============================================================================ + +describe('getFirstNonEmptySummary', () => { + it('should return the first non-empty string', () => { + expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first'); + }); + + it('should skip null and undefined candidates', () => { + expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid'); + }); + + it('should skip whitespace-only strings', () => { + expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content'); + }); + + it('should return null when all candidates are empty', () => { + expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull(); + }); + + it('should return null when no candidates provided', () => { + expect(getFirstNonEmptySummary()).toBeNull(); + }); + + it('should handle empty string as invalid', () => { + expect(getFirstNonEmptySummary('', 'valid')).toBe('valid'); + }); + + it('should prefer first valid candidate', () => { + expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first'); + }); + + it('should handle strings with only spaces as invalid', () => { + expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid'); + }); + + it('should accept strings with content surrounded by whitespace', () => { + expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces '); + }); +}); + +describe('Agent Output Summary E2E Flow', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + + const baseFeature: Feature = { + id: 'e2e-feature-1', + name: 'E2E Feature', + title: 'E2E Feature Title', + description: 'A feature going through complete pipeline', + status: 'pipeline_implementation', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + const mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + }); + + describe('complete pipeline flow: server accumulation → UI display', () => { + it('should maintain complete summary across all pipeline steps', async () => { + // ===== STEP 1: Implementation ===== + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Changes\n- Created auth module\n- Added user service' + ); + + const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const step1Summary = step1Feature.summary; + + // Verify server-side accumulation format + expect(step1Summary).toBe( + '### Implementation\n\n## Changes\n- Created auth module\n- Added user service' + ); + + // Verify UI can parse this summary + const phases1 = parsePhaseSummaries(step1Summary); + expect(phases1.size).toBe(1); + expect(phases1.get('implementation')).toContain('Created auth module'); + + // ===== STEP 2: Code Review ===== + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Code Review', + id: 'code_review', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Review Results\n- Approved with minor suggestions' + ); + + const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const step2Summary = step2Feature.summary; + + // Verify accumulation now has both steps + expect(step2Summary).toContain('### Implementation'); + expect(step2Summary).toContain('Created auth module'); + expect(step2Summary).toContain('### Code Review'); + expect(step2Summary).toContain('Approved with minor suggestions'); + expect(step2Summary).toContain('\n\n---\n\n'); // Separator + + // Verify UI can parse accumulated summary + expect(isAccumulatedSummary(step2Summary)).toBe(true); + const phases2 = parsePhaseSummaries(step2Summary); + expect(phases2.size).toBe(2); + expect(phases2.get('implementation')).toContain('Created auth module'); + expect(phases2.get('code review')).toContain('Approved with minor suggestions'); + + // ===== STEP 3: Testing ===== + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Test Results\n- 42 tests pass\n- 98% coverage' + ); + + const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const finalSummary = finalFeature.summary; + + // Verify final accumulation has all three steps + expect(finalSummary).toContain('### Implementation'); + expect(finalSummary).toContain('Created auth module'); + expect(finalSummary).toContain('### Code Review'); + expect(finalSummary).toContain('Approved with minor suggestions'); + expect(finalSummary).toContain('### Testing'); + expect(finalSummary).toContain('42 tests pass'); + + // Verify UI-side parsing of complete pipeline + expect(isAccumulatedSummary(finalSummary)).toBe(true); + const finalPhases = parsePhaseSummaries(finalSummary); + expect(finalPhases.size).toBe(3); + + // Verify chronological order (implementation before testing) + const summaryLines = finalSummary!.split('\n'); + const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation')); + const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review')); + const testIndex = summaryLines.findIndex((l) => l.includes('### Testing')); + expect(implIndex).toBeLessThan(reviewIndex); + expect(reviewIndex).toBeLessThan(testIndex); + }); + + it('should emit events with accumulated summaries for real-time UI updates', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output'); + + // Verify event emission + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output', + }); + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_testing', + summary: '### Implementation\n\nStep 1 output', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output'); + + // Event should contain FULL accumulated summary + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output', + }); + }); + }); + + describe('UI display logic: feature.summary vs extractSummary()', () => { + it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => { + // Simulate what the server has accumulated + const featureSummary = [ + '### Implementation', + '', + '## Changes', + '- Created feature', + '', + '---', + '', + '### Testing', + '', + '## Results', + '- All tests pass', + ].join('\n'); + + // Simulate raw agent output (only contains last summary) + const rawOutput = ` +Working on tests... + + +## Results +- All tests pass + +`; + + // UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); + + // Should use server-accumulated summary + expect(displaySummary).toBe(featureSummary); + expect(displaySummary).toContain('### Implementation'); + expect(displaySummary).toContain('### Testing'); + + // If server summary was missing, only last summary would be shown + const fallbackSummary = extractSummary(rawOutput); + expect(fallbackSummary).not.toContain('Implementation'); + expect(fallbackSummary).toContain('All tests pass'); + }); + + it('should handle legacy features without server accumulation', () => { + // Legacy features have no feature.summary + const featureSummary = undefined; + + // Raw output contains the summary + const rawOutput = ` + +## Implementation Complete +- Created the feature +- All tests pass + +`; + + // UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); + + // Should fall back to client-side extraction + expect(displaySummary).toContain('Implementation Complete'); + expect(displaySummary).toContain('All tests pass'); + }); + }); + + describe('error recovery and edge cases', () => { + it('should gracefully handle pipeline interruption', async () => { + // Step 1 completes + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Pipeline gets interrupted (status changes but summary is preserved) + // When user views the feature later, the summary should still be available + expect(step1Summary).toBe('### Implementation\n\nImplementation done'); + + // UI can still parse the partial pipeline + const phases = parsePhaseSummaries(step1Summary); + expect(phases.size).toBe(1); + expect(phases.get('implementation')).toBe('Implementation done'); + }); + + it('should handle very large accumulated summaries', async () => { + // Generate large content for each step + const generateLargeContent = (stepNum: number) => { + const lines = [`## Step ${stepNum} Changes`]; + for (let i = 0; i < 100; i++) { + lines.push( + `- Change ${i}: This is a detailed description of the change made during step ${stepNum}` + ); + } + return lines.join('\n'); + }; + + // Simulate 5 pipeline steps with large content + let currentSummary: string | undefined = undefined; + const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement']; + + for (let i = 0; i < 5; i++) { + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: stepNames[i], + id: stepNames[i].toLowerCase().replace(' ', '_'), + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`, + summary: currentSummary, + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1)); + + currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + } + + // Final summary should be large but still parseable + expect(currentSummary!.length).toBeGreaterThan(5000); + expect(isAccumulatedSummary(currentSummary)).toBe(true); + + const phases = parsePhaseSummaries(currentSummary); + expect(phases.size).toBe(5); + + // Verify all steps are present + for (const stepName of stepNames) { + expect(phases.has(stepName.toLowerCase())).toBe(true); + } + }); + }); + + describe('query invalidation simulation', () => { + it('should trigger UI refetch on auto_mode_summary event', async () => { + // This test documents the expected behavior: + // When saveFeatureSummary is called, it emits auto_mode_summary event + // The UI's use-query-invalidation.ts invalidates the feature query + // This causes a refetch of the feature, getting the updated summary + + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content'); + + // Verify event was emitted (triggers React Query invalidation) + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + summary: expect.any(String), + }) + ); + + // The UI would then: + // 1. Receive the event via WebSocket + // 2. Invalidate the feature query + // 3. Refetch the feature (GET /api/features/:id) + // 4. Display the updated feature.summary + }); + }); +}); + +/** + * KEY E2E FLOW SUMMARY: + * + * 1. PIPELINE EXECUTION: + * - Feature starts with status='pipeline_implementation' + * - Agent runs and produces summary + * - FeatureStateManager.saveFeatureSummary() accumulates with step header + * - Status advances to 'pipeline_testing' + * - Process repeats for each step + * + * 2. SERVER-SIDE ACCUMULATION: + * - First step: `### Implementation\n\n` + * - Second step: `### Implementation\n\n\n\n---\n\n### Testing\n\n` + * - Pattern continues with each step + * + * 3. EVENT EMISSION: + * - auto_mode_summary event contains FULL accumulated summary + * - UI receives event via WebSocket + * - React Query invalidates feature query + * - Feature is refetched with updated summary + * + * 4. UI DISPLAY: + * - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + * - feature.summary is preferred (contains all steps) + * - extractSummary() is fallback (last summary only) + * - parsePhaseSummaries() can split into individual phases for UI + * + * 5. FALLBACK FOR LEGACY: + * - Old features may not have feature.summary + * - UI falls back to extracting from raw output + * - Only last summary is available in this case + */ diff --git a/jules_branch/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts b/jules_branch/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c31f950e4af28c20a63f152fc1d91be1c4ec2c40 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts @@ -0,0 +1,403 @@ +/** + * Unit tests for the agent output summary priority logic. + * + * These tests verify the summary display logic used in AgentOutputModal + * where the UI must choose between server-accumulated summaries and + * client-side extracted summaries. + * + * Priority order (from agent-output-modal.tsx): + * 1. feature.summary (server-accumulated, contains all pipeline steps) + * 2. extractSummary(output) (client-side fallback, last summary only) + * + * This priority is crucial for pipeline features where the server-side + * accumulation provides the complete history of all step summaries. + */ + +import { describe, it, expect } from 'vitest'; +// Import the actual extractSummary function to ensure test behavior matches production +import { extractSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +/** + * Simulates the summary priority logic from AgentOutputModal. + * + * Priority: + * 1. feature?.summary (server-accumulated) + * 2. extractSummary(output) (client-side fallback) + */ +function getDisplaySummary( + featureSummary: string | undefined | null, + rawOutput: string +): string | null { + return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); +} + +describe('Agent Output Summary Priority Logic', () => { + describe('priority order: feature.summary over extractSummary', () => { + it('should use feature.summary when available (server-accumulated wins)', () => { + const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step'; + const rawOutput = ` + +Only the last summary is extracted client-side + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // Server-accumulated summary should be used, not client-side extraction + expect(result).toBe(featureSummary); + expect(result).toContain('### Step 1'); + expect(result).toContain('### Step 2'); + expect(result).not.toContain('Only the last summary'); + }); + + it('should use client-side extractSummary when feature.summary is undefined', () => { + const rawOutput = ` + +This is the only summary + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBe('This is the only summary'); + }); + + it('should use client-side extractSummary when feature.summary is null', () => { + const rawOutput = ` + +Client-side extracted summary + +`; + + const result = getDisplaySummary(null, rawOutput); + + expect(result).toBe('Client-side extracted summary'); + }); + + it('should use client-side extractSummary when feature.summary is empty string', () => { + const rawOutput = ` + +Fallback content + +`; + + const result = getDisplaySummary('', rawOutput); + + // Empty string is falsy, so fallback is used + expect(result).toBe('Fallback content'); + }); + + it('should use client-side extractSummary when feature.summary is whitespace only', () => { + const rawOutput = ` + +Fallback for whitespace summary + +`; + + const result = getDisplaySummary(' \n ', rawOutput); + + expect(result).toBe('Fallback for whitespace summary'); + }); + + it('should preserve original server summary formatting when non-empty after trim', () => { + const featureSummary = '\n### Implementation\n\n- Added API route\n'; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toBe(featureSummary); + expect(result).toContain('### Implementation'); + }); + }); + + describe('pipeline step accumulation scenarios', () => { + it('should display all pipeline steps when using server-accumulated summary', () => { + // This simulates a feature that went through 3 pipeline steps + const featureSummary = [ + '### Implementation', + '', + '## Changes', + '- Created new module', + '- Added tests', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Approved with minor suggestions', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- All 42 tests pass', + '- Coverage: 98%', + ].join('\n'); + + const rawOutput = ` + +Only testing step visible in raw output + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // All pipeline steps should be visible + expect(result).toContain('### Implementation'); + expect(result).toContain('### Code Review'); + expect(result).toContain('### Testing'); + expect(result).toContain('All 42 tests pass'); + }); + + it('should display only last summary when server-side accumulation not available', () => { + // When feature.summary is not available, only the last summary is shown + const rawOutput = ` + +Step 1: Implementation complete + + +--- + + +Step 2: Code review complete + + +--- + + +Step 3: Testing complete + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + // Only the LAST summary should be shown (client-side fallback behavior) + expect(result).toBe('Step 3: Testing complete'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle single-step pipeline (no accumulation needed)', () => { + const featureSummary = '### Implementation\n\nCreated the feature'; + const rawOutput = ''; + + const result = getDisplaySummary(featureSummary, rawOutput); + + expect(result).toBe(featureSummary); + expect(result).not.toContain('---'); // No separator for single step + }); + }); + + describe('edge cases', () => { + it('should return null when both feature.summary and extractSummary are unavailable', () => { + const rawOutput = 'No summary tags here, just regular output.'; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBeNull(); + }); + + it('should return null when rawOutput is empty and no feature summary', () => { + const result = getDisplaySummary(undefined, ''); + + expect(result).toBeNull(); + }); + + it('should return null when rawOutput is whitespace only', () => { + const result = getDisplaySummary(undefined, ' \n\n '); + + expect(result).toBeNull(); + }); + + it('should use client-side fallback when feature.summary is empty string (falsy)', () => { + // Empty string is falsy in JavaScript, so fallback is correctly used. + // This is the expected behavior - an empty summary has no value to display. + const rawOutput = ` + +Fallback content when server summary is empty + +`; + + // Empty string is falsy, so fallback is used + const result = getDisplaySummary('', rawOutput); + expect(result).toBe('Fallback content when server summary is empty'); + }); + + it('should behave identically when feature is null vs feature.summary is undefined', () => { + // This test verifies that the behavior is consistent whether: + // - The feature object itself is null/undefined + // - The feature object exists but summary property is undefined + const rawOutput = ` + +Client-side extracted summary + +`; + + // Both scenarios should use client-side fallback + const resultWithUndefined = getDisplaySummary(undefined, rawOutput); + const resultWithNull = getDisplaySummary(null, rawOutput); + + expect(resultWithUndefined).toBe('Client-side extracted summary'); + expect(resultWithNull).toBe('Client-side extracted summary'); + expect(resultWithUndefined).toBe(resultWithNull); + }); + }); + + describe('markdown content preservation', () => { + it('should preserve markdown formatting in server-accumulated summary', () => { + const featureSummary = `### Code Review + +## Changes Made +- Fixed **critical bug** in \`parser.ts\` +- Added \`validateInput()\` function + +\`\`\`typescript +const x = 1; +\`\`\` + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toContain('**critical bug**'); + expect(result).toContain('`parser.ts`'); + expect(result).toContain('```typescript'); + expect(result).toContain('| Test | Result |'); + }); + + it('should preserve unicode in server-accumulated summary', () => { + const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage'; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toContain('✅'); + expect(result).toContain('❌'); + expect(result).toContain('🎉'); + }); + }); + + describe('real-world scenarios', () => { + it('should handle typical pipeline feature with server accumulation', () => { + // Simulates a real pipeline feature that went through Implementation → Testing + const featureSummary = `### Implementation + +## Changes Made +- Created UserProfile component +- Added authentication middleware +- Updated API endpoints + +--- + +### Testing + +## Test Results +- Unit tests: 15 passed +- Integration tests: 8 passed +- E2E tests: 3 passed`; + + const rawOutput = ` +Working on the feature... + + +## Test Results +- Unit tests: 15 passed +- Integration tests: 8 passed +- E2E tests: 3 passed + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // Both steps should be visible + expect(result).toContain('### Implementation'); + expect(result).toContain('### Testing'); + expect(result).toContain('UserProfile component'); + expect(result).toContain('15 passed'); + }); + + it('should handle non-pipeline feature (single summary)', () => { + // Non-pipeline features have a single summary, no accumulation + const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass'; + const rawOutput = ''; + + const result = getDisplaySummary(featureSummary, rawOutput); + + expect(result).toBe(featureSummary); + expect(result).not.toContain('###'); // No step headers for non-pipeline + }); + + it('should handle legacy feature without server summary (fallback)', () => { + // Legacy features may not have feature.summary set + const rawOutput = ` + +Legacy implementation from before server-side accumulation + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBe('Legacy implementation from before server-side accumulation'); + }); + }); + + describe('view mode determination logic', () => { + /** + * Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86 + * Default to 'summary' if summary is available, otherwise 'parsed' + */ + function getEffectiveViewMode( + viewMode: string | null, + summary: string | null + ): 'summary' | 'parsed' { + return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed'; + } + + it('should default to summary view when server summary is available', () => { + const summary = '### Implementation\n\nContent'; + const result = getEffectiveViewMode(null, summary); + expect(result).toBe('summary'); + }); + + it('should default to summary view when client-side extraction succeeds', () => { + const summary = 'Extracted from raw output'; + const result = getEffectiveViewMode(null, summary); + expect(result).toBe('summary'); + }); + + it('should default to parsed view when no summary is available', () => { + const result = getEffectiveViewMode(null, null); + expect(result).toBe('parsed'); + }); + + it('should respect explicit view mode selection over default', () => { + const summary = 'Summary is available'; + expect(getEffectiveViewMode('raw', summary)).toBe('raw'); + expect(getEffectiveViewMode('parsed', summary)).toBe('parsed'); + expect(getEffectiveViewMode('changes', summary)).toBe('changes'); + }); + }); +}); + +/** + * KEY ARCHITECTURE INSIGHT: + * + * The priority order (feature.summary > extractSummary(output)) is essential for + * pipeline features because: + * + * 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects + * ALL step summaries with headers and separators in chronological order. + * + * 2. Client-side extractSummary() only returns the LAST summary tag from raw output, + * losing all previous step summaries. + * + * 3. The UI must prefer feature.summary to display the complete history of all + * pipeline steps to the user. + * + * For non-pipeline features (single execution), both sources contain the same + * summary, so the priority doesn't matter. But for pipeline features, using the + * wrong source would result in incomplete information display. + */ diff --git a/jules_branch/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts b/jules_branch/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..37a03e9d402b19d47b83aaaca0c26e1137255989 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + parseAllPhaseSummaries, + parsePhaseSummaries, + extractPhaseSummary, + extractImplementationSummary, + isAccumulatedSummary, +} from '../../../../ui/src/lib/log-parser.ts'; + +describe('log-parser mixed summary format compatibility', () => { + const mixedSummary = [ + 'Implemented core auth flow and API wiring.', + '', + '---', + '', + '### Code Review', + '', + 'Addressed lint warnings and improved error handling.', + '', + '---', + '', + '### Testing', + '', + 'All tests passing.', + ].join('\n'); + + it('treats leading headerless section as Implementation phase', () => { + const phases = parsePhaseSummaries(mixedSummary); + + expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.'); + expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.'); + expect(phases.get('testing')).toBe('All tests passing.'); + }); + + it('returns implementation summary from mixed format', () => { + expect(extractImplementationSummary(mixedSummary)).toBe( + 'Implemented core auth flow and API wiring.' + ); + }); + + it('includes Implementation as the first parsed phase entry', () => { + const entries = parseAllPhaseSummaries(mixedSummary); + + expect(entries[0]).toMatchObject({ + phaseName: 'Implementation', + content: 'Implemented core auth flow and API wiring.', + }); + expect(entries.map((entry) => entry.phaseName)).toEqual([ + 'Implementation', + 'Code Review', + 'Testing', + ]); + }); + + it('extracts specific phase summaries from mixed format', () => { + expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe( + 'Implemented core auth flow and API wiring.' + ); + expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe( + 'Addressed lint warnings and improved error handling.' + ); + expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.'); + }); + + it('treats mixed format as accumulated summary', () => { + expect(isAccumulatedSummary(mixedSummary)).toBe(true); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts b/jules_branch/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c919b5a57f631c9267c5193bf9ca8c76d5808492 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts @@ -0,0 +1,973 @@ +/** + * Unit tests for log-parser phase summary parsing functions. + * + * These functions are used to parse accumulated summaries that contain multiple + * pipeline step summaries separated by `---` and identified by `### StepName` headers. + * + * Functions tested: + * - parsePhaseSummaries: Parses the entire accumulated summary into a Map + * - extractPhaseSummary: Extracts a specific phase's content + * - extractImplementationSummary: Extracts implementation phase content (convenience) + * - isAccumulatedSummary: Checks if a summary is in accumulated format + */ + +import { describe, it, expect } from 'vitest'; + +// Mirror the functions from apps/ui/src/lib/log-parser.ts +// (We can't import directly because it's a UI file) + +/** + * Parses an accumulated summary string into individual phase summaries. + */ +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + + if (!summary || !summary.trim()) { + return phaseSummaries; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + + return phaseSummaries; +} + +/** + * Extracts a specific phase summary from an accumulated summary string. + */ +function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { + const phaseSummaries = parsePhaseSummaries(summary); + const normalizedPhaseName = phaseName.toLowerCase(); + return phaseSummaries.get(normalizedPhaseName) || null; +} + +/** + * Extracts the implementation phase summary from an accumulated summary string. + */ +function extractImplementationSummary(summary: string | undefined): string | null { + if (!summary || !summary.trim()) { + return null; + } + + const phaseSummaries = parsePhaseSummaries(summary); + + // Try exact match first + const implementationContent = phaseSummaries.get('implementation'); + if (implementationContent) { + return implementationContent; + } + + // Fallback: find any phase containing "implement" + for (const [phaseName, content] of phaseSummaries) { + if (phaseName.includes('implement')) { + return content; + } + } + + // If no phase summaries found, the summary might not be in accumulated format + // (legacy or non-pipeline feature). In this case, return the whole summary + // if it looks like a single summary (no phase headers). + if (!summary.includes('### ') && !summary.includes('\n---\n')) { + return summary; + } + + return null; +} + +/** + * Checks if a summary string is in the accumulated multi-phase format. + */ +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) { + return false; + } + + // Check for the presence of phase headers with separator + const hasMultiplePhases = + summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0; + + return hasMultiplePhases; +} + +/** + * Represents a single phase entry in an accumulated summary. + */ +interface PhaseSummaryEntry { + /** The phase name (e.g., "Implementation", "Testing", "Code Review") */ + phaseName: string; + /** The content of this phase's summary */ + content: string; + /** The original header line (e.g., "### Implementation") */ + header: string; +} + +/** Default phase name used for non-accumulated summaries */ +const DEFAULT_PHASE_NAME = 'Summary'; + +/** + * Parses an accumulated summary into individual phase entries. + * Returns phases in the order they appear in the summary. + */ +function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] { + const entries: PhaseSummaryEntry[] = []; + + if (!summary || !summary.trim()) { + return entries; + } + + // Check if this is an accumulated summary (has phase headers) + if (!summary.includes('### ')) { + // Not an accumulated summary - return as single entry with generic name + return [ + { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, + ]; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/); + if (headerMatch) { + const header = headerMatch[0].trim(); + const phaseName = headerMatch[2].trim(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + entries.push({ phaseName, content, header }); + } + } + + return entries; +} + +describe('parsePhaseSummaries', () => { + describe('basic parsing', () => { + it('should parse single phase summary', () => { + const summary = `### Implementation + +## Changes Made +- Created new module +- Added unit tests`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe( + '## Changes Made\n- Created new module\n- Added unit tests' + ); + }); + + it('should parse multiple phase summaries', () => { + const summary = `### Implementation + +## Changes Made +- Created new module + +--- + +### Testing + +## Test Results +- All tests pass`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(2); + expect(result.get('implementation')).toBe('## Changes Made\n- Created new module'); + expect(result.get('testing')).toBe('## Test Results\n- All tests pass'); + }); + + it('should handle three or more phases', () => { + const summary = `### Planning + +Plan created + +--- + +### Implementation + +Code written + +--- + +### Testing + +Tests pass + +--- + +### Refinement + +Code polished`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(4); + expect(result.get('planning')).toBe('Plan created'); + expect(result.get('implementation')).toBe('Code written'); + expect(result.get('testing')).toBe('Tests pass'); + expect(result.get('refinement')).toBe('Code polished'); + }); + }); + + describe('edge cases', () => { + it('should return empty map for undefined summary', () => { + const result = parsePhaseSummaries(undefined); + expect(result.size).toBe(0); + }); + + it('should return empty map for null summary', () => { + const result = parsePhaseSummaries(null as unknown as string); + expect(result.size).toBe(0); + }); + + it('should return empty map for empty string', () => { + const result = parsePhaseSummaries(''); + expect(result.size).toBe(0); + }); + + it('should return empty map for whitespace-only string', () => { + const result = parsePhaseSummaries(' \n\n '); + expect(result.size).toBe(0); + }); + + it('should handle summary without phase headers', () => { + const summary = 'Just some regular content without headers'; + const result = parsePhaseSummaries(summary); + expect(result.size).toBe(0); + }); + + it('should handle section without header after separator', () => { + const summary = `### Implementation + +Content here + +--- + +This section has no header`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe('Content here'); + }); + }); + + describe('phase name normalization', () => { + it('should normalize phase names to lowercase', () => { + const summary = `### IMPLEMENTATION + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.has('implementation')).toBe(true); + expect(result.has('IMPLEMENTATION')).toBe(false); + }); + + it('should handle mixed case phase names', () => { + const summary = `### Code Review + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.has('code review')).toBe(true); + }); + + it('should preserve spaces in multi-word phase names', () => { + const summary = `### Code Review + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.get('code review')).toBe('Content'); + }); + }); + + describe('content preservation', () => { + it('should preserve markdown formatting in content', () => { + const summary = `### Implementation + +## Heading +- **Bold text** +- \`code\` +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parsePhaseSummaries(summary); + const content = result.get('implementation'); + + expect(content).toContain('**Bold text**'); + expect(content).toContain('`code`'); + expect(content).toContain('```typescript'); + }); + + it('should preserve unicode in content', () => { + const summary = `### Testing + +Results: ✅ 42 passed, ❌ 0 failed`; + + const result = parsePhaseSummaries(summary); + expect(result.get('testing')).toContain('✅'); + expect(result.get('testing')).toContain('❌'); + }); + + it('should preserve tables in content', () => { + const summary = `### Testing + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = parsePhaseSummaries(summary); + expect(result.get('testing')).toContain('| Test | Result |'); + }); + + it('should handle empty phase content', () => { + const summary = `### Implementation + +--- + +### Testing + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toBe(''); + expect(result.get('testing')).toBe('Content'); + }); + }); +}); + +describe('extractPhaseSummary', () => { + describe('extraction by phase name', () => { + it('should extract specified phase content', () => { + const summary = `### Implementation + +Implementation content + +--- + +### Testing + +Testing content`; + + expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content'); + expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content'); + }); + + it('should be case-insensitive for phase name', () => { + const summary = `### Implementation + +Content`; + + expect(extractPhaseSummary(summary, 'implementation')).toBe('Content'); + expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content'); + expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content'); + }); + + it('should return null for non-existent phase', () => { + const summary = `### Implementation + +Content`; + + expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should return null for undefined summary', () => { + expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull(); + }); + + it('should return null for empty summary', () => { + expect(extractPhaseSummary('', 'Implementation')).toBeNull(); + }); + + it('should handle whitespace in phase name', () => { + const summary = `### Code Review + +Content`; + + expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content'); + expect(extractPhaseSummary(summary, 'code review')).toBe('Content'); + }); + }); +}); + +describe('extractImplementationSummary', () => { + describe('exact match', () => { + it('should extract implementation phase by exact name', () => { + const summary = `### Implementation + +## Changes Made +- Created feature +- Added tests + +--- + +### Testing + +Tests pass`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('## Changes Made\n- Created feature\n- Added tests'); + }); + + it('should be case-insensitive', () => { + const summary = `### IMPLEMENTATION + +Content`; + + expect(extractImplementationSummary(summary)).toBe('Content'); + }); + }); + + describe('partial match fallback', () => { + it('should find phase containing "implement"', () => { + const summary = `### Feature Implementation + +Content here`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Content here'); + }); + + it('should find phase containing "implementation"', () => { + const summary = `### Implementation Phase + +Content here`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Content here'); + }); + }); + + describe('legacy/non-accumulated summary handling', () => { + it('should return full summary if no phase headers present', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + + const result = extractImplementationSummary(summary); + expect(result).toBe(summary); + }); + + it('should return null if summary has phase headers but no implementation', () => { + const summary = `### Testing + +Tests pass + +--- + +### Review + +Review complete`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + + it('should not return full summary if it contains phase headers', () => { + const summary = `### Testing + +Tests pass`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should return null for undefined summary', () => { + expect(extractImplementationSummary(undefined)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(extractImplementationSummary('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(extractImplementationSummary(' \n\n ')).toBeNull(); + }); + }); +}); + +describe('isAccumulatedSummary', () => { + describe('accumulated format detection', () => { + it('should return true for accumulated summary with separator and headers', () => { + const summary = `### Implementation + +Content + +--- + +### Testing + +Content`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return true for accumulated summary with multiple phases', () => { + const summary = `### Phase 1 + +Content 1 + +--- + +### Phase 2 + +Content 2 + +--- + +### Phase 3 + +Content 3`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return true for accumulated summary with just one phase and separator', () => { + // Even a single phase with a separator suggests it's in accumulated format + const summary = `### Implementation + +Content + +--- + +### Testing + +More content`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + }); + + describe('non-accumulated format detection', () => { + it('should return false for summary without separator', () => { + const summary = `### Implementation + +Just content`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for summary with separator but no headers', () => { + const summary = `Content + +--- + +More content`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for simple text summary', () => { + const summary = 'Just a simple summary without any special formatting'; + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for markdown summary without phase headers', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + expect(isAccumulatedSummary(summary)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for undefined summary', () => { + expect(isAccumulatedSummary(undefined)).toBe(false); + }); + + it('should return false for null summary', () => { + expect(isAccumulatedSummary(null as unknown as string)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isAccumulatedSummary('')).toBe(false); + }); + + it('should return false for whitespace-only string', () => { + expect(isAccumulatedSummary(' \n\n ')).toBe(false); + }); + }); +}); + +describe('Integration: Full parsing workflow', () => { + it('should correctly parse typical server-accumulated pipeline summary', () => { + // This simulates what FeatureStateManager.saveFeatureSummary() produces + const summary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '- Created user service', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Style issues fixed', + '- Added error handling', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + '- 98% coverage', + ].join('\n'); + + // Verify isAccumulatedSummary + expect(isAccumulatedSummary(summary)).toBe(true); + + // Verify parsePhaseSummaries + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(3); + expect(phases.get('implementation')).toContain('Added auth module'); + expect(phases.get('code review')).toContain('Style issues fixed'); + expect(phases.get('testing')).toContain('42 tests pass'); + + // Verify extractPhaseSummary + expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module'); + expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed'); + expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass'); + + // Verify extractImplementationSummary + expect(extractImplementationSummary(summary)).toContain('Added auth module'); + }); + + it('should handle legacy non-pipeline summary correctly', () => { + // Legacy features have simple summaries without accumulation + const summary = `## Implementation Complete +- Created the feature +- All tests pass`; + + // Should NOT be detected as accumulated + expect(isAccumulatedSummary(summary)).toBe(false); + + // parsePhaseSummaries should return empty + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(0); + + // extractPhaseSummary should return null + expect(extractPhaseSummary(summary, 'Implementation')).toBeNull(); + + // extractImplementationSummary should return the full summary (legacy handling) + expect(extractImplementationSummary(summary)).toBe(summary); + }); + + it('should handle single-step pipeline summary', () => { + // A single pipeline step still gets the header but no separator + const summary = `### Implementation + +## Changes +- Created the feature`; + + // Should NOT be detected as accumulated (no separator) + expect(isAccumulatedSummary(summary)).toBe(false); + + // parsePhaseSummaries should still extract the single phase + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(1); + expect(phases.get('implementation')).toContain('Created the feature'); + }); +}); + +/** + * KEY ARCHITECTURE NOTES: + * + * 1. The accumulated summary format uses: + * - `### PhaseName` for step headers + * - `\n\n---\n\n` as separator between steps + * + * 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup. + * + * 3. Legacy summaries (non-pipeline features) don't have phase headers and should + * be returned as-is by extractImplementationSummary. + * + * 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be + * confident that the summary is in the accumulated format. + * + * 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for + * creating summaries in this accumulated format. + */ + +describe('parseAllPhaseSummaries', () => { + describe('basic parsing', () => { + it('should parse single phase summary into array with one entry', () => { + const summary = `### Implementation + +## Changes Made +- Created new module +- Added unit tests`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests'); + expect(result[0].header).toBe('### Implementation'); + }); + + it('should parse multiple phase summaries in order', () => { + const summary = `### Implementation + +## Changes Made +- Created new module + +--- + +### Testing + +## Test Results +- All tests pass`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(2); + // Verify order is preserved + expect(result[0].phaseName).toBe('Implementation'); + expect(result[0].content).toBe('## Changes Made\n- Created new module'); + expect(result[1].phaseName).toBe('Testing'); + expect(result[1].content).toBe('## Test Results\n- All tests pass'); + }); + + it('should parse three or more phases in correct order', () => { + const summary = `### Planning + +Plan created + +--- + +### Implementation + +Code written + +--- + +### Testing + +Tests pass + +--- + +### Refinement + +Code polished`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(4); + expect(result[0].phaseName).toBe('Planning'); + expect(result[1].phaseName).toBe('Implementation'); + expect(result[2].phaseName).toBe('Testing'); + expect(result[3].phaseName).toBe('Refinement'); + }); + }); + + describe('non-accumulated summary handling', () => { + it('should return single entry for summary without phase headers', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Summary'); + expect(result[0].content).toBe(summary); + }); + + it('should return single entry for simple text summary', () => { + const summary = 'Just a simple summary without any special formatting'; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Summary'); + expect(result[0].content).toBe(summary); + }); + }); + + describe('edge cases', () => { + it('should return empty array for undefined summary', () => { + const result = parseAllPhaseSummaries(undefined); + expect(result.length).toBe(0); + }); + + it('should return empty array for empty string', () => { + const result = parseAllPhaseSummaries(''); + expect(result.length).toBe(0); + }); + + it('should return empty array for whitespace-only string', () => { + const result = parseAllPhaseSummaries(' \n\n '); + expect(result.length).toBe(0); + }); + + it('should handle section without header after separator', () => { + const summary = `### Implementation + +Content here + +--- + +This section has no header`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Implementation'); + }); + }); + + describe('content preservation', () => { + it('should preserve markdown formatting in content', () => { + const summary = `### Implementation + +## Heading +- **Bold text** +- \`code\` +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parseAllPhaseSummaries(summary); + const content = result[0].content; + + expect(content).toContain('**Bold text**'); + expect(content).toContain('`code`'); + expect(content).toContain('```typescript'); + }); + + it('should preserve unicode in content', () => { + const summary = `### Testing + +Results: ✅ 42 passed, ❌ 0 failed`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].content).toContain('✅'); + expect(result[0].content).toContain('❌'); + }); + + it('should preserve tables in content', () => { + const summary = `### Testing + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].content).toContain('| Test | Result |'); + }); + + it('should handle empty phase content', () => { + const summary = `### Implementation + +--- + +### Testing + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result.length).toBe(2); + expect(result[0].content).toBe(''); + expect(result[1].content).toBe('Content'); + }); + }); + + describe('header preservation', () => { + it('should preserve original header text', () => { + const summary = `### Code Review + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].header).toBe('### Code Review'); + }); + + it('should preserve phase name with original casing', () => { + const summary = `### CODE REVIEW + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].phaseName).toBe('CODE REVIEW'); + }); + }); + + describe('chronological order preservation', () => { + it('should maintain order: Alpha before Beta before Gamma', () => { + const summary = `### Alpha + +First + +--- + +### Beta + +Second + +--- + +### Gamma + +Third`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(3); + const names = result.map((e) => e.phaseName); + expect(names).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('should preserve typical pipeline order', () => { + const summary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Style issues fixed', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + ].join('\n'); + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(3); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[1].phaseName).toBe('Code Review'); + expect(result[2].phaseName).toBe('Testing'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/log-parser-summary.test.ts b/jules_branch/apps/server/tests/unit/ui/log-parser-summary.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5bc0b707a680679697b652812e32d385e5680b2 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/log-parser-summary.test.ts @@ -0,0 +1,453 @@ +/** + * Unit tests for the UI's log-parser extractSummary() function. + * + * These tests document the behavior of extractSummary() which is used as a + * CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available. + * + * IMPORTANT: extractSummary() returns only the LAST tag from raw output. + * For pipeline features with multiple steps, the server-side FeatureStateManager + * accumulates all step summaries into feature.summary, which the UI prefers. + * + * The tests below verify that extractSummary() correctly: + * - Returns the LAST summary when multiple exist (mimicking pipeline accumulation) + * - Handles various summary formats ( tags, markdown headers) + * - Returns null when no summary is found + * - Handles edge cases like empty input and malformed tags + */ + +import { describe, it, expect } from 'vitest'; + +// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts +// We can't import directly because it's a UI file, so we mirror the logic here + +/** + * Cleans up fragmented streaming text by removing spurious newlines + */ +function cleanFragmentedText(content: string): string { + let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); + cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>'); + cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, ''); + return cleaned; +} + +/** + * Extracts summary content from raw log output + * Returns the LAST summary text if found, or null if no summary exists + */ +function extractSummary(rawOutput: string): string | null { + if (!rawOutput || !rawOutput.trim()) { + return null; + } + + const cleanedOutput = cleanFragmentedText(rawOutput); + + const regexesToTry: Array<{ + regex: RegExp; + processor: (m: RegExpMatchArray) => string; + }> = [ + { regex: /([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, + { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, + { + regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, + processor: (m) => `## ${m[1]}\n${m[2]}`, + }, + { + regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, + processor: (m) => m[2], + }, + { + regex: + /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, + processor: (m) => m[2], + }, + ]; + + for (const { regex, processor } of regexesToTry) { + const matches = [...cleanedOutput.matchAll(regex)]; + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + return cleanFragmentedText(processor(lastMatch)).trim(); + } + } + + return null; +} + +describe('log-parser extractSummary (UI fallback)', () => { + describe('basic summary extraction', () => { + it('should extract summary from tags', () => { + const output = ` +Some agent output... + + +## Changes Made +- Fixed the bug in parser.ts +- Added error handling + + +More output... +`; + const result = extractSummary(output); + expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling'); + }); + + it('should prefer tags over markdown headers', () => { + const output = ` +## Summary + +Markdown summary here. + + +XML summary here. + +`; + const result = extractSummary(output); + expect(result).toBe('XML summary here.'); + }); + }); + + describe('multiple summaries (pipeline accumulation scenario)', () => { + it('should return ONLY the LAST summary tag when multiple exist', () => { + // This is the key behavior for pipeline features: + // extractSummary returns only the LAST, which is why server-side + // accumulation is needed for multi-step pipelines + const output = ` +## Step 1: Code Review + + +- Found 3 issues +- Approved with changes + + +--- + +## Step 2: Testing + + +- All tests pass +- Coverage 95% + +`; + const result = extractSummary(output); + expect(result).toBe('- All tests pass\n- Coverage 95%'); + expect(result).not.toContain('Code Review'); + expect(result).not.toContain('Found 3 issues'); + }); + + it('should return ONLY the LAST summary from three pipeline steps', () => { + const output = ` +Step 1 complete + +--- + +Step 2 complete + +--- + +Step 3 complete - all done! +`; + const result = extractSummary(output); + expect(result).toBe('Step 3 complete - all done!'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle mixed summary formats across pipeline steps', () => { + const output = ` +## Step 1 + + +Implementation done + + +--- + +## Step 2 + +## Summary +Review complete + +--- + +## Step 3 + + +All tests passing + +`; + const result = extractSummary(output); + // The tag format takes priority, and returns the LAST match + expect(result).toBe('All tests passing'); + }); + }); + + describe('priority order of summary patterns', () => { + it('should try patterns in priority order: first, then markdown headers', () => { + // When both tags and markdown headers exist, + // tags should take priority + const output = ` +## Summary + +This markdown summary should be ignored. + + +This XML summary should be used. + +`; + const result = extractSummary(output); + expect(result).toBe('This XML summary should be used.'); + expect(result).not.toContain('ignored'); + }); + + it('should fall back to Feature/Changes/Implementation headers when no tag', () => { + // Note: The regex for these headers requires content before the header + // (^ at start or preceded by newline). Adding some content before. + const output = ` +Agent output here... + +## Feature + +New authentication system with OAuth support. + +## Next +`; + const result = extractSummary(output); + // Should find the Feature header and include it in result + // Note: Due to regex behavior, it captures content until next ## + expect(result).toContain('## Feature'); + }); + + it('should fall back to completion phrases when no structured summary found', () => { + const output = ` +Working on the feature... +Making progress... + +All tasks completed successfully. The feature is ready. + +🔧 Tool: Bash +`; + const result = extractSummary(output); + expect(result).toContain('All tasks completed'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(extractSummary('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(extractSummary(' \n\n ')).toBeNull(); + }); + + it('should return null when no summary pattern found', () => { + expect(extractSummary('Random agent output without any summary patterns')).toBeNull(); + }); + + it('should handle malformed tags gracefully', () => { + const output = ` + +This summary is never closed... +`; + // Without closing tag, the regex won't match + expect(extractSummary(output)).toBeNull(); + }); + + it('should handle empty tags', () => { + const output = ` + +`; + const result = extractSummary(output); + expect(result).toBe(''); // Empty string is valid + }); + + it('should handle tags with only whitespace', () => { + const output = ` + + + +`; + const result = extractSummary(output); + expect(result).toBe(''); // Trimmed to empty string + }); + + it('should handle summary with markdown code blocks', () => { + const output = ` + +## Changes + +\`\`\`typescript +const x = 1; +\`\`\` + +Done! + +`; + const result = extractSummary(output); + expect(result).toContain('```typescript'); + expect(result).toContain('const x = 1;'); + }); + + it('should handle summary with special characters', () => { + const output = ` + +Fixed bug in parser.ts: "quotes" and 'apostrophes' +Special chars: <>&$@#%^* + +`; + const result = extractSummary(output); + expect(result).toContain('"quotes"'); + expect(result).toContain('<>&$@#%^*'); + }); + }); + + describe('fragmented streaming text handling', () => { + it('should handle fragmented tags from streaming', () => { + // Sometimes streaming providers split text like "" + const output = ` + +Fixed the issue + +`; + const result = extractSummary(output); + // The cleanFragmentedText function should normalize this + expect(result).toBe('Fixed the issue'); + }); + + it('should handle fragmented text within summary content', () => { + const output = ` + +Fixed the bug in par +ser.ts + +`; + const result = extractSummary(output); + // cleanFragmentedText should join "par\n\nser" into "parser" + expect(result).toBe('Fixed the bug in parser.ts'); + }); + }); + + describe('completion phrase detection', () => { + it('should extract "All tasks completed" summaries', () => { + const output = ` +Some output... + +All tasks completed successfully. The feature is ready for review. + +🔧 Tool: Bash +`; + const result = extractSummary(output); + expect(result).toContain('All tasks completed'); + }); + + it("should extract I've completed summaries", () => { + const output = ` +Working on feature... + +I've successfully implemented the feature with all requirements met. + +🔧 Tool: Read +`; + const result = extractSummary(output); + expect(result).toContain("I've successfully implemented"); + }); + + it('should extract "I have finished" summaries', () => { + const output = ` +Implementation phase... + +I have finished the implementation. + +📋 Planning +`; + const result = extractSummary(output); + expect(result).toContain('I have finished'); + }); + }); + + describe('real-world pipeline scenarios', () => { + it('should handle typical multi-step pipeline output (returns last only)', () => { + // This test documents WHY server-side accumulation is essential: + // extractSummary only returns the last step's summary + const output = ` +📋 Planning Mode: Full + +🔧 Tool: Read +Input: {"file_path": "src/parser.ts"} + + +## Code Review +- Analyzed parser.ts +- Found potential improvements + + +--- + +## Follow-up Session + +🔧 Tool: Edit +Input: {"file_path": "src/parser.ts"} + + +## Implementation +- Applied suggested improvements +- Updated tests + + +--- + +## Follow-up Session + +🔧 Tool: Bash +Input: {"command": "npm test"} + + +## Testing +- All 42 tests pass +- No regressions detected + +`; + const result = extractSummary(output); + // Only the LAST summary is returned + expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected'); + // Earlier summaries are lost + expect(result).not.toContain('Code Review'); + expect(result).not.toContain('Implementation'); + }); + + it('should handle single-step non-pipeline output', () => { + // For non-pipeline features, extractSummary works correctly + const output = ` +Working on feature... + + +## Implementation Complete +- Created new component +- Added unit tests +- Updated documentation + +`; + const result = extractSummary(output); + expect(result).toContain('Implementation Complete'); + expect(result).toContain('Created new component'); + }); + }); +}); + +/** + * These tests verify the UI fallback behavior for summary extraction. + * + * KEY INSIGHT: The extractSummary() function returns only the LAST summary, + * which is why the server-side FeatureStateManager.saveFeatureSummary() method + * accumulates all step summaries into feature.summary. + * + * The UI's AgentOutputModal component uses this priority: + * 1. feature.summary (server-accumulated, contains all steps) + * 2. extractSummary(output) (client-side fallback, last summary only) + * + * For pipeline features, this ensures all step summaries are displayed. + */ diff --git a/jules_branch/apps/server/tests/unit/ui/phase-summary-parser.test.ts b/jules_branch/apps/server/tests/unit/ui/phase-summary-parser.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa7f0533f3be3996b20d7b06488522e70ddcc3d5 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/phase-summary-parser.test.ts @@ -0,0 +1,533 @@ +/** + * Unit tests for the UI's log-parser phase summary parsing functions. + * + * These tests verify the behavior of: + * - parsePhaseSummaries(): Parses accumulated summary into individual phases + * - extractPhaseSummary(): Extracts a specific phase's summary + * - extractImplementationSummary(): Extracts only the implementation phase + * - isAccumulatedSummary(): Checks if summary is in accumulated format + * + * The accumulated summary format uses markdown headers with `###` for phase names + * and `---` as separators between phases. + * + * TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts + * because server-side tests cannot import from the UI module. If the production + * implementation changes, these tests may pass while production fails. + * Consider adding an integration test that validates the actual UI parsing behavior. + */ + +import { describe, it, expect } from 'vitest'; + +// ============================================================================ +// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts +// ============================================================================ +// NOTE: These functions are mirrored from the UI implementation because +// server-side tests cannot import from apps/ui/. Keep these in sync with the +// production implementation. The UI implementation includes additional +// handling for getPhaseSections/leadingImplementationSection for backward +// compatibility with mixed formats. + +/** + * Parses an accumulated summary string into individual phase summaries. + */ +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + + if (!summary || !summary.trim()) { + return phaseSummaries; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + + return phaseSummaries; +} + +/** + * Extracts a specific phase summary from an accumulated summary string. + */ +function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { + const phaseSummaries = parsePhaseSummaries(summary); + const normalizedPhaseName = phaseName.toLowerCase(); + return phaseSummaries.get(normalizedPhaseName) || null; +} + +/** + * Gets the implementation phase summary from an accumulated summary string. + */ +function extractImplementationSummary(summary: string | undefined): string | null { + if (!summary || !summary.trim()) { + return null; + } + + const phaseSummaries = parsePhaseSummaries(summary); + + // Try exact match first + const implementationContent = phaseSummaries.get('implementation'); + if (implementationContent) { + return implementationContent; + } + + // Fallback: find any phase containing "implement" + for (const [phaseName, content] of phaseSummaries) { + if (phaseName.includes('implement')) { + return content; + } + } + + // If no phase summaries found, the summary might not be in accumulated format + // (legacy or non-pipeline feature). In this case, return the whole summary + // if it looks like a single summary (no phase headers). + if (!summary.includes('### ') && !summary.includes('\n---\n')) { + return summary; + } + + return null; +} + +/** + * Checks if a summary string is in the accumulated multi-phase format. + */ +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) { + return false; + } + + // Check for the presence of phase headers with separator + const hasMultiplePhases = + summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0; + + return hasMultiplePhases; +} + +describe('phase summary parser', () => { + describe('parsePhaseSummaries', () => { + it('should parse single phase summary', () => { + const summary = `### Implementation + +Created auth module with login functionality.`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe('Created auth module with login functionality.'); + }); + + it('should parse multiple phase summaries', () => { + const summary = `### Implementation + +Created auth module. + +--- + +### Testing + +All tests pass. + +--- + +### Code Review + +Approved with minor suggestions.`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(3); + expect(result.get('implementation')).toBe('Created auth module.'); + expect(result.get('testing')).toBe('All tests pass.'); + expect(result.get('code review')).toBe('Approved with minor suggestions.'); + }); + + it('should handle empty input', () => { + expect(parsePhaseSummaries('').size).toBe(0); + expect(parsePhaseSummaries(undefined).size).toBe(0); + expect(parsePhaseSummaries(' \n\n ').size).toBe(0); + }); + + it('should handle phase names with spaces', () => { + const summary = `### Code Review + +Review findings here.`; + + const result = parsePhaseSummaries(summary); + expect(result.get('code review')).toBe('Review findings here.'); + }); + + it('should normalize phase names to lowercase', () => { + const summary = `### IMPLEMENTATION + +Content here.`; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toBe('Content here.'); + expect(result.get('IMPLEMENTATION')).toBeUndefined(); + }); + + it('should handle content with markdown', () => { + const summary = `### Implementation + +## Changes Made +- Fixed bug in parser.ts +- Added error handling + +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toContain('## Changes Made'); + expect(result.get('implementation')).toContain('```typescript'); + }); + + it('should return empty map for non-accumulated format', () => { + // Legacy format without phase headers + const summary = `## Summary + +This is a simple summary without phase headers.`; + + const result = parsePhaseSummaries(summary); + expect(result.size).toBe(0); + }); + }); + + describe('extractPhaseSummary', () => { + it('should extract specific phase by name (case-insensitive)', () => { + const summary = `### Implementation + +Implementation content. + +--- + +### Testing + +Testing content.`; + + expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.'); + }); + + it('should return null for non-existent phase', () => { + const summary = `### Implementation + +Content here.`; + + expect(extractPhaseSummary(summary, 'code review')).toBeNull(); + }); + + it('should return null for empty input', () => { + expect(extractPhaseSummary('', 'implementation')).toBeNull(); + expect(extractPhaseSummary(undefined, 'implementation')).toBeNull(); + }); + }); + + describe('extractImplementationSummary', () => { + it('should extract implementation phase from accumulated summary', () => { + const summary = `### Implementation + +Created auth module. + +--- + +### Testing + +All tests pass. + +--- + +### Code Review + +Approved.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Created auth module.'); + expect(result).not.toContain('Testing'); + expect(result).not.toContain('Code Review'); + }); + + it('should return implementation phase even when not first', () => { + const summary = `### Planning + +Plan created. + +--- + +### Implementation + +Implemented the feature. + +--- + +### Review + +Reviewed.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Implemented the feature.'); + }); + + it('should handle phase with "implementation" in name', () => { + const summary = `### Feature Implementation + +Built the feature.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Built the feature.'); + }); + + it('should return full summary for non-accumulated format (legacy)', () => { + // Non-pipeline features store summary without phase headers + const summary = `## Changes +- Fixed bug +- Added tests`; + + const result = extractImplementationSummary(summary); + expect(result).toBe(summary); + }); + + it('should return null for empty input', () => { + expect(extractImplementationSummary('')).toBeNull(); + expect(extractImplementationSummary(undefined)).toBeNull(); + expect(extractImplementationSummary(' \n\n ')).toBeNull(); + }); + + it('should return null when no implementation phase in accumulated summary', () => { + const summary = `### Testing + +Tests written. + +--- + +### Code Review + +Approved.`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + }); + + describe('isAccumulatedSummary', () => { + it('should return true for accumulated multi-phase summary', () => { + const summary = `### Implementation + +Content. + +--- + +### Testing + +Content.`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return false for single phase summary (no separator)', () => { + const summary = `### Implementation + +Content.`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for legacy non-accumulated format', () => { + const summary = `## Summary + +This is a simple summary.`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for empty input', () => { + expect(isAccumulatedSummary('')).toBe(false); + expect(isAccumulatedSummary(undefined)).toBe(false); + expect(isAccumulatedSummary(' \n\n ')).toBe(false); + }); + + it('should return true even for two phases', () => { + const summary = `### Implementation + +Content A. + +--- + +### Code Review + +Content B.`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + }); + + describe('acceptance criteria scenarios', () => { + it('AC1: Implementation summary preserved when Testing completes', () => { + // Given a task card completes the Implementation phase, + // when the Testing phase subsequently completes, + // then the Implementation phase summary must remain stored independently + const summary = `### Implementation + +- Created auth module +- Added user service + +--- + +### Testing + +- 42 tests pass +- 98% coverage`; + + const impl = extractImplementationSummary(summary); + const testing = extractPhaseSummary(summary, 'testing'); + + expect(impl).toBe('- Created auth module\n- Added user service'); + expect(testing).toBe('- 42 tests pass\n- 98% coverage'); + expect(impl).not.toContain('Testing'); + expect(testing).not.toContain('auth module'); + }); + + it('AC4: Implementation Summary tab shows only implementation phase', () => { + // Given a task card has completed the Implementation phase + // (regardless of how many subsequent phases have run), + // when the user opens the "Implementation Summary" tab, + // then it must display only the summary produced by the Implementation phase + const summary = `### Implementation + +Implementation phase output here. + +--- + +### Testing + +Testing phase output here. + +--- + +### Code Review + +Code review output here.`; + + const impl = extractImplementationSummary(summary); + + expect(impl).toBe('Implementation phase output here.'); + expect(impl).not.toContain('Testing'); + expect(impl).not.toContain('Code Review'); + }); + + it('AC5: Empty state when implementation not started', () => { + // Given a task card has not yet started the Implementation phase + const summary = `### Planning + +Planning phase complete.`; + + const impl = extractImplementationSummary(summary); + + // Should return null (UI shows "No implementation summary available") + expect(impl).toBeNull(); + }); + + it('AC6: Single phase summary displayed correctly', () => { + // Given a task card where Implementation was the only completed phase + const summary = `### Implementation + +Only implementation was done.`; + + const impl = extractImplementationSummary(summary); + + expect(impl).toBe('Only implementation was done.'); + }); + + it('AC9: Mid-progress shows only completed phases', () => { + // Given a task card is mid-progress + // (e.g., Implementation and Testing complete, Code Review pending) + const summary = `### Implementation + +Implementation done. + +--- + +### Testing + +Testing done.`; + + const phases = parsePhaseSummaries(summary); + + expect(phases.size).toBe(2); + expect(phases.has('implementation')).toBe(true); + expect(phases.has('testing')).toBe(true); + expect(phases.has('code review')).toBe(false); + }); + + it('AC10: All phases in chronological order', () => { + // Given all phases of a task card are complete + const summary = `### Implementation + +First phase content. + +--- + +### Testing + +Second phase content. + +--- + +### Code Review + +Third phase content.`; + + // ParsePhaseSummaries should preserve order + const phases = parsePhaseSummaries(summary); + const phaseNames = [...phases.keys()]; + + expect(phaseNames).toEqual(['implementation', 'testing', 'code review']); + }); + + it('AC17: Retried phase shows only latest', () => { + // Given a phase was retried, when viewing the Summary tab, + // only one entry for the retried phase must appear (the latest retry's summary) + // + // Note: The server-side FeatureStateManager overwrites the phase summary + // when the same phase runs again, so we only have one entry per phase name. + // This test verifies that the parser correctly handles this. + const summary = `### Implementation + +First attempt content. + +--- + +### Testing + +First test run. + +--- + +### Implementation + +Retry content - fixed issues. + +--- + +### Testing + +Retry - all tests now pass.`; + + const phases = parsePhaseSummaries(summary); + + // The parser will have both entries, but Map keeps last value for same key + expect(phases.get('implementation')).toBe('Retry content - fixed issues.'); + expect(phases.get('testing')).toBe('Retry - all tests now pass.'); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/summary-auto-scroll.test.ts b/jules_branch/apps/server/tests/unit/ui/summary-auto-scroll.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..544e88a05aacfa6791c7f193a7c287813922cf04 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/summary-auto-scroll.test.ts @@ -0,0 +1,238 @@ +/** + * Unit tests for the summary auto-scroll detection logic. + * + * These tests verify the behavior of the scroll detection function used in + * AgentOutputModal to determine if auto-scroll should be enabled. + * + * The logic mirrors the handleSummaryScroll function in: + * apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx + * + * Auto-scroll behavior: + * - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled + * - When user scrolls up to view older content, auto-scroll is disabled + * - Scrolling back to bottom re-enables auto-scroll + */ + +import { describe, it, expect } from 'vitest'; + +/** + * Determines if the scroll position is at the bottom of the container. + * This is the core logic from handleSummaryScroll in AgentOutputModal. + * + * @param scrollTop - Current scroll position from top + * @param scrollHeight - Total scrollable height + * @param clientHeight - Visible height of the container + * @param threshold - Distance from bottom to consider "at bottom" (default: 50px) + * @returns true if at bottom, false otherwise + */ +function isScrollAtBottom( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + threshold = 50 +): boolean { + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + return distanceFromBottom < threshold; +} + +describe('Summary Auto-Scroll Detection Logic', () => { + describe('basic scroll position detection', () => { + it('should return true when scrolled to exact bottom', () => { + // Container: 500px tall, content: 1000px tall + // ScrollTop: 500 (scrolled to bottom) + const result = isScrollAtBottom(500, 1000, 500); + expect(result).toBe(true); + }); + + it('should return true when near bottom (within threshold)', () => { + // 49px from bottom - within 50px threshold + const result = isScrollAtBottom(451, 1000, 500); + expect(result).toBe(true); + }); + + it('should return true when exactly at threshold boundary (49px)', () => { + // 49px from bottom + const result = isScrollAtBottom(451, 1000, 500); + expect(result).toBe(true); + }); + + it('should return false when just outside threshold (51px)', () => { + // 51px from bottom - outside 50px threshold + const result = isScrollAtBottom(449, 1000, 500); + expect(result).toBe(false); + }); + + it('should return false when scrolled to top', () => { + const result = isScrollAtBottom(0, 1000, 500); + expect(result).toBe(false); + }); + + it('should return false when scrolled to middle', () => { + const result = isScrollAtBottom(250, 1000, 500); + expect(result).toBe(false); + }); + }); + + describe('edge cases with small content', () => { + it('should return true when content fits in viewport (no scroll needed)', () => { + // Content is smaller than container - no scrolling possible + const result = isScrollAtBottom(0, 300, 500); + expect(result).toBe(true); + }); + + it('should return true when content exactly fits viewport', () => { + const result = isScrollAtBottom(0, 500, 500); + expect(result).toBe(true); + }); + + it('should return true when content slightly exceeds viewport (within threshold)', () => { + // Content: 540px, Viewport: 500px, can scroll 40px + // At scroll 0, we're 40px from bottom - within threshold + const result = isScrollAtBottom(0, 540, 500); + expect(result).toBe(true); + }); + }); + + describe('large content scenarios', () => { + it('should correctly detect bottom in very long content', () => { + // Simulate accumulated summary from many pipeline steps + // Content: 10000px, Viewport: 500px + const result = isScrollAtBottom(9500, 10000, 500); + expect(result).toBe(true); + }); + + it('should correctly detect non-bottom in very long content', () => { + // User scrolled up to read earlier summaries + const result = isScrollAtBottom(5000, 10000, 500); + expect(result).toBe(false); + }); + + it('should detect when user scrolls up from bottom', () => { + // Started at bottom (scroll: 9500), then scrolled up 100px + const result = isScrollAtBottom(9400, 10000, 500); + expect(result).toBe(false); + }); + }); + + describe('custom threshold values', () => { + it('should work with larger threshold (100px)', () => { + // 75px from bottom - within 100px threshold + const result = isScrollAtBottom(425, 1000, 500, 100); + expect(result).toBe(true); + }); + + it('should work with smaller threshold (10px)', () => { + // 15px from bottom - outside 10px threshold + const result = isScrollAtBottom(485, 1000, 500, 10); + expect(result).toBe(false); + }); + + it('should work with zero threshold (exact match only)', () => { + // At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison + // This is an edge case: the implementation uses < not <= + const result = isScrollAtBottom(500, 1000, 500, 0); + expect(result).toBe(false); // 0 < 0 is false + + // 1px from bottom - also fails + const result2 = isScrollAtBottom(499, 1000, 500, 0); + expect(result2).toBe(false); + + // For exact match with 0 threshold, we need negative distanceFromBottom + // which happens when scrollTop > scrollHeight - clientHeight (overscroll) + const result3 = isScrollAtBottom(501, 1000, 500, 0); + expect(result3).toBe(true); // -1 < 0 is true + }); + }); + + describe('pipeline summary scrolling scenarios', () => { + it('should enable auto-scroll when new content arrives while at bottom', () => { + // User is at bottom viewing step 2 summary + // Step 3 summary is added, increasing scrollHeight from 1000 to 1500 + // ScrollTop stays at 950 (was at bottom), but now user needs to scroll + + // Before new content: isScrollAtBottom(950, 1000, 500) = true + // After new content: auto-scroll should kick in to scroll to new bottom + + // Simulating the auto-scroll effect setting scrollTop to new bottom + const newScrollTop = 1500 - 500; // scrollHeight - clientHeight + const result = isScrollAtBottom(newScrollTop, 1500, 500); + expect(result).toBe(true); + }); + + it('should not auto-scroll when user is reading earlier summaries', () => { + // User scrolled up to read step 1 summary while step 3 is added + // scrollHeight increases, but scrollTop stays same + // User is now further from bottom + + // User was at scroll position 200 (reading early content) + // New content increases scrollHeight from 1000 to 1500 + // Distance from bottom goes from 300 to 800 + const result = isScrollAtBottom(200, 1500, 500); + expect(result).toBe(false); + }); + + it('should re-enable auto-scroll when user scrolls back to bottom', () => { + // User was reading step 1 (scrollTop: 200) + // User scrolls back to bottom to see latest content + const result = isScrollAtBottom(1450, 1500, 500); + expect(result).toBe(true); + }); + }); + + describe('decimal scroll values', () => { + it('should handle fractional scroll positions', () => { + // Browsers can report fractional scroll values + const result = isScrollAtBottom(499.5, 1000, 500); + expect(result).toBe(true); + }); + + it('should handle fractional scroll heights', () => { + const result = isScrollAtBottom(450.7, 1000.3, 500); + expect(result).toBe(true); + }); + }); + + describe('negative and invalid inputs', () => { + it('should handle negative scrollTop (bounce scroll)', () => { + // iOS can report negative scrollTop during bounce + const result = isScrollAtBottom(-10, 1000, 500); + expect(result).toBe(false); + }); + + it('should handle zero scrollHeight', () => { + // Empty content + const result = isScrollAtBottom(0, 0, 500); + expect(result).toBe(true); + }); + + it('should handle zero clientHeight', () => { + // Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000 + // This is not < threshold, so returns false + // This edge case represents a broken/invisible container + const result = isScrollAtBottom(0, 1000, 0); + expect(result).toBe(false); + }); + }); + + describe('real-world accumulated summary dimensions', () => { + it('should handle typical 3-step pipeline summary dimensions', () => { + // Approximate: 3 steps x ~800px each = ~2400px + // Viewport: 400px (modal height) + const result = isScrollAtBottom(2000, 2400, 400); + expect(result).toBe(true); + }); + + it('should handle large 10-step pipeline summary dimensions', () => { + // Approximate: 10 steps x ~800px each = ~8000px + // Viewport: 400px + const result = isScrollAtBottom(7600, 8000, 400); + expect(result).toBe(true); + }); + + it('should detect scroll to top of large summary', () => { + // User at top of 10-step summary + const result = isScrollAtBottom(0, 8000, 400); + expect(result).toBe(false); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/summary-normalization.test.ts b/jules_branch/apps/server/tests/unit/ui/summary-normalization.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..999a4f3506383997fe9eb187c37063ae4a5225f4 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/summary-normalization.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for summary normalization between UI components and parser functions. + * + * These tests verify that: + * - getFirstNonEmptySummary returns string | null + * - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined + * - The normalization (summary ?? undefined) correctly converts null to undefined + * + * This ensures the UI components properly bridge the type gap between: + * - getFirstNonEmptySummary (returns string | null) + * - parseAllPhaseSummaries (expects string | undefined) + * - isAccumulatedSummary (expects string | undefined) + */ + +import { describe, it, expect } from 'vitest'; +import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +describe('Summary Normalization', () => { + describe('getFirstNonEmptySummary', () => { + it('should return the first non-empty string', () => { + const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another'); + expect(result).toBe('valid summary'); + }); + + it('should return null when all candidates are empty', () => { + const result = getFirstNonEmptySummary(null, undefined, '', ' '); + expect(result).toBeNull(); + }); + + it('should return null when no candidates provided', () => { + const result = getFirstNonEmptySummary(); + expect(result).toBeNull(); + }); + + it('should return null for all null/undefined candidates', () => { + const result = getFirstNonEmptySummary(null, undefined, null); + expect(result).toBeNull(); + }); + + it('should preserve original string formatting (not trim)', () => { + const result = getFirstNonEmptySummary(' summary with spaces '); + expect(result).toBe(' summary with spaces '); + }); + }); + + describe('parseAllPhaseSummaries with normalized input', () => { + it('should handle null converted to undefined via ?? operator', () => { + const summary = getFirstNonEmptySummary(null, undefined); + // This is the normalization: summary ?? undefined + const normalizedSummary = summary ?? undefined; + + // TypeScript should accept this without error + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toEqual([]); + }); + + it('should parse accumulated summary when non-null is normalized', () => { + const rawSummary = + '### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass'; + const summary = getFirstNonEmptySummary(null, rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toHaveLength(2); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[1].phaseName).toBe('Testing'); + }); + }); + + describe('isAccumulatedSummary with normalized input', () => { + it('should return false for null converted to undefined', () => { + const summary = getFirstNonEmptySummary(null, undefined); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(false); + }); + + it('should return true for valid accumulated summary after normalization', () => { + const rawSummary = + '### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass'; + const summary = getFirstNonEmptySummary(rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(true); + }); + + it('should return false for single-phase summary after normalization', () => { + const rawSummary = '### Implementation\n\nDid some work'; + const summary = getFirstNonEmptySummary(rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(false); + }); + }); + + describe('Type safety verification', () => { + it('should demonstrate that null must be normalized to undefined', () => { + // This test documents the type mismatch that requires normalization + const summary: string | null = getFirstNonEmptySummary(null); + const normalizedSummary: string | undefined = summary ?? undefined; + + // parseAllPhaseSummaries expects string | undefined, not string | null + // The normalization converts null -> undefined, which is compatible + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toEqual([]); + }); + + it('should work with the actual usage pattern from components', () => { + // Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx + const featureSummary: string | null | undefined = null; + const extractedSummary: string | null | undefined = undefined; + + const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary); + const normalizedSummary = rawSummary ?? undefined; + + // Both parser functions should work with the normalized value + const phases = parseAllPhaseSummaries(normalizedSummary); + const hasMultiple = isAccumulatedSummary(normalizedSummary); + + expect(phases).toEqual([]); + expect(hasMultiple).toBe(false); + }); + }); +}); diff --git a/jules_branch/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts b/jules_branch/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f268d4aca7e1fd68da9e83afabc2895065d11c5 --- /dev/null +++ b/jules_branch/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +/** + * Mirrors summary source priority in agent-info-panel.tsx: + * freshFeature.summary > feature.summary > summaryProp > agentInfo.summary + */ +function getCardEffectiveSummary(params: { + freshFeatureSummary?: string | null; + featureSummary?: string | null; + summaryProp?: string | null; + agentInfoSummary?: string | null; +}): string | undefined | null { + return getFirstNonEmptySummary( + params.freshFeatureSummary, + params.featureSummary, + params.summaryProp, + params.agentInfoSummary + ); +} + +/** + * Mirrors SummaryDialog raw summary selection in summary-dialog.tsx: + * summaryProp > feature.summary > agentInfo.summary + */ +function getDialogRawSummary(params: { + summaryProp?: string | null; + featureSummary?: string | null; + agentInfoSummary?: string | null; +}): string | undefined | null { + return getFirstNonEmptySummary( + params.summaryProp, + params.featureSummary, + params.agentInfoSummary + ); +} + +describe('Summary Source Flow Integration', () => { + it('uses fresh per-feature summary in card and preserves it through summary dialog', () => { + const staleListSummary = '## Old summary from stale list cache'; + const freshAccumulatedSummary = `### Implementation + +Implemented auth + profile flow. + +--- + +### Testing + +- Unit tests: 18 passed +- Integration tests: 6 passed`; + const parsedAgentInfoSummary = 'Fallback summary from parsed agent output'; + + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: freshAccumulatedSummary, + featureSummary: staleListSummary, + summaryProp: undefined, + agentInfoSummary: parsedAgentInfoSummary, + }); + + expect(cardEffectiveSummary).toBe(freshAccumulatedSummary); + + const dialogRawSummary = getDialogRawSummary({ + summaryProp: cardEffectiveSummary, + featureSummary: staleListSummary, + agentInfoSummary: parsedAgentInfoSummary, + }); + + expect(dialogRawSummary).toBe(freshAccumulatedSummary); + expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true); + + const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined); + expect(phases).toHaveLength(2); + expect(phases[0]?.phaseName).toBe('Implementation'); + expect(phases[1]?.phaseName).toBe('Testing'); + }); + + it('falls back in order when fresher sources are absent', () => { + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: undefined, + featureSummary: '', + summaryProp: undefined, + agentInfoSummary: 'Agent parsed fallback', + }); + + expect(cardEffectiveSummary).toBe('Agent parsed fallback'); + + const dialogRawSummary = getDialogRawSummary({ + summaryProp: undefined, + featureSummary: undefined, + agentInfoSummary: cardEffectiveSummary, + }); + + expect(dialogRawSummary).toBe('Agent parsed fallback'); + expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false); + }); + + it('treats whitespace-only summaries as empty during fallback selection', () => { + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: ' \n', + featureSummary: '\t', + summaryProp: ' ', + agentInfoSummary: 'Agent parsed fallback', + }); + + expect(cardEffectiveSummary).toBe('Agent parsed fallback'); + }); +}); diff --git a/jules_branch/apps/server/tests/utils/helpers.ts b/jules_branch/apps/server/tests/utils/helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf928f07ae74998d3b9a7ae20ce065e153530063 --- /dev/null +++ b/jules_branch/apps/server/tests/utils/helpers.ts @@ -0,0 +1,38 @@ +/** + * Test helper functions + */ + +/** + * Collect all values from an async generator + */ +export async function collectAsyncGenerator(gen: AsyncGenerator): Promise { + const results: T[] = []; + for await (const item of gen) { + results.push(item); + } + return results; +} + +/** + * Wait for a condition to be true + */ +export async function waitFor( + condition: () => boolean, + timeout = 1000, + interval = 10 +): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeout) { + throw new Error('Timeout waiting for condition'); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } +} + +/** + * Create a temporary directory for tests + */ +export function createTempDir(): string { + return `/tmp/test-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/jules_branch/apps/server/tests/utils/mocks.ts b/jules_branch/apps/server/tests/utils/mocks.ts new file mode 100644 index 0000000000000000000000000000000000000000..380ac9fd4b84e14f2cbd1697c456a484912fffb8 --- /dev/null +++ b/jules_branch/apps/server/tests/utils/mocks.ts @@ -0,0 +1,107 @@ +/** + * Mock utilities for testing + * Provides reusable mocks for common dependencies + */ + +import { vi } from 'vitest'; +import type { ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import type { Readable } from 'stream'; + +/** + * Mock child_process.spawn for subprocess tests + */ +export function createMockChildProcess(options: { + stdout?: string[]; + stderr?: string[]; + exitCode?: number | null; + shouldError?: boolean; +}): ChildProcess { + const { stdout = [], stderr = [], exitCode = 0, shouldError = false } = options; + + const mockProcess = new EventEmitter() as any; + + // Create mock stdout stream + mockProcess.stdout = new EventEmitter() as Readable; + mockProcess.stderr = new EventEmitter() as Readable; + + mockProcess.kill = vi.fn(); + + // Simulate async output + process.nextTick(() => { + // Emit stdout lines + for (const line of stdout) { + mockProcess.stdout.emit('data', Buffer.from(line + '\n')); + } + + // Emit stderr lines + for (const line of stderr) { + mockProcess.stderr.emit('data', Buffer.from(line + '\n')); + } + + // Emit exit or error + if (shouldError) { + mockProcess.emit('error', new Error('Process error')); + } else { + mockProcess.emit('exit', exitCode); + } + }); + + return mockProcess as ChildProcess; +} + +/** + * Mock fs/promises for file system tests + */ +export function createMockFs() { + return { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), + stat: vi.fn(), + }; +} + +/** + * Mock Express request/response/next for middleware tests + */ +export function createMockExpressContext() { + const req = { + headers: {}, + body: {}, + params: {}, + query: {}, + } as any; + + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + } as any; + + const next = vi.fn(); + + return { req, res, next }; +} + +/** + * Mock AbortController for async operation tests + */ +export function createMockAbortController() { + const controller = new AbortController(); + const originalAbort = controller.abort.bind(controller); + controller.abort = vi.fn(originalAbort); + return controller; +} + +/** + * Mock Claude SDK query function + */ +export function createMockClaudeQuery(messages: any[] = []) { + return vi.fn(async function* ({ prompt, options }: any) { + for (const msg of messages) { + yield msg; + } + }); +} diff --git a/jules_branch/apps/server/tsconfig.json b/jules_branch/apps/server/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..c83c5333234b1fd69d23bfc13d07cead4ebaee6f --- /dev/null +++ b/jules_branch/apps/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/jules_branch/apps/server/tsconfig.test.json b/jules_branch/apps/server/tsconfig.test.json new file mode 100644 index 0000000000000000000000000000000000000000..1c3058b4afa5dd41e65b72bb5a9db992aceea892 --- /dev/null +++ b/jules_branch/apps/server/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node"], + "moduleResolution": "Bundler", + "module": "ESNext" + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/jules_branch/apps/server/vitest.config.ts b/jules_branch/apps/server/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..36577c5eaec4f70aaf0fd0a2386e3584fb2580c3 --- /dev/null +++ b/jules_branch/apps/server/vitest.config.ts @@ -0,0 +1,62 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + name: 'server', + reporters: ['verbose'], + globals: true, + environment: 'node', + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.d.ts', + 'src/index.ts', + 'src/routes/**', // Routes are better tested with integration tests + 'src/types/**', // Type re-exports don't need coverage + 'src/middleware/**', // Middleware needs integration tests + 'src/lib/enhancement-prompts.ts', // Prompt templates don't need unit tests + 'src/services/claude-usage-service.ts', // TODO: Add tests for usage tracking + 'src/services/mcp-test-service.ts', // Needs MCP SDK integration tests + 'src/providers/index.ts', // Just exports + 'src/providers/types.ts', // Type definitions + 'src/providers/cli-provider.ts', // CLI integration - needs integration tests + 'src/providers/cursor-provider.ts', // Cursor CLI integration - needs integration tests + '**/libs/**', // Exclude aliased shared packages from server coverage + ], + thresholds: { + // Coverage thresholds + lines: 60, + functions: 75, + branches: 55, + statements: 60, + }, + }, + include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts'], + exclude: ['**/node_modules/**', '**/dist/**'], + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + // Resolve shared packages to source files for proper mocking in tests + '@automaker/utils': path.resolve(__dirname, '../../libs/utils/src/index.ts'), + '@automaker/platform': path.resolve(__dirname, '../../libs/platform/src/index.ts'), + '@automaker/types': path.resolve(__dirname, '../../libs/types/src/index.ts'), + '@automaker/model-resolver': path.resolve( + __dirname, + '../../libs/model-resolver/src/index.ts' + ), + '@automaker/dependency-resolver': path.resolve( + __dirname, + '../../libs/dependency-resolver/src/index.ts' + ), + '@automaker/git-utils': path.resolve(__dirname, '../../libs/git-utils/src/index.ts'), + }, + }, +}); diff --git a/jules_branch/apps/ui/.gitignore b/jules_branch/apps/ui/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b8ed277526771358a679e61c66c00122e7a228fa --- /dev/null +++ b/jules_branch/apps/ui/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# Vite +/dist/ +/dist-electron/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# typescript +*.tsbuildinfo + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/tests/.auth/ + +# Electron +/release/ +/server-bundle/ + +# TanStack Router generated +src/routeTree.gen.ts diff --git a/jules_branch/apps/ui/components.json b/jules_branch/apps/ui/components.json new file mode 100644 index 0000000000000000000000000000000000000000..edcaef267e34d591c9ae0f0b4cd14a146e6c012f --- /dev/null +++ b/jules_branch/apps/ui/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/jules_branch/apps/ui/docs/AGENT_ARCHITECTURE.md b/jules_branch/apps/ui/docs/AGENT_ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..f5c374c472f9b548e496df7e5756a9d283518bb1 --- /dev/null +++ b/jules_branch/apps/ui/docs/AGENT_ARCHITECTURE.md @@ -0,0 +1,285 @@ +# Agent Architecture - Surviving Next.js Restarts + +## Problem Statement + +When using the Automaker app to iterate on itself: + +1. Agent modifies code files +2. Next.js hot-reloads and restarts +3. API routes are killed +4. Agent conversation is lost + +## Solution: Electron Main Process Agent + +The agent now runs in the **Electron main process** instead of Next.js API routes. This provides: + +- ✅ **Survives Next.js restarts** - Main process is independent of renderer +- ✅ **Persistent state** - Conversations saved to disk automatically +- ✅ **Real-time streaming** - IPC events for live updates +- ✅ **Session recovery** - Reconnects automatically after restart + +## Architecture Overview + +``` +┌─────────────────────────────────────────┐ +│ Electron Main Process │ +│ ┌───────────────────────────────┐ │ +│ │ Agent Service │ │ +│ │ - Manages sessions │ │ +│ │ - Runs Claude Agent SDK │ │ +│ │ - Persists to disk │ │ +│ │ - Streams via IPC │ │ +│ └───────────────────────────────┘ │ +└──────────────┬──────────────────────────┘ + │ IPC (survives restarts) +┌──────────────┴──────────────────────────┐ +│ Electron Renderer (Next.js) │ +│ ┌───────────────────────────────┐ │ +│ │ React Frontend │ │ +│ │ - useElectronAgent hook │ │ +│ │ - Auto-reconnects │ │ +│ │ - Real-time updates │ │ +│ └───────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +## Key Components + +### 1. Agent Service (`electron/agent-service.js`) + +The core service running in the Electron main process: + +- **Session Management**: Tracks multiple conversations by session ID +- **State Persistence**: Saves conversations to `userData/agent-sessions/*.json` +- **Streaming**: Sends real-time updates to renderer via IPC +- **Tool Support**: Full Read/Write/Edit/Bash/Grep/Glob capabilities +- **Error Recovery**: Continues after errors, saves state + +### 2. IPC Handlers (`electron/main.js`) + +Electron main process handlers: + +- `agent:start` - Initialize or resume a session +- `agent:send` - Send a message (returns immediately) +- `agent:getHistory` - Retrieve conversation history +- `agent:stop` - Stop current execution +- `agent:clear` - Clear conversation +- `agent:stream` - Event emitted for streaming updates + +### 3. Preload Bridge (`electron/preload.js`) + +Secure IPC bridge exposed to renderer: + +```javascript +window.electronAPI.agent.start(sessionId, workingDir); +window.electronAPI.agent.send(sessionId, message, workingDir); +window.electronAPI.agent.onStream(callback); +``` + +### 4. React Hook (`src/hooks/use-electron-agent.ts`) + +Easy-to-use React hook: + +```typescript +const { + messages, // Conversation history + isProcessing, // Agent is working + isConnected, // Session initialized + sendMessage, // Send user message + stopExecution, // Stop current task + clearHistory, // Clear conversation + error, // Error state +} = useElectronAgent({ + sessionId: 'project_xyz', + workingDirectory: '/path/to/project', + onToolUse: (tool) => console.log('Using:', tool), +}); +``` + +### 5. Frontend Component (`src/components/views/agent-view.tsx`) + +Updated to use IPC instead of HTTP: + +- Generates session ID from project path +- Auto-reconnects on mount +- Shows tool usage in real-time +- Displays connection status + +## Data Flow + +### Sending a Message + +1. User types message in React UI +2. `sendMessage()` calls `window.electronAPI.agent.send()` +3. IPC handler in main process receives message +4. Agent service starts processing +5. Main process streams updates via `agent:stream` events +6. React hook receives events and updates UI +7. Conversation saved to disk + +### Surviving a Restart + +1. Agent is modifying code → Next.js restarts +2. React component unmounts +3. **Main process keeps running** (agent continues) +4. React component remounts after restart +5. Calls `agent:start` with same session ID +6. Main process returns full conversation history +7. Subscribes to `agent:stream` events +8. UI shows complete conversation + live updates + +## Session Storage + +Sessions are stored in: + +``` +/agent-sessions/.json +``` + +Each session file contains: + +```json +[ + { + "id": "msg_1234_abc", + "role": "user", + "content": "Add a new feature...", + "timestamp": "2024-12-07T12:00:00.000Z" + }, + { + "id": "msg_1235_def", + "role": "assistant", + "content": "I'll help you add that feature...", + "timestamp": "2024-12-07T12:00:05.000Z" + } +] +``` + +## Session ID Generation + +Session IDs are generated from project paths: + +```typescript +const sessionId = `project_${projectPath.replace(/[^a-zA-Z0-9]/g, '_')}`; +``` + +This ensures: + +- Each project has its own conversation +- Conversations persist across app restarts +- Multiple projects can run simultaneously + +## Streaming Events + +The agent emits these event types: + +### `message` + +User message added to conversation + +### `stream` + +Assistant response streaming (updates in real-time) + +### `tool_use` + +Agent is using a tool (Read, Write, Edit, etc.) + +### `complete` + +Agent finished processing + +### `error` + +Error occurred during processing + +## Configuration + +The agent is configured with: + +```javascript +{ + model: "claude-opus-4-6", + maxTurns: 20, + cwd: workingDirectory, + allowedTools: [ + "Read", "Write", "Edit", "Glob", "Grep", + "Bash", "WebSearch", "WebFetch" + ], + permissionMode: "acceptEdits", // Auto-approve file edits + sandbox: { + enabled: true, // Sandboxed bash execution + autoAllowBashIfSandboxed: true + } +} +``` + +## Benefits + +### For Self-Iteration + +Now you can ask the agent to modify Automaker itself: + +``` +User: "Add a dark mode toggle to the settings" +Agent: *modifies files* +→ Next.js restarts +→ Agent continues working +→ UI reconnects automatically +→ Shows full conversation history +``` + +### For Long-Running Tasks + +The agent can work on complex tasks that take multiple turns: + +``` +User: "Implement authentication with GitHub OAuth" +Agent: + 1. Creates auth API routes + 2. Next.js restarts + 3. Agent continues: Adds middleware + 4. Next.js restarts again + 5. Agent continues: Updates UI components + 6. All changes tracked, conversation preserved +``` + +## Testing + +To test the architecture: + +1. Open a project in Automaker +2. Ask the agent to modify a file in `src/` +3. Watch Next.js restart +4. Verify the conversation continues +5. Check that history is preserved +6. Restart the entire Electron app +7. Verify conversation loads from disk + +## Troubleshooting + +### "Electron API not available" + +- Make sure you're running in Electron, not browser +- Check `window.isElectron` is `true` + +### Session not persisting + +- Check userData directory exists +- Verify write permissions +- Look for errors in Electron console + +### Next.js restart kills agent + +- Verify agent service is in `electron/main.js` +- Check IPC handlers are registered +- Ensure not using HTTP `/api/chat` route + +## Future Enhancements + +- [ ] Multiple concurrent sessions +- [ ] Export conversation history +- [ ] Undo/redo for agent actions +- [ ] Progress bars for long-running tasks +- [ ] Voice input/output +- [ ] Agent memory across sessions diff --git a/jules_branch/apps/ui/docs/SESSION_MANAGEMENT.md b/jules_branch/apps/ui/docs/SESSION_MANAGEMENT.md new file mode 100644 index 0000000000000000000000000000000000000000..b4c5eac775c11443cf01111835d8c64a2296e55a --- /dev/null +++ b/jules_branch/apps/ui/docs/SESSION_MANAGEMENT.md @@ -0,0 +1,393 @@ +# Session Management Guide + +## Overview + +The Automaker Agent Chat now supports multiple concurrent sessions, allowing you to organize different conversations by topic, feature, or task. Each session is independently managed and persisted. + +## Features + +### ✨ Multiple Sessions + +- Create unlimited agent sessions per project +- Each session has its own conversation history +- Switch between sessions instantly +- Sessions persist across app restarts + +### 📋 Session Organization + +- Custom names for easy identification +- Last message preview +- Message count tracking +- Sort by most recently updated + +### 🗄️ Archive & Delete + +- Archive old sessions to declutter +- Unarchive when needed +- Permanently delete sessions +- Confirm before destructive actions + +### 💾 Automatic Persistence + +- All sessions auto-save to disk +- Survive Next.js restarts +- Survive Electron app restarts +- Never lose your conversations + +## User Interface + +### Session Manager Sidebar + +Located on the left side of the Agent Chat view: + +``` +┌──────────────────────────┬────────────────────────┐ +│ Session Manager │ Chat Messages │ +│ │ │ +│ [+ New] [Archive] │ User: Hello │ +│ │ Agent: Hi there! │ +│ 📝 Feature: Auth │ │ +│ "Add OAuth login..." │ [Input field] │ +│ 42 messages │ │ +│ │ │ +│ 📝 Bug: Payment │ │ +│ "Fix stripe inte..." │ │ +│ 15 messages │ │ +│ │ │ +└──────────────────────────┴────────────────────────┘ +``` + +### Toggle Sidebar + +Click the panel icon in the header to show/hide the session manager. + +## How to Use + +### Creating a Session + +1. Click the **"+ New"** button +2. Enter a descriptive name +3. Press Enter or click ✓ +4. The new session is immediately active + +**Example session names:** + +- "Feature: Dark Mode" +- "Bug: Login redirect" +- "Refactor: API layer" +- "Docs: Getting started" + +### Switching Sessions + +Simply click on any session in the list to switch to it. The conversation history loads instantly. + +### Renaming a Session + +1. Click the edit icon (✏️) next to the session name +2. Type the new name +3. Press Enter or click ✓ + +### Clearing a Session + +Click the **"Clear"** button in the chat header to delete all messages from the current session while keeping the session itself. + +### Archiving a Session + +1. Click the archive icon (📦) next to the session +2. The session moves to the archived list +3. Toggle **"Show Archived"** to view archived sessions + +**When to archive:** + +- Completed features +- Resolved bugs +- Old experiments +- Historical reference + +### Unarchiving a Session + +1. Toggle **"Show Archived"** to see archived sessions +2. Click the unarchive icon (📤) +3. The session returns to the active list + +### Deleting a Session + +1. Archive the session first +2. View archived sessions +3. Click the delete icon (🗑️) +4. Confirm the deletion +5. **This is permanent!** + +## Storage Location + +Sessions are stored in your user data directory: + +**macOS:** + +``` +~/Library/Application Support/automaker/agent-sessions/ +``` + +**Windows:** + +``` +%APPDATA%/automaker/agent-sessions/ +``` + +**Linux:** + +``` +~/.config/automaker/agent-sessions/ +``` + +### File Structure + +``` +agent-sessions/ +├── session_1234567890_abc.json # Session conversation +├── session_1234567891_def.json # Another session +└── sessions-metadata.json # Session metadata +``` + +### Session File Format + +Each session file contains an array of messages: + +```json +[ + { + "id": "msg_1234567890_xyz", + "role": "user", + "content": "Add authentication to the app", + "timestamp": "2024-12-07T12:00:00.000Z" + }, + { + "id": "msg_1234567891_abc", + "role": "assistant", + "content": "I'll help you add authentication...", + "timestamp": "2024-12-07T12:00:05.000Z" + } +] +``` + +### Metadata File Format + +The metadata file tracks all sessions: + +```json +{ + "session_1234567890_abc": { + "name": "Feature: Authentication", + "projectPath": "/path/to/project", + "createdAt": "2024-12-07T12:00:00.000Z", + "updatedAt": "2024-12-07T12:30:00.000Z", + "isArchived": false, + "tags": [] + } +} +``` + +## Best Practices + +### Naming Conventions + +Use prefixes to organize sessions by type: + +- **Feature:** New functionality + - "Feature: Dark mode toggle" + - "Feature: User profiles" + +- **Bug:** Issue resolution + - "Bug: Memory leak in dashboard" + - "Bug: Form validation errors" + +- **Refactor:** Code improvements + - "Refactor: Database layer" + - "Refactor: Component structure" + +- **Docs:** Documentation work + - "Docs: API documentation" + - "Docs: README updates" + +- **Experiment:** Try new ideas + - "Experiment: WebGL renderer" + - "Experiment: New state management" + +### Session Lifecycle + +1. **Create** → Start a new feature or task +2. **Work** → Have conversation, iterate on code +3. **Complete** → Finish the task +4. **Archive** → Keep for reference +5. **Delete** → Remove when no longer needed + +### When to Create Multiple Sessions + +**Do create separate sessions for:** + +- ✅ Different features +- ✅ Unrelated bugs +- ✅ Experimental work +- ✅ Different contexts or approaches + +**Don't create separate sessions for:** + +- ❌ Same feature, different iterations +- ❌ Related bug fixes +- ❌ Continuation of previous work + +### Managing Session Clutter + +- Archive completed work weekly +- Delete archived sessions after 30 days +- Use clear naming conventions +- Consolidate related sessions + +## Integration with Project Workflow + +### Feature Development + +``` +1. Create: "Feature: User notifications" +2. Agent: Design the notification system +3. Agent: Implement backend +4. Next.js restarts (agent continues) +5. Agent: Implement frontend +6. Agent: Add tests +7. Complete & Archive +``` + +### Bug Fixing + +``` +1. Create: "Bug: Payment processing timeout" +2. Agent: Investigate the issue +3. Agent: Identify root cause +4. Agent: Implement fix +5. Agent: Add regression test +6. Complete & Archive +``` + +### Refactoring + +``` +1. Create: "Refactor: API error handling" +2. Agent: Analyze current implementation +3. Agent: Design new approach +4. Agent: Refactor service layer +5. Next.js restarts (agent continues) +6. Agent: Refactor controller layer +7. Agent: Update tests +8. Complete & Archive +``` + +## Keyboard Shortcuts + +_(Coming soon)_ + +- `Cmd/Ctrl + K` - Create new session +- `Cmd/Ctrl + [` - Previous session +- `Cmd/Ctrl + ]` - Next session +- `Cmd/Ctrl + Shift + A` - Toggle archive view + +## Troubleshooting + +### Session Not Saving + +**Check:** + +- Electron has write permissions +- Disk space available +- Check Electron console for errors + +**Solution:** + +```bash +# macOS - Check permissions +ls -la ~/Library/Application\ Support/automaker/ + +# Fix permissions if needed +chmod -R u+w ~/Library/Application\ Support/automaker/ +``` + +### Can't Switch Sessions + +**Check:** + +- Session is not archived +- No errors in console +- Agent is not currently processing + +**Solution:** + +- Wait for current message to complete +- Check for error messages +- Try clearing and reloading + +### Session Disappeared + +**Check:** + +- Not filtered by archive status +- Not accidentally deleted +- Check backup files + +**Recovery:** + +- Toggle "Show Archived" +- Check filesystem for `.json` files +- Restore from backup if available + +## API Reference + +For developers integrating session management: + +### Create Session + +```typescript +const result = await window.electronAPI.sessions.create( + 'Session Name', + '/project/path', + '/working/directory' +); +``` + +### List Sessions + +```typescript +const { sessions } = await window.electronAPI.sessions.list( + false // includeArchived +); +``` + +### Update Session + +```typescript +await window.electronAPI.sessions.update(sessionId, 'New Name', ['tag1', 'tag2']); +``` + +### Archive/Unarchive + +```typescript +await window.electronAPI.sessions.archive(sessionId); +await window.electronAPI.sessions.unarchive(sessionId); +``` + +### Delete Session + +```typescript +await window.electronAPI.sessions.delete(sessionId); +``` + +## Future Enhancements + +- [ ] Tag system for categorization +- [ ] Search sessions by content +- [ ] Export session to markdown +- [ ] Share sessions with team +- [ ] Session templates +- [ ] Keyboard shortcuts +- [ ] Drag & drop to reorder +- [ ] Favorite/pin sessions +- [ ] Session statistics +- [ ] Automatic archiving rules diff --git a/jules_branch/apps/ui/eslint.config.mjs b/jules_branch/apps/ui/eslint.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0bd5a9b2b2f62afdcf5c48321abf48755786341f --- /dev/null +++ b/jules_branch/apps/ui/eslint.config.mjs @@ -0,0 +1,190 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import ts from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import reactHooks from 'eslint-plugin-react-hooks'; + +const eslintConfig = defineConfig([ + js.configs.recommended, + { + files: ['**/*.mjs', '**/*.cjs'], + languageOptions: { + globals: { + console: 'readonly', + process: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + }, + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + // Browser/DOM APIs + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + Navigator: 'readonly', + localStorage: 'readonly', + sessionStorage: 'readonly', + fetch: 'readonly', + WebSocket: 'readonly', + File: 'readonly', + FileList: 'readonly', + FileReader: 'readonly', + Blob: 'readonly', + atob: 'readonly', + crypto: 'readonly', + prompt: 'readonly', + confirm: 'readonly', + getComputedStyle: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + requestIdleCallback: 'readonly', + cancelIdleCallback: 'readonly', + alert: 'readonly', + // DOM Element Types + HTMLElement: 'readonly', + HTMLInputElement: 'readonly', + HTMLDivElement: 'readonly', + HTMLButtonElement: 'readonly', + HTMLSpanElement: 'readonly', + HTMLTextAreaElement: 'readonly', + HTMLHeadingElement: 'readonly', + HTMLParagraphElement: 'readonly', + HTMLImageElement: 'readonly', + HTMLLinkElement: 'readonly', + HTMLScriptElement: 'readonly', + Element: 'readonly', + SVGElement: 'readonly', + SVGSVGElement: 'readonly', + // Event Types + Event: 'readonly', + KeyboardEvent: 'readonly', + DragEvent: 'readonly', + PointerEvent: 'readonly', + CustomEvent: 'readonly', + ClipboardEvent: 'readonly', + WheelEvent: 'readonly', + MouseEvent: 'readonly', + UIEvent: 'readonly', + MediaQueryListEvent: 'readonly', + PageTransitionEvent: 'readonly', + DataTransfer: 'readonly', + // Web APIs + ResizeObserver: 'readonly', + AbortSignal: 'readonly', + AbortController: 'readonly', + IntersectionObserver: 'readonly', + Audio: 'readonly', + HTMLAudioElement: 'readonly', + ScrollBehavior: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + XMLHttpRequest: 'readonly', + Response: 'readonly', + RequestInit: 'readonly', + RequestCache: 'readonly', + ServiceWorkerRegistration: 'readonly', + // Timers + setTimeout: 'readonly', + setInterval: 'readonly', + clearTimeout: 'readonly', + clearInterval: 'readonly', + queueMicrotask: 'readonly', + // Node.js (for scripts and Electron) + process: 'readonly', + require: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + NodeJS: 'readonly', + // React + React: 'readonly', + JSX: 'readonly', + // Electron + Electron: 'readonly', + // Console + console: 'readonly', + // Structured clone (modern browser/Node API) + structuredClone: 'readonly', + // Vite defines + __APP_VERSION__: 'readonly', + __APP_BUILD_HASH__: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': ts, + 'react-hooks': reactHooks, + }, + rules: { + ...ts.configs.recommended.rules, + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/ban-ts-comment': [ + 'error', + { + 'ts-nocheck': 'allow-with-description', + minimumDescriptionLength: 10, + }, + ], + }, + }, + { + files: ['public/sw.js'], + languageOptions: { + globals: { + // Service Worker globals + self: 'readonly', + caches: 'readonly', + fetch: 'readonly', + Headers: 'readonly', + Response: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + console: 'readonly', + // Built-in globals used in sw.js + Date: 'readonly', + Promise: 'readonly', + Set: 'readonly', + JSON: 'readonly', + String: 'readonly', + Array: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + }, + }, + globalIgnores([ + 'dist/**', + 'dist-electron/**', + 'node_modules/**', + 'server-bundle/**', + 'release/**', + 'src/routeTree.gen.ts', + ]), +]); + +export default eslintConfig; diff --git a/jules_branch/apps/ui/index.html b/jules_branch/apps/ui/index.html new file mode 100644 index 0000000000000000000000000000000000000000..afc0f07fc2804191ed93bb8b4df8117a7fd80509 --- /dev/null +++ b/jules_branch/apps/ui/index.html @@ -0,0 +1,240 @@ + + + + + Automaker - Autonomous AI Development Studio + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+ + + diff --git a/jules_branch/apps/ui/nginx.conf b/jules_branch/apps/ui/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..da56165d015afbeb9829692c25e2357919684bb9 --- /dev/null +++ b/jules_branch/apps/ui/nginx.conf @@ -0,0 +1,31 @@ +# Map for conditional WebSocket upgrade header +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Proxy API and WebSocket requests to the backend server container + # Handles both HTTP API calls and WebSocket upgrades (/api/events, /api/terminal/ws) + location /api/ { + proxy_pass http://server:3008; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_read_timeout 3600s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/jules_branch/apps/ui/package.json b/jules_branch/apps/ui/package.json new file mode 100644 index 0000000000000000000000000000000000000000..a95bb3eca774b00b7868719c9005c264d391e6cf --- /dev/null +++ b/jules_branch/apps/ui/package.json @@ -0,0 +1,302 @@ +{ + "name": "@automaker/ui", + "version": "1.0.0", + "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", + "homepage": "https://github.com/AutoMaker-Org/automaker", + "repository": { + "type": "git", + "url": "https://github.com/AutoMaker-Org/automaker.git" + }, + "author": "AutoMaker Team", + "license": "SEE LICENSE IN LICENSE", + "desktopName": "automaker.desktop", + "private": true, + "engines": { + "node": ">=22.0.0 <23.0.0" + }, + "main": "dist-electron/main.js", + "scripts": { + "dev": "vite", + "dev:web": "cross-env VITE_SKIP_ELECTRON=true vite", + "dev:electron": "vite", + "dev:electron:debug": "cross-env OPEN_DEVTOOLS=true vite", + "build": "vite build", + "build:electron": "node scripts/prepare-server.mjs && vite build && electron-builder", + "build:electron:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --dir", + "build:electron:win": "node scripts/prepare-server.mjs && vite build && electron-builder --win", + "build:electron:win:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --win --dir", + "build:electron:mac": "node scripts/prepare-server.mjs && vite build && electron-builder --mac", + "build:electron:mac:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --mac --dir", + "build:electron:linux": "node scripts/prepare-server.mjs && vite build && electron-builder --linux", + "build:electron:linux:dir": "node scripts/prepare-server.mjs && vite build && electron-builder --linux --dir", + "postinstall": "electron-builder install-app-deps", + "preview": "vite preview", + "lint": "npx eslint", + "typecheck": "tsc --noEmit", + "pretest": "node scripts/kill-test-servers.mjs && node scripts/setup-e2e-fixtures.mjs", + "test": "playwright test", + "test:headed": "playwright test --headed", + "dev:electron:wsl": "cross-env vite", + "dev:electron:wsl:gpu": "cross-env MESA_D3D12_DEFAULT_ADAPTER_NAME=NVIDIA vite" + }, + "dependencies": { + "@automaker/dependency-resolver": "1.0.0", + "@automaker/spec-parser": "1.0.0", + "@automaker/types": "1.0.0", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "6.1.0", + "@codemirror/language": "^6.12.1", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/merge": "^6.12.0", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.5.4", + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "^6.39.15", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@fontsource/cascadia-code": "^5.2.3", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/inconsolata": "^5.2.8", + "@fontsource/inter": "^5.2.8", + "@fontsource/iosevka": "^5.2.5", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/lato": "^5.2.7", + "@fontsource/montserrat": "^5.2.8", + "@fontsource/open-sans": "^5.2.7", + "@fontsource/poppins": "^5.2.7", + "@fontsource/raleway": "^5.2.8", + "@fontsource/roboto": "^5.2.9", + "@fontsource/source-code-pro": "^5.2.7", + "@fontsource/source-sans-3": "^5.2.9", + "@fontsource/work-sans": "^5.2.8", + "@lezer/highlight": "1.2.3", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-query": "^5.90.17", + "@tanstack/react-query-devtools": "^5.91.2", + "@tanstack/react-query-persist-client": "^5.90.22", + "@tanstack/react-router": "1.141.6", + "@uiw/react-codemirror": "4.25.4", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/addon-webgl": "0.18.0", + "@xterm/xterm": "5.5.0", + "@xyflow/react": "12.10.0", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "dagre": "0.8.5", + "dotenv": "17.2.3", + "geist": "1.5.1", + "idb-keyval": "^6.2.2", + "lucide-react": "0.562.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-markdown": "10.1.0", + "react-resizable-panels": "3.0.6", + "rehype-raw": "7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "sonner": "2.0.7", + "tailwind-merge": "3.4.0", + "usehooks-ts": "3.1.1", + "zod": "^3.24.1 || ^4.0.0", + "zustand": "5.0.9" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + }, + "devDependencies": { + "@electron/rebuild": "4.0.2", + "@eslint/js": "9.0.0", + "@playwright/test": "1.57.0", + "@tailwindcss/vite": "4.1.18", + "@tanstack/router-plugin": "1.141.7", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/dagre": "0.7.53", + "@types/node": "22.19.3", + "@types/react": "19.2.7", + "@types/react-dom": "19.2.3", + "@typescript-eslint/eslint-plugin": "8.50.0", + "@typescript-eslint/parser": "8.50.0", + "@vitejs/plugin-react": "5.1.2", + "cross-env": "10.1.0", + "electron": "39.2.7", + "electron-builder": "26.0.12", + "eslint": "9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "jsdom": "^28.1.0", + "tailwindcss": "4.1.18", + "tw-animate-css": "1.4.0", + "typescript": "5.9.3", + "vite": "7.3.0", + "vite-plugin-electron": "0.29.0", + "vite-plugin-electron-renderer": "0.14.6" + }, + "build": { + "appId": "com.automaker.app", + "productName": "Automaker", + "artifactName": "${productName}-${version}-${arch}.${ext}", + "npmRebuild": false, + "publish": null, + "afterPack": "./scripts/rebuild-server-natives.cjs", + "directories": { + "output": "release" + }, + "files": [ + "dist/**/*", + "dist-electron/**/*", + "public/**/*", + "!node_modules/**/*" + ], + "extraResources": [ + { + "from": "server-bundle/dist", + "to": "server" + }, + { + "from": "server-bundle/node_modules", + "to": "server/node_modules" + }, + { + "from": "server-bundle/libs", + "to": "server/libs" + }, + { + "from": "server-bundle/package.json", + "to": "server/package.json" + }, + { + "from": "../../.env", + "to": ".env", + "filter": [ + "**/*" + ] + }, + { + "from": "public/logo_larger.png", + "to": "logo_larger.png" + } + ], + "mac": { + "category": "public.app-category.developer-tools", + "target": [ + { + "target": "dmg", + "arch": [ + "x64", + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "public/logo_larger.png" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "public/icon.ico" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64" + ] + }, + { + "target": "deb", + "arch": [ + "x64" + ] + }, + { + "target": "rpm", + "arch": [ + "x64" + ] + } + ], + "category": "Development", + "icon": "public/logo_larger.png", + "maintainer": "webdevcody@gmail.com", + "executableName": "automaker", + "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", + "synopsis": "AI-powered autonomous development studio", + "desktop": { + "entry": { + "Icon": "/opt/Automaker/resources/logo_larger.png" + } + } + }, + "rpm": { + "depends": [ + "gtk3", + "libnotify", + "nss", + "libXScrnSaver", + "libXtst", + "xdg-utils", + "at-spi2-core", + "libuuid" + ], + "compression": "xz", + "vendor": "AutoMaker Team", + "afterInstall": "scripts/rpm-after-install.sh" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true + } + } +} diff --git a/jules_branch/apps/ui/playwright.config.ts b/jules_branch/apps/ui/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1230318cc96b0490e4804a0b489945c1da05e22 --- /dev/null +++ b/jules_branch/apps/ui/playwright.config.ts @@ -0,0 +1,127 @@ +import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; + +const port = process.env.TEST_PORT || 3107; + +// PATH that includes common git locations so the E2E server can run git (worktree list, etc.) +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const extraPath = + process.platform === 'win32' + ? [ + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`, + ].filter(Boolean) + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/home/linuxbrew/.linuxbrew/bin', + process.env.HOME && `${process.env.HOME}/.local/bin`, + ].filter(Boolean); +const e2eServerPath = [process.env.PATH, ...extraPath].filter(Boolean).join(pathSeparator); +const serverPort = process.env.TEST_SERVER_PORT || 3108; +// When true, no webServer is started; you must run UI (port 3107) and server (3108) yourself. +const reuseServer = process.env.TEST_REUSE_SERVER === 'true'; +// Only skip backend startup when explicitly requested for E2E runs. +// VITE_SERVER_URL may be set in user shells for local dev and should not affect tests. +const useExternalBackend = process.env.TEST_USE_EXTERNAL_BACKEND === 'true'; +// Always use mock agent for tests (disables rate limiting, uses mock Claude responses) +const mockAgent = true; + +// Auth state file written by global setup, reused by all tests to skip per-test login +const AUTH_STATE_PATH = path.join(__dirname, 'tests/.auth/storage-state.json'); + +export default defineConfig({ + testDir: './tests', + // Keep Playwright scoped to E2E specs so Vitest unit files are not executed here. + testMatch: '**/*.spec.ts', + testIgnore: ['**/unit/**'], + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // Use multiple workers for parallelism. CI gets 2 workers (constrained resources), + // local runs use 8 workers for faster test execution. + workers: process.env.CI ? 2 : 8, + reporter: process.env.CI ? 'github' : 'html', + timeout: 30000, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: 'on-failure', + screenshot: 'only-on-failure', + serviceWorkers: 'block', + // Reuse auth state from global setup - avoids per-test login overhead + storageState: AUTH_STATE_PATH, + }, + // Global setup - authenticate once and save state for all workers + globalSetup: require.resolve('./tests/global-setup.ts'), + globalTeardown: require.resolve('./tests/global-teardown.ts'), + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + ...(reuseServer + ? {} + : { + webServer: [ + // Backend server - runs with mock agent enabled in CI + // Uses dev:test (no file watching) to avoid port conflicts from server restarts + ...(useExternalBackend + ? [] + : [ + { + command: `cd ../server && npm run dev:test`, + url: `http://127.0.0.1:${serverPort}/api/health`, + // Don't reuse existing server to ensure we use the test API key + reuseExistingServer: false, + timeout: 60000, + env: { + ...process.env, + PORT: String(serverPort), + // Ensure server can find git in CI/minimal env (worktree list, etc.) + PATH: e2eServerPath, + // Enable mock agent in CI to avoid real API calls + AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false', + // Set a test API key for web mode authentication + AUTOMAKER_API_KEY: + process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests', + // Hide the API key banner to reduce log noise + AUTOMAKER_HIDE_API_KEY: 'true', + // Explicitly unset ALLOWED_ROOT_DIRECTORY to allow all paths for testing + // (prevents inheriting /projects from Docker or other environments) + ALLOWED_ROOT_DIRECTORY: '', + // Simulate containerized environment to skip sandbox confirmation dialogs + IS_CONTAINERIZED: 'true', + // Increase Node.js memory limit to prevent OOM during tests + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096'] + .filter(Boolean) + .join(' '), + }, + }, + ]), + // Frontend Vite dev server + { + command: `npm run dev`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: false, + timeout: 120000, + env: { + ...process.env, + // Must set AUTOMAKER_WEB_PORT to match the port Playwright waits for + AUTOMAKER_WEB_PORT: String(port), + // Must set AUTOMAKER_SERVER_PORT so Vite proxy forwards to the correct backend port + AUTOMAKER_SERVER_PORT: String(serverPort), + VITE_SKIP_SETUP: 'true', + // Always skip electron plugin during tests - prevents duplicate server spawning + VITE_SKIP_ELECTRON: 'true', + // Clear VITE_SERVER_URL to force the frontend to use the Vite proxy (/api) + // instead of calling the backend directly. Direct calls bypass the proxy and + // cause cookie domain mismatches (cookies are bound to 127.0.0.1 but + // VITE_SERVER_URL typically uses localhost). + VITE_SERVER_URL: '', + }, + }, + ], + }), +}); diff --git a/jules_branch/apps/ui/public/automaker.svg b/jules_branch/apps/ui/public/automaker.svg new file mode 100644 index 0000000000000000000000000000000000000000..ece4dc5c0397fa424268e1dffff5943c64ed3041 --- /dev/null +++ b/jules_branch/apps/ui/public/automaker.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jules_branch/apps/ui/public/file.svg b/jules_branch/apps/ui/public/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862 --- /dev/null +++ b/jules_branch/apps/ui/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jules_branch/apps/ui/public/globe.svg b/jules_branch/apps/ui/public/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb --- /dev/null +++ b/jules_branch/apps/ui/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jules_branch/apps/ui/public/icon.ico b/jules_branch/apps/ui/public/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fd02de332529993e913d35423961c6b3a41b8bf9 Binary files /dev/null and b/jules_branch/apps/ui/public/icon.ico differ diff --git a/jules_branch/apps/ui/public/logo.png b/jules_branch/apps/ui/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..643b70d9f6679deb9cae2c26607d609b411abb1b Binary files /dev/null and b/jules_branch/apps/ui/public/logo.png differ diff --git a/jules_branch/apps/ui/public/logo_larger.png b/jules_branch/apps/ui/public/logo_larger.png new file mode 100644 index 0000000000000000000000000000000000000000..efa9d3bee2c25e8534a55735e52f91714013b60d --- /dev/null +++ b/jules_branch/apps/ui/public/logo_larger.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf60e646a6b9b27f193d3527019a0d8b6a5f1a83c97573547702ad1b35257cf6 +size 324788 diff --git a/jules_branch/apps/ui/public/manifest.json b/jules_branch/apps/ui/public/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5231d721b6b0711d851d9d631b1fcc801e768f3b --- /dev/null +++ b/jules_branch/apps/ui/public/manifest.json @@ -0,0 +1,44 @@ +{ + "name": "Automaker - Autonomous AI Development Studio", + "short_name": "Automaker", + "description": "Build software autonomously with AI agents", + "start_url": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "orientation": "any", + "scope": "/", + "id": "/", + "icons": [ + { + "src": "/logo.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/logo_larger.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/automaker.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ], + "categories": ["developer", "productivity", "utilities"], + "lang": "en-US", + "dir": "ltr", + "launch_handler": { + "client_mode": "focus-existing" + }, + "handle_links": "preferred", + "edge_side_panel": { + "preferred_width": 480 + }, + "prefer_related_applications": false, + "display_override": ["standalone", "minimal-ui"] +} diff --git a/jules_branch/apps/ui/public/next.svg b/jules_branch/apps/ui/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/jules_branch/apps/ui/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/jules_branch/apps/ui/public/readme_logo.png b/jules_branch/apps/ui/public/readme_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b0d7f2bc82db97761a554996ffa6dfb720a70554 Binary files /dev/null and b/jules_branch/apps/ui/public/readme_logo.png differ diff --git a/jules_branch/apps/ui/public/readme_logo.svg b/jules_branch/apps/ui/public/readme_logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..86177aeab215399c0e75c9621ae9222fe8bc42eb --- /dev/null +++ b/jules_branch/apps/ui/public/readme_logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + automaker. + + diff --git a/jules_branch/apps/ui/public/sounds/ding.mp3 b/jules_branch/apps/ui/public/sounds/ding.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..660e58198d62723afbc712445d187fab6cb49958 Binary files /dev/null and b/jules_branch/apps/ui/public/sounds/ding.mp3 differ diff --git a/jules_branch/apps/ui/public/sw.js b/jules_branch/apps/ui/public/sw.js new file mode 100644 index 0000000000000000000000000000000000000000..1370cb7c3a33733f13e0087be95aff8f7dcbae18 --- /dev/null +++ b/jules_branch/apps/ui/public/sw.js @@ -0,0 +1,626 @@ +// Automaker Service Worker - Optimized for mobile PWA loading performance +// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster +// Vite plugin (see vite.config.mts). In development it stays as-is; in production +// builds it becomes e.g. 'automaker-v5-a1b2c3d4' for automatic cache invalidation. +const CACHE_NAME = 'automaker-v5'; // replaced at build time → 'automaker-v5-' + +// Separate cache for immutable hashed assets (long-lived) +const IMMUTABLE_CACHE = 'automaker-immutable-v2'; + +// Separate cache for API responses (short-lived, stale-while-revalidate on mobile) +const API_CACHE = 'automaker-api-v1'; + +// Assets to cache on install (app shell for instant loading) +const SHELL_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/logo.png', + '/logo_larger.png', + '/automaker.svg', + '/favicon.ico', +]; + +// Critical JS/CSS assets extracted from index.html at build time by the swCacheBuster +// Vite plugin. Populated during production builds; empty in dev mode. +// These are precached on SW install so that PWA cold starts after memory eviction +// serve instantly from cache instead of requiring a full network download. +const CRITICAL_ASSETS = []; + +// Whether mobile caching is enabled (set via message from main thread). +// Persisted to Cache Storage so it survives aggressive SW termination on mobile. +let mobileMode = false; +const MOBILE_MODE_CACHE_KEY = 'automaker-sw-config'; +const MOBILE_MODE_URL = '/sw-config/mobile-mode'; + +/** + * Persist mobileMode to Cache Storage so it survives SW restarts. + * Service workers on mobile get killed aggressively — without persistence, + * mobileMode resets to false and API caching silently stops working. + */ +async function persistMobileMode(enabled) { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = new Response(JSON.stringify({ mobileMode: enabled }), { + headers: { 'Content-Type': 'application/json' }, + }); + await cache.put(MOBILE_MODE_URL, response); + } catch (_e) { + // Best-effort persistence — SW still works without it + } +} + +/** + * Restore mobileMode from Cache Storage on SW startup. + */ +async function restoreMobileMode() { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = await cache.match(MOBILE_MODE_URL); + if (response) { + const data = await response.json(); + mobileMode = !!data.mobileMode; + } + } catch (_e) { + // Best-effort restore — defaults to false + } +} + +// Restore mobileMode immediately on SW startup +// Keep a promise so fetch handlers can await restoration on cold SW starts. +// This prevents a race where early API requests run before mobileMode is loaded +// from Cache Storage, incorrectly falling back to network-first. +const mobileModeRestorePromise = restoreMobileMode(); + +// API endpoints that are safe to serve from stale cache on mobile. +// These are GET-only, read-heavy endpoints where showing slightly stale data +// is far better than a blank screen or reload on flaky mobile connections. +const CACHEABLE_API_PATTERNS = [ + '/api/features', + '/api/settings', + '/api/models', + '/api/usage', + '/api/worktrees', + '/api/github', + '/api/cli', + '/api/sessions', + '/api/running-agents', + '/api/pipeline', + '/api/workspace', + '/api/spec', +]; + +// Max age for API cache entries (5 minutes). +// After this, even mobile will require a network fetch. +const API_CACHE_MAX_AGE = 5 * 60 * 1000; + +// Maximum entries in API cache to prevent unbounded growth +const API_CACHE_MAX_ENTRIES = 100; + +/** + * Check if an API request is safe to cache (read-only data endpoints) + */ +function isCacheableApiRequest(url) { + const path = url.pathname; + if (!path.startsWith('/api/')) return false; + return CACHEABLE_API_PATTERNS.some((pattern) => path.startsWith(pattern)); +} + +/** + * Check if a cached API response is still fresh enough to use + */ +function isApiCacheFresh(response) { + const cachedAt = response.headers.get('x-sw-cached-at'); + if (!cachedAt) return false; + return Date.now() - parseInt(cachedAt, 10) < API_CACHE_MAX_AGE; +} + +/** + * Clone a response and add a timestamp header for cache freshness tracking. + * Uses arrayBuffer() instead of blob() to avoid doubling memory for large responses. + */ +async function addCacheTimestamp(response) { + const headers = new Headers(response.headers); + headers.set('x-sw-cached-at', String(Date.now())); + const body = await response.clone().arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +self.addEventListener('install', (event) => { + // Cache the app shell AND critical JS/CSS assets so the PWA loads instantly. + // SHELL_ASSETS go into CACHE_NAME (general cache), CRITICAL_ASSETS go into + // IMMUTABLE_CACHE (long-lived, content-hashed assets). This ensures that even + // the very first visit populates the immutable cache — previously, assets were + // only cached on fetch interception, but the SW isn't active during the first + // page load so nothing got cached until the second visit. + // + // self.skipWaiting() is NOT called here — activation is deferred until the main + // thread sends a SKIP_WAITING message to avoid disrupting a live page. + event.waitUntil( + Promise.all([ + // Cache app shell (HTML, icons, manifest) using individual fetch+put instead of + // cache.addAll(). This is critical because cache.addAll() respects the server's + // Cache-Control response headers — if the server sends 'Cache-Control: no-store' + // (which Vite dev server does for index.html), addAll() silently skips caching + // and the pre-React loading spinner is never served from cache. + // + // cache.put() bypasses Cache-Control headers entirely, ensuring the app shell + // is always cached on install regardless of what the server sends. This is the + // correct approach for SW-managed caches where the SW (not HTTP headers) controls + // freshness via the activate event's cache cleanup and the navigation strategy's + // background revalidation. + caches.open(CACHE_NAME).then((cache) => + Promise.all( + SHELL_ASSETS.map((url) => + fetch(url) + .then((response) => { + if (response.ok) return cache.put(url, response); + }) + .catch(() => { + // Individual asset fetch failure is non-fatal — the SW still activates + // and the next navigation will populate the cache via Strategy 3. + }) + ) + ) + ), + // Cache critical JS/CSS bundles (injected at build time by swCacheBuster). + // Uses individual fetch+put instead of cache.addAll() so a single asset + // failure doesn't prevent the rest from being cached. + // + // IMPORTANT: We fetch with { mode: 'cors' } because Vite's output HTML uses + //