Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d6c8cb14 | |||
| 01295eff99 | |||
| a41e5a0eba | |||
| 5ed85e86ab | |||
| 071379a109 | |||
| 0ddd740bd1 | |||
| 2e2b82365f | |||
| acb131b721 | |||
| df528f0650 | |||
| ff91a1af0f | |||
| 6d0222fa9c | |||
| fe08c644c4 | |||
| a5e3cc9a2a | |||
| 4587162e43 | |||
| b4e6b2fe43 | |||
| d9cd300fee | |||
| f5a7e26d99 | |||
| 5f63daa3cc | |||
| e6e8da17c8 | |||
| 9317148b6b | |||
| 4730206214 | |||
| ae703be9f3 | |||
| 5293fb3996 | |||
| 8030c59f25 | |||
| ab91f7baf8 | |||
| a2c3507d61 | |||
| 26134c289c | |||
| 303b3781a4 | |||
| 1d2f47b02d | |||
| 985c79673b | |||
| 9b38eb3457 | |||
| e2f9abadbc | |||
| d16be30771 | |||
| bad1a9b2c4 | |||
| cd423c65c7 | |||
| 02ed91c91c | |||
| fe5cc582b8 | |||
| 8f02a51297 | |||
| 1e19971679 | |||
| 900dd43600 | |||
| f355e09d78 | |||
| bde2ca4b2f | |||
| 7bf12e8566 | |||
| 54d0cd2f4e | |||
| f8cc63af48 |
@@ -405,11 +405,11 @@ jobs:
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Setup Flatpak
|
||||
- name: Setup Flatpak and verification tools
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
@@ -447,6 +447,9 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -344,10 +344,10 @@ jobs:
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Setup Flatpak
|
||||
- name: Setup Flatpak and verification tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
@@ -383,6 +383,9 @@ jobs:
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
packages/
|
||||
|
||||
@@ -40,6 +40,17 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
## Memory (claude-mem)
|
||||
|
||||
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
|
||||
|
||||
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
|
||||
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
|
||||
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
|
||||
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
|
||||
|
||||
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -35,18 +35,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
# Token Encryption Investigation
|
||||
|
||||
## Issue Summary
|
||||
|
||||
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
|
||||
|
||||
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
|
||||
|
||||
## Token Storage Format
|
||||
|
||||
### Encrypted Token Format
|
||||
|
||||
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
|
||||
|
||||
```text
|
||||
enc:djEwtxMGISt3tQ...
|
||||
```
|
||||
|
||||
This format is used when tokens are stored in:
|
||||
- **macOS**: Keychain (service: "Claude Code-credentials")
|
||||
- **Linux**: Secret Service API (DBus, via secretstorage library)
|
||||
- **Windows**: Credential Manager / .credentials.json files
|
||||
|
||||
### Decrypted Token Format
|
||||
|
||||
Valid Claude OAuth tokens have the format:
|
||||
```text
|
||||
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
## Current Token Flow (BROKEN)
|
||||
|
||||
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
|
||||
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
|
||||
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR system keychain via `get_token_from_keychain()`
|
||||
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
|
||||
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
|
||||
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
|
||||
|
||||
### Proof of Broken Flow
|
||||
|
||||
Test in `apps/backend`:
|
||||
```python
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
|
||||
|
||||
from core.auth import get_auth_token
|
||||
token = get_auth_token()
|
||||
print(f"Token: {token}") # Output: "enc:test123"
|
||||
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
|
||||
```
|
||||
|
||||
## How Standalone Claude Code CLI Handles Tokens
|
||||
|
||||
### Current Understanding
|
||||
|
||||
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
|
||||
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
|
||||
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
|
||||
|
||||
### Missing Documentation
|
||||
|
||||
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
|
||||
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
|
||||
- Security issues (tokens exposed in debug logs before v2.1.0)
|
||||
- Keychain access patterns for macOS/Linux/Windows
|
||||
|
||||
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
|
||||
|
||||
Sources:
|
||||
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
|
||||
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
|
||||
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
|
||||
|
||||
## Decryption Approach Options
|
||||
|
||||
### Option 1: Claude Agent SDK Built-in Decryption
|
||||
|
||||
**Status**: NEEDS VERIFICATION
|
||||
|
||||
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
|
||||
- Token is passed to SDK still encrypted
|
||||
- SDK detects `enc:` prefix
|
||||
- SDK has access to system keyring for decryption
|
||||
|
||||
**Action Required**: Check if SDK has decryption capabilities by examining:
|
||||
- SDK source code or documentation
|
||||
- Whether SDK expects encrypted vs decrypted tokens
|
||||
- If SDK requires specific environment variables for decryption
|
||||
|
||||
### Option 2: Python Backend Decryption (Recommended)
|
||||
|
||||
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
|
||||
|
||||
**Implementation Pattern**:
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
"""Get authentication token (decrypted if necessary)."""
|
||||
token = _retrieve_token_from_sources() # From env or keychain
|
||||
|
||||
if token and token.startswith("enc:"):
|
||||
# Decrypt the token
|
||||
token = decrypt_token(token)
|
||||
|
||||
return token
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""
|
||||
Decrypt Claude Code encrypted token.
|
||||
|
||||
Args:
|
||||
encrypted_token: Token with 'enc:' prefix
|
||||
|
||||
Returns:
|
||||
Decrypted token in format 'sk-ant-oat01-...'
|
||||
"""
|
||||
# Remove 'enc:' prefix
|
||||
encrypted_data = encrypted_token[4:]
|
||||
|
||||
# TODO: Implement decryption logic
|
||||
# Questions to answer:
|
||||
# 1. What encryption algorithm does Claude Code use?
|
||||
# 2. Where is the decryption key stored?
|
||||
# 3. Is the decryption key platform-specific (per-user)?
|
||||
# 4. Can we reuse Claude Code's decryption mechanism?
|
||||
|
||||
raise NotImplementedError("Token decryption not yet implemented")
|
||||
```
|
||||
|
||||
### Option 3: Call Claude Code CLI for Decryption
|
||||
|
||||
**Approach**: Use the Claude Code CLI binary to decrypt tokens
|
||||
|
||||
```python
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""Decrypt token by invoking Claude Code CLI."""
|
||||
# Find claude binary
|
||||
claude_path = shutil.which("claude") or "~/.local/bin/claude"
|
||||
|
||||
# Use CLI command to get decrypted token
|
||||
# (if such a command exists - needs research)
|
||||
result = subprocess.run(
|
||||
[claude_path, "auth", "decrypt", encrypted_token],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
**Issues**:
|
||||
- Requires Claude Code CLI to be installed
|
||||
- No documented CLI command for token decryption
|
||||
- Adds external dependency
|
||||
|
||||
## Required Investigation Steps
|
||||
|
||||
### 1. Verify SDK Decryption Capabilities
|
||||
|
||||
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
|
||||
|
||||
**Method**:
|
||||
```bash
|
||||
# In environment with SDK installed
|
||||
python3 << 'EOF'
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
|
||||
|
||||
from claude_agent_sdk import Client
|
||||
# Try creating client - does it decrypt internally?
|
||||
client = Client()
|
||||
# Check if authentication works
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Reverse Engineer Claude Code CLI Decryption
|
||||
|
||||
**Task**: Understand how Claude CLI decrypts tokens
|
||||
|
||||
**Method**:
|
||||
- Examine Claude CLI binary (if possible)
|
||||
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
|
||||
- Check if CLI accesses specific keychain entries for decryption keys
|
||||
- Look for encryption/decryption libraries used by CLI
|
||||
|
||||
### 3. Find Decryption Key Storage
|
||||
|
||||
**Task**: Locate where decryption keys are stored
|
||||
|
||||
**Hypothesis**: Decryption key stored in:
|
||||
- macOS: Keychain (separate entry from encrypted token)
|
||||
- Linux: Secret Service API
|
||||
- Windows: Credential Manager
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
# macOS: List all keychain entries
|
||||
security find-generic-password -a "$(whoami)" | grep -i claude
|
||||
|
||||
# Linux: Use secretstorage to list all items
|
||||
python3 -c "import secretstorage; ..."
|
||||
```
|
||||
|
||||
## Recommended Decryption Approach for Python Backend
|
||||
|
||||
Based on investigation so far, the recommended approach is:
|
||||
|
||||
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
|
||||
2. **Decrypt before use**: Implement `decrypt_token()` function
|
||||
3. **Platform-specific decryption**: Use appropriate keyring library:
|
||||
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
|
||||
- Linux: Use `secretstorage` library to access Secret Service API
|
||||
- Windows: Access Credential Manager or credentials.json
|
||||
4. **Backward compatibility**: Support both encrypted and plaintext tokens
|
||||
5. **Error handling**: Provide clear error messages if decryption fails
|
||||
|
||||
## Complete Token Flow Trace (Frontend → Backend)
|
||||
|
||||
### 1. Token Retrieval (Frontend)
|
||||
|
||||
**File**: `apps/frontend/src/main/services/profile-service.ts`
|
||||
|
||||
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
|
||||
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR the Python backend retrieving it from the keychain
|
||||
|
||||
**Key Code**:
|
||||
```typescript
|
||||
// Line 223: Returns empty object in OAuth mode, allowing
|
||||
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
|
||||
```
|
||||
|
||||
### 2. Environment Variable Passing (Frontend → PTY)
|
||||
|
||||
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
|
||||
|
||||
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
|
||||
|
||||
**Key Code** (Lines 149-152):
|
||||
```typescript
|
||||
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
|
||||
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
|
||||
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
|
||||
```
|
||||
|
||||
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
|
||||
|
||||
### 3. Token Retrieval (Backend)
|
||||
|
||||
**File**: `apps/backend/core/auth.py`
|
||||
|
||||
#### 3.1. get_auth_token()
|
||||
|
||||
This function retrieves the token from multiple sources:
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token # ❌ Returns immediately without checking for enc: prefix
|
||||
|
||||
# Fallback to system credential store
|
||||
return get_token_from_keychain() # ❌ Also returns without decryption
|
||||
```
|
||||
|
||||
**Issue**: Returns token as-is with `enc:` prefix intact.
|
||||
|
||||
#### 3.2. require_auth_token()
|
||||
|
||||
This function calls `get_auth_token()` and raises an error if no token is found:
|
||||
|
||||
```python
|
||||
def require_auth_token() -> str:
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if not token:
|
||||
raise ValueError("No OAuth token found...")
|
||||
return token # ❌ Returns encrypted token
|
||||
```
|
||||
|
||||
**Issue**: No decryption step between retrieval and return.
|
||||
|
||||
#### 3.3. ensure_claude_code_oauth_token()
|
||||
|
||||
This function ensures the environment variable is set:
|
||||
|
||||
```python
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
return
|
||||
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Propagates encrypted token to environment variable.
|
||||
|
||||
### 4. Token Usage in SDK Client Creation (Backend)
|
||||
|
||||
#### 4.1. Full Client Creation
|
||||
|
||||
**File**: `apps/backend/core/client.py` (see `create_client()` function)
|
||||
|
||||
```python
|
||||
def create_client(...):
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
|
||||
|
||||
#### 4.2. Simple Client Creation
|
||||
|
||||
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
|
||||
|
||||
```python
|
||||
def create_simple_client(...):
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
import os
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Same problem - encrypted token passed to SDK.
|
||||
|
||||
#### 4.3. Other Usages
|
||||
|
||||
**Files**:
|
||||
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
|
||||
- `apps/backend/runners/insights_runner.py` - Insights analysis
|
||||
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
|
||||
- `apps/backend/integrations/linear/updater.py` - Linear integration
|
||||
- `apps/backend/commit_message.py` - Commit message generation
|
||||
- `apps/backend/analysis/insight_extractor.py` - Code insights
|
||||
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
|
||||
|
||||
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
|
||||
|
||||
### 5. Where Decryption Should Be Inserted
|
||||
|
||||
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
|
||||
|
||||
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
# ✅ INSERT DECRYPTION HERE
|
||||
if token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
|
||||
# Fallback to system credential store
|
||||
token = get_token_from_keychain()
|
||||
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
|
||||
if token and token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
```
|
||||
|
||||
**Benefits of this approach**:
|
||||
1. Single location for decryption logic
|
||||
2. All downstream functions automatically get decrypted tokens
|
||||
3. Backward compatible (plaintext tokens pass through unchanged)
|
||||
4. Consistent behavior across all token sources (env vars and keychain)
|
||||
|
||||
**Alternative insertion points** (NOT recommended):
|
||||
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
|
||||
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
|
||||
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
|
||||
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
|
||||
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
|
||||
4. ⏳ Verify if Claude Agent SDK handles decryption internally
|
||||
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
|
||||
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
|
||||
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
|
||||
8. ⏳ Test with real encrypted tokens on macOS and Linux
|
||||
9. ⏳ Add comprehensive error handling for decryption failures
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
|
||||
- Possible: AES-256, ChaCha20, or similar
|
||||
- Key derivation method?
|
||||
|
||||
2. **Where is the decryption key stored?**
|
||||
- Same keychain entry as encrypted token?
|
||||
- Separate keychain entry?
|
||||
- Derived from system/user credentials?
|
||||
|
||||
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
|
||||
- If it expects decrypted: we must decrypt before passing
|
||||
- If it handles encryption: we may be missing SDK configuration
|
||||
|
||||
4. **Is there a Claude Code CLI command to decrypt tokens?**
|
||||
- `claude auth decrypt <token>`?
|
||||
- `claude auth get-token`?
|
||||
- No documented command found in research
|
||||
|
||||
5. **Can we reuse Claude Code's decryption mechanism?**
|
||||
- Import decryption functions from CLI?
|
||||
- Call CLI as subprocess?
|
||||
- Implement decryption ourselves?
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
|
||||
- Current auth implementation: `apps/backend/core/auth.py`
|
||||
- SDK client initialization: `apps/backend/core/client.py`
|
||||
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.5"
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,3 +21,76 @@ INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
|
||||
+1100
-747
File diff suppressed because it is too large
Load Diff
+183
-183
@@ -1,183 +1,183 @@
|
||||
"""
|
||||
Planner Agent Module
|
||||
====================
|
||||
|
||||
Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_followup_planner(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Run the follow-up planner to add new subtasks to a completed spec.
|
||||
|
||||
This is a simplified version of run_autonomous_agent that:
|
||||
1. Creates a client
|
||||
2. Loads the followup planner prompt
|
||||
3. Runs a single planning session
|
||||
4. Returns after the plan is updated (doesn't enter coding loop)
|
||||
|
||||
The planner agent will:
|
||||
- Read FOLLOWUP_REQUEST.md for the new task
|
||||
- Read the existing implementation_plan.json
|
||||
- Add new phase(s) with pending subtasks
|
||||
- Update the plan status back to in_progress
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the completed spec
|
||||
model: Claude model to use
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
bool: True if planning completed successfully
|
||||
"""
|
||||
from implementation_plan import ImplementationPlan
|
||||
from prompts import get_followup_planner_prompt
|
||||
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
|
||||
# Show header
|
||||
content = [
|
||||
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
|
||||
"",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
muted("Adding follow-up work to completed spec."),
|
||||
"",
|
||||
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
|
||||
# Start planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
prompt = get_followup_planner_prompt(spec_dir)
|
||||
|
||||
print_status("Running follow-up planner...", "progress")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Run single planning session
|
||||
async with client:
|
||||
status, response, error_info = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
|
||||
)
|
||||
|
||||
# End planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
success=(status != "error"),
|
||||
message="Follow-up planning session completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
print()
|
||||
print_status("Follow-up planning failed", "error")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
# Verify the plan was updated (should have pending subtasks now)
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
plan = ImplementationPlan.load(plan_file)
|
||||
|
||||
# Check if there are any pending subtasks
|
||||
all_subtasks = [c for p in plan.phases for c in p.subtasks]
|
||||
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
|
||||
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
await plan.async_save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
|
||||
f"Total subtasks: {len(all_subtasks)}",
|
||||
"",
|
||||
muted("Next steps:"),
|
||||
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return True
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Warning: No pending subtasks found after planning", "warning"
|
||||
)
|
||||
print(muted("The planner may not have added new subtasks."))
|
||||
print(muted("Check implementation_plan.json manually."))
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return False
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Error: implementation_plan.json not found after planning", "error"
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
print_status(f"Follow-up planning error: {e}", "error")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
"""
|
||||
Planner Agent Module
|
||||
====================
|
||||
|
||||
Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_followup_planner(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Run the follow-up planner to add new subtasks to a completed spec.
|
||||
|
||||
This is a simplified version of run_autonomous_agent that:
|
||||
1. Creates a client
|
||||
2. Loads the followup planner prompt
|
||||
3. Runs a single planning session
|
||||
4. Returns after the plan is updated (doesn't enter coding loop)
|
||||
|
||||
The planner agent will:
|
||||
- Read FOLLOWUP_REQUEST.md for the new task
|
||||
- Read the existing implementation_plan.json
|
||||
- Add new phase(s) with pending subtasks
|
||||
- Update the plan status back to in_progress
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the completed spec
|
||||
model: Claude model to use
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
bool: True if planning completed successfully
|
||||
"""
|
||||
from implementation_plan import ImplementationPlan
|
||||
from prompts import get_followup_planner_prompt
|
||||
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
|
||||
# Show header
|
||||
content = [
|
||||
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
|
||||
"",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
muted("Adding follow-up work to completed spec."),
|
||||
"",
|
||||
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
|
||||
# Start planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
prompt = get_followup_planner_prompt(spec_dir)
|
||||
|
||||
print_status("Running follow-up planner...", "progress")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Run single planning session
|
||||
async with client:
|
||||
status, response, error_info = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
|
||||
)
|
||||
|
||||
# End planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
success=(status != "error"),
|
||||
message="Follow-up planning session completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
print()
|
||||
print_status("Follow-up planning failed", "error")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
# Verify the plan was updated (should have pending subtasks now)
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
plan = ImplementationPlan.load(plan_file)
|
||||
|
||||
# Check if there are any pending subtasks
|
||||
all_subtasks = [c for p in plan.phases for c in p.subtasks]
|
||||
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
|
||||
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
await plan.async_save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
|
||||
f"Total subtasks: {len(all_subtasks)}",
|
||||
"",
|
||||
muted("Next steps:"),
|
||||
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return True
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Warning: No pending subtasks found after planning", "warning"
|
||||
)
|
||||
print(muted("The planner may not have added new subtasks."))
|
||||
print(muted("Check implementation_plan.json manually."))
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return False
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Error: implementation_plan.json not found after planning", "error"
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
print_status(f"Follow-up planning error: {e}", "error")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
PR Template Filler Agent Module
|
||||
================================
|
||||
|
||||
Detects GitHub PR templates in a project and uses Claude to intelligently
|
||||
fill them based on code changes, spec context, commit history, and branch info.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from task_logger import LogPhase, get_task_logger
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum diff size (in characters) before truncating to file-level summaries
|
||||
MAX_DIFF_CHARS = 30_000
|
||||
|
||||
|
||||
def detect_pr_template(project_dir: Path | str) -> str | None:
|
||||
"""
|
||||
Detect a GitHub PR template in the project.
|
||||
|
||||
Searches for:
|
||||
1. .github/PULL_REQUEST_TEMPLATE.md (single template)
|
||||
2. .github/PULL_REQUEST_TEMPLATE/ directory (picks the first .md file)
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
The template content as a string, or None if no template is found.
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
# Check for single template file
|
||||
single_template = project_dir / ".github" / "PULL_REQUEST_TEMPLATE.md"
|
||||
if single_template.is_file():
|
||||
try:
|
||||
content = single_template.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {single_template}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template {single_template}: {e}")
|
||||
|
||||
# Check for template directory (pick first .md file alphabetically)
|
||||
template_dir = project_dir / ".github" / "PULL_REQUEST_TEMPLATE"
|
||||
if template_dir.is_dir():
|
||||
try:
|
||||
md_files = sorted(template_dir.glob("*.md"))
|
||||
if md_files:
|
||||
content = md_files[0].read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {md_files[0]}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template from {template_dir}: {e}")
|
||||
|
||||
logger.info("No GitHub PR template found in project")
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_diff(diff_summary: str) -> str:
|
||||
"""
|
||||
Truncate a large diff to file-level summaries to stay within token limits.
|
||||
|
||||
If the diff is within MAX_DIFF_CHARS, return it unchanged.
|
||||
Otherwise, extract only file-level change summaries (e.g. file names
|
||||
with insertions/deletions counts) and discard line-level detail.
|
||||
|
||||
Args:
|
||||
diff_summary: The full diff summary text
|
||||
|
||||
Returns:
|
||||
The original or truncated diff summary.
|
||||
"""
|
||||
if len(diff_summary) <= MAX_DIFF_CHARS:
|
||||
return diff_summary
|
||||
|
||||
lines = diff_summary.splitlines()
|
||||
summary_lines: list[str] = []
|
||||
summary_lines.append("(Diff truncated to file-level summaries due to size)")
|
||||
summary_lines.append("")
|
||||
|
||||
for line in lines:
|
||||
# Keep file-level summary lines (stat lines, file headers, etc.)
|
||||
stripped = line.strip()
|
||||
if (
|
||||
stripped.startswith("diff --git")
|
||||
or stripped.startswith("---")
|
||||
or stripped.startswith("+++")
|
||||
or "file changed" in stripped.lower()
|
||||
or "files changed" in stripped.lower()
|
||||
or "insertion" in stripped.lower()
|
||||
or "deletion" in stripped.lower()
|
||||
or stripped.startswith("rename")
|
||||
or stripped.startswith("new file")
|
||||
or stripped.startswith("deleted file")
|
||||
or stripped.startswith("Binary files")
|
||||
):
|
||||
summary_lines.append(line)
|
||||
|
||||
# If we couldn't extract meaningful summaries, take the first chunk
|
||||
if len(summary_lines) <= 2:
|
||||
truncated = diff_summary[:MAX_DIFF_CHARS]
|
||||
return truncated + "\n\n(... diff truncated due to size)"
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
|
||||
def _strip_markdown_fences(content: str) -> str:
|
||||
"""
|
||||
Strip markdown code fences from the response if present.
|
||||
|
||||
The AI sometimes wraps the output in ```markdown ... ``` even when instructed
|
||||
not to. This ensures the PR body renders correctly on GitHub.
|
||||
|
||||
Args:
|
||||
content: The response content to clean
|
||||
|
||||
Returns:
|
||||
The content with markdown fences stripped.
|
||||
"""
|
||||
result = content
|
||||
|
||||
# Strip opening fence (```markdown or just ```)
|
||||
if result.startswith("```markdown"):
|
||||
result = result[len("```markdown") :].lstrip("\n")
|
||||
elif result.startswith("```md"):
|
||||
result = result[len("```md") :].lstrip("\n")
|
||||
elif result.startswith("```"):
|
||||
result = result[3:].lstrip("\n")
|
||||
|
||||
# Strip closing fence
|
||||
if result.endswith("```"):
|
||||
result = result[:-3].rstrip("\n")
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
template_content: str,
|
||||
diff_summary: str,
|
||||
spec_overview: str,
|
||||
commit_log: str,
|
||||
branch_name: str,
|
||||
target_branch: str,
|
||||
) -> str:
|
||||
"""
|
||||
Build the prompt for the PR template filler agent.
|
||||
|
||||
Combines the system prompt context variables into a single message
|
||||
that includes the template and all change context.
|
||||
|
||||
Args:
|
||||
template_content: The PR template markdown
|
||||
diff_summary: Git diff summary (possibly truncated)
|
||||
spec_overview: Spec.md content or summary
|
||||
commit_log: Git log of commits in the PR
|
||||
branch_name: Source branch name
|
||||
target_branch: Target branch name
|
||||
|
||||
Returns:
|
||||
The assembled prompt string.
|
||||
"""
|
||||
return f"""Fill out the following GitHub PR template using the provided context.
|
||||
Return ONLY the filled template markdown — no preamble, no explanation, no code fences.
|
||||
|
||||
## Checkbox Guidelines
|
||||
|
||||
IMPORTANT: Be accurate and honest about what has and hasn't been verified.
|
||||
|
||||
**Check these based on context (you can infer from the diff/spec):**
|
||||
- Base Branch targeting — check based on target_branch value
|
||||
- Type of Change (bug fix, feature, docs, refactor, test) — infer from diff and spec
|
||||
- Area (Frontend, Backend, Fullstack) — infer from changed file paths
|
||||
- Feature Toggle "N/A" — if the feature appears complete and not behind a flag
|
||||
- Breaking Changes "No" — if changes appear backward compatible
|
||||
|
||||
**Leave UNCHECKED (these require human verification you cannot perform):**
|
||||
- "I've tested my changes locally" — you have not tested anything
|
||||
- "All CI checks pass" — CI has not run yet
|
||||
- "Windows/macOS/Linux tested" — requires manual testing on each platform
|
||||
- "All existing tests pass" — CI has not run yet
|
||||
- "New features include test coverage" — unless test files are clearly visible in the diff
|
||||
- "Bug fixes include regression tests" — unless test files are clearly visible in the diff
|
||||
|
||||
**For platform/code quality checkboxes:**
|
||||
- "Used centralized platform/ module" — leave unchecked unless you can verify from the diff
|
||||
- "No hardcoded paths" — leave unchecked unless you can verify from the diff
|
||||
- "PR is small and focused (< 400 lines)" — check only if diff stats show < 400 lines changed
|
||||
|
||||
**For the "I've synced with develop branch" checkbox:**
|
||||
- Leave unchecked — you cannot verify the sync status
|
||||
|
||||
## PR Template
|
||||
|
||||
{template_content}
|
||||
|
||||
## Change Context
|
||||
|
||||
### Branch Information
|
||||
- **Source branch:** {branch_name}
|
||||
- **Target branch:** {target_branch}
|
||||
|
||||
### Git Diff Summary
|
||||
```
|
||||
{diff_summary}
|
||||
```
|
||||
|
||||
### Spec Overview
|
||||
{spec_overview}
|
||||
|
||||
### Commit History
|
||||
```
|
||||
{commit_log}
|
||||
```
|
||||
|
||||
Fill every section of the PR template. Follow the checkbox guidelines above carefully.
|
||||
Output ONLY the completed template — no code fences, no preamble."""
|
||||
|
||||
|
||||
def _load_spec_overview(spec_dir: Path) -> str:
|
||||
"""
|
||||
Load the spec.md content for context. Falls back to a brief note if unavailable.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
The spec content or a fallback message.
|
||||
"""
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.is_file():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Truncate very long specs to keep prompt manageable
|
||||
if len(content) > 8000:
|
||||
return content[:8000] + "\n\n(... spec truncated for brevity)"
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read spec.md: {e}")
|
||||
return "(No spec overview available)"
|
||||
|
||||
|
||||
async def run_pr_template_filler(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None = None,
|
||||
branch_name: str = "",
|
||||
target_branch: str = "develop",
|
||||
diff_summary: str = "",
|
||||
commit_log: str = "",
|
||||
verbose: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Run the PR template filler agent to generate a filled PR body.
|
||||
|
||||
Detects the project's PR template, gathers change context, and invokes
|
||||
Claude to intelligently fill out the template sections.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
spec_dir: Directory containing the spec files
|
||||
model: Claude model to use
|
||||
thinking_budget: Max thinking tokens (None to disable extended thinking)
|
||||
branch_name: Source branch name for the PR
|
||||
target_branch: Target branch name for the PR
|
||||
diff_summary: Git diff summary of changes
|
||||
commit_log: Git log of commits included in the PR
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
The filled template markdown string, or None if template detection fails
|
||||
or the agent encounters an error.
|
||||
"""
|
||||
# Detect PR template
|
||||
template_content = detect_pr_template(project_dir)
|
||||
if template_content is None:
|
||||
logger.info("No PR template detected — skipping template filler")
|
||||
return None
|
||||
|
||||
# Load spec overview
|
||||
spec_overview = _load_spec_overview(spec_dir)
|
||||
|
||||
# Truncate diff if too large
|
||||
truncated_diff = _truncate_diff(diff_summary)
|
||||
|
||||
# Build the prompt
|
||||
prompt = _build_prompt(
|
||||
template_content=template_content,
|
||||
diff_summary=truncated_diff,
|
||||
spec_overview=spec_overview,
|
||||
commit_log=commit_log,
|
||||
branch_name=branch_name,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Initialize task logger
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "PR template filling")
|
||||
|
||||
# Create client following the pattern from planner.py
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
model,
|
||||
agent_type="pr_template_filler",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
status, response, _ = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.CODING
|
||||
)
|
||||
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
success=(status != "error"),
|
||||
message="PR template filling completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
logger.error("PR template filler agent returned an error")
|
||||
return None
|
||||
|
||||
# The agent should return only the filled template markdown
|
||||
if response and response.strip():
|
||||
result = _strip_markdown_fences(response.strip())
|
||||
logger.info("PR template filled successfully")
|
||||
return result
|
||||
|
||||
logger.warning("PR template filler returned empty response")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PR template filler error: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"PR template filler error: {e}", LogPhase.CODING)
|
||||
return None
|
||||
+710
-598
File diff suppressed because it is too large
Load Diff
@@ -263,6 +263,12 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_template_filler": {
|
||||
"tools": BASE_READ_TOOLS, # Read-only — reads diff, template, spec
|
||||
"mcp_servers": [], # No MCP needed, context passed via prompt
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low", # Fast utility task for structured fill-in
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
|
||||
@@ -56,14 +56,10 @@ def _apply_qa_update(
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
# The frontend XState task state machine owns status transitions.
|
||||
# Writing status here races with XState's persistPlanStatusAndReasonSync()
|
||||
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ def handle_build_command(
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import get_base_branch_from_metadata
|
||||
from prompts_pkg.prompts import (
|
||||
get_base_branch_from_metadata,
|
||||
get_use_local_branch_from_metadata,
|
||||
)
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
@@ -203,6 +206,9 @@ def handle_build_command(
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
# Check if user requested local branch (preserves gitignored files like .env)
|
||||
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
@@ -213,6 +219,7 @@ def handle_build_command(
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
use_local_branch=use_local_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
|
||||
@@ -536,6 +536,175 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _detect_conflict_scenario(
|
||||
project_dir: Path,
|
||||
conflicting_files: list[str],
|
||||
spec_branch: str,
|
||||
base_branch: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze conflicting files to determine the conflict scenario.
|
||||
|
||||
This helps distinguish between:
|
||||
- 'already_merged': Task changes already identical in target branch
|
||||
- 'superseded': Target has newer version of same feature
|
||||
- 'diverged': Standard diverged branches (AI can resolve)
|
||||
- 'normal_conflict': Actual conflicting changes
|
||||
|
||||
Returns dict with:
|
||||
- scenario: 'already_merged' | 'superseded' | 'diverged' | 'normal_conflict'
|
||||
- already_merged_files: files identical in task and target
|
||||
- details: additional context
|
||||
"""
|
||||
if not conflicting_files:
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "No conflicting files to analyze",
|
||||
}
|
||||
|
||||
already_merged_files = []
|
||||
superseded_files = []
|
||||
diverged_files = []
|
||||
|
||||
try:
|
||||
# Get the merge-base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE, "Could not find merge base for conflict scenario detection"
|
||||
)
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "Could not determine merge base",
|
||||
}
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
for file_path in conflicting_files:
|
||||
try:
|
||||
# Get content from spec branch (task's changes)
|
||||
spec_content_result = subprocess.run(
|
||||
["git", "show", f"{spec_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from base branch (target)
|
||||
base_content_result = subprocess.run(
|
||||
["git", "show", f"{base_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from merge-base (original state)
|
||||
merge_base_content_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Check file existence in each ref
|
||||
spec_exists = spec_content_result.returncode == 0
|
||||
base_exists = base_content_result.returncode == 0
|
||||
merge_base_exists = merge_base_content_result.returncode == 0
|
||||
|
||||
if spec_exists and base_exists:
|
||||
spec_content = spec_content_result.stdout
|
||||
base_content = base_content_result.stdout
|
||||
|
||||
# If contents are identical, the changes are already merged
|
||||
if spec_content == base_content:
|
||||
already_merged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: already merged (identical content)",
|
||||
)
|
||||
elif merge_base_exists:
|
||||
merge_base_content = merge_base_content_result.stdout
|
||||
# If base has changed from merge_base but spec matches merge_base,
|
||||
# the task's changes are superseded by newer changes
|
||||
if spec_content == merge_base_content:
|
||||
superseded_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: superseded (base has newer changes)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: diverged (both branches modified)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE, f"Error analyzing file {file_path} for scenario: {e}"
|
||||
)
|
||||
diverged_files.append(file_path)
|
||||
|
||||
# Determine overall scenario based on dominant pattern
|
||||
total_files = len(conflicting_files)
|
||||
|
||||
if len(already_merged_files) == total_files:
|
||||
scenario = "already_merged"
|
||||
details = "All conflicting files have identical content in both branches"
|
||||
elif len(already_merged_files) > total_files / 2:
|
||||
scenario = "already_merged"
|
||||
details = f"{len(already_merged_files)} of {total_files} files already have the same content"
|
||||
elif len(superseded_files) == total_files:
|
||||
scenario = "superseded"
|
||||
details = "All task changes have been superseded by newer changes in the target branch"
|
||||
elif len(superseded_files) > total_files / 2:
|
||||
scenario = "superseded"
|
||||
details = (
|
||||
f"{len(superseded_files)} of {total_files} files have been superseded"
|
||||
)
|
||||
elif diverged_files:
|
||||
scenario = "diverged"
|
||||
details = f"{len(diverged_files)} files have diverged and need AI merge"
|
||||
else:
|
||||
scenario = "normal_conflict"
|
||||
details = "Standard merge conflicts detected"
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario: {scenario}",
|
||||
already_merged=len(already_merged_files),
|
||||
superseded=len(superseded_files),
|
||||
diverged=len(diverged_files),
|
||||
)
|
||||
|
||||
return {
|
||||
"scenario": scenario,
|
||||
"already_merged_files": already_merged_files,
|
||||
"superseded_files": superseded_files,
|
||||
"diverged_files": diverged_files,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error detecting conflict scenario: {e}")
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"superseded_files": [],
|
||||
"diverged_files": [],
|
||||
"details": f"Error during analysis: {e}",
|
||||
}
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
@@ -879,6 +1048,24 @@ def handle_merge_preview_command(
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Detect conflict scenario (already_merged, superseded, diverged, normal_conflict)
|
||||
# This helps the UI show appropriate messaging and actions
|
||||
conflict_scenario = None
|
||||
if non_lock_conflicting_files:
|
||||
conflict_scenario = _detect_conflict_scenario(
|
||||
project_dir,
|
||||
non_lock_conflicting_files,
|
||||
git_conflicts["spec_branch"],
|
||||
git_conflicts["base_branch"],
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario detected: {conflict_scenario.get('scenario')}",
|
||||
already_merged_files=len(
|
||||
conflict_scenario.get("already_merged_files", [])
|
||||
),
|
||||
)
|
||||
|
||||
# Use git diff file count as the authoritative totalFiles count
|
||||
# The semantic tracker may not track all files (e.g., test files, config files)
|
||||
# but we want to show the user all files that will be merged
|
||||
@@ -952,6 +1139,16 @@ def handle_merge_preview_command(
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
# Conflict scenario detection for better UX messaging
|
||||
"scenario": conflict_scenario.get("scenario")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
"alreadyMergedFiles": conflict_scenario.get("already_merged_files", [])
|
||||
if conflict_scenario
|
||||
else [],
|
||||
"scenarioMessage": conflict_scenario.get("details")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
|
||||
@@ -948,6 +948,57 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def configure_sdk_authentication(config_dir: str | None = None) -> None:
|
||||
"""
|
||||
Configure SDK authentication based on environment variables.
|
||||
|
||||
Supports two authentication modes:
|
||||
- API Profile mode (ANTHROPIC_BASE_URL set): uses ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode (default): uses CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
In API profile mode, explicitly removes CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
environment because the SDK gives OAuth priority over API keys when both
|
||||
are present.
|
||||
|
||||
Args:
|
||||
config_dir: Optional profile config directory for per-profile Keychain
|
||||
lookup. When set, enables multi-profile token storage.
|
||||
|
||||
Raises:
|
||||
ValueError: If required tokens are missing for the active mode.
|
||||
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
|
||||
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
|
||||
"""
|
||||
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
|
||||
|
||||
if api_profile_mode:
|
||||
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
|
||||
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
|
||||
raise ValueError(
|
||||
"API profile mode active (ANTHROPIC_BASE_URL is set) "
|
||||
"but ANTHROPIC_AUTH_TOKEN is not set"
|
||||
)
|
||||
# Explicitly remove CLAUDE_CODE_OAUTH_TOKEN so SDK uses ANTHROPIC_AUTH_TOKEN
|
||||
# SDK gives OAuth priority over API keys when both are present
|
||||
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
|
||||
logger.info("Using API profile authentication")
|
||||
else:
|
||||
# OAuth mode: require and validate OAuth token
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
logger.info("Using OAuth authentication")
|
||||
|
||||
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
"""
|
||||
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
|
||||
|
||||
@@ -139,9 +139,8 @@ from agents.tools_pkg import (
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from linear_updater import is_linear_enabled
|
||||
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
|
||||
@@ -497,18 +496,8 @@ def create_client(
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Provider Detection
|
||||
======================
|
||||
|
||||
Utility to detect git hosting provider (GitHub, GitLab, or unknown) from git remote URLs.
|
||||
Supports both SSH and HTTPS remote formats, and self-hosted GitLab instances.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .git_executable import run_git
|
||||
|
||||
|
||||
def detect_git_provider(project_dir: str | Path, remote_name: str | None = None) -> str:
|
||||
"""Detect the git hosting provider from the git remote URL.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the git repository
|
||||
remote_name: Name of the remote to check (defaults to "origin")
|
||||
|
||||
Returns:
|
||||
'github' if GitHub remote detected
|
||||
'gitlab' if GitLab remote detected (cloud or self-hosted)
|
||||
'unknown' if no remote or unsupported provider
|
||||
|
||||
Examples:
|
||||
>>> detect_git_provider('/path/to/repo')
|
||||
'github' # for git@github.com:user/repo.git
|
||||
'gitlab' # for git@gitlab.com:user/repo.git
|
||||
'gitlab' # for https://gitlab.company.com/user/repo.git
|
||||
'unknown' # for no remote or other providers
|
||||
"""
|
||||
try:
|
||||
# Get the remote URL (use specified remote or default to origin)
|
||||
remote = remote_name if remote_name else "origin"
|
||||
result = run_git(
|
||||
["remote", "get-url", remote],
|
||||
cwd=project_dir,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# If command failed or no output, return unknown
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return "unknown"
|
||||
|
||||
remote_url = result.stdout.strip()
|
||||
|
||||
# Parse ssh:// URL format: ssh://[user@]host[:port]/path
|
||||
ssh_url_match = re.match(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/", remote_url)
|
||||
if ssh_url_match:
|
||||
hostname = ssh_url_match.group(1)
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Parse HTTPS/HTTP format: https://host/path or http://host/path
|
||||
# Must check before scp-like format to avoid matching "https" as hostname
|
||||
https_match = re.match(r"^https?://([^/]+)/", remote_url)
|
||||
if https_match:
|
||||
hostname = https_match.group(1)
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Parse scp-like format: [user@]host:path (any username, not just 'git')
|
||||
# This handles git@github.com:user/repo.git and similar formats
|
||||
scp_match = re.match(r"^(?:[^@]+@)?([^:]+):", remote_url)
|
||||
if scp_match:
|
||||
hostname = scp_match.group(1)
|
||||
# Exclude paths that look like Windows drives (e.g., C:)
|
||||
if len(hostname) > 1:
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Unrecognized URL format
|
||||
return "unknown"
|
||||
|
||||
except Exception:
|
||||
# Any error (subprocess issues, etc.) -> unknown
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _classify_hostname(hostname: str) -> str:
|
||||
"""Classify a hostname as github, gitlab, or unknown.
|
||||
|
||||
Args:
|
||||
hostname: The git remote hostname (e.g., 'github.com', 'gitlab.example.com')
|
||||
|
||||
Returns:
|
||||
'github', 'gitlab', or 'unknown'
|
||||
"""
|
||||
hostname_lower = hostname.lower()
|
||||
|
||||
# Check for GitHub (cloud and self-hosted/enterprise)
|
||||
# Match github.com, *.github.com, or domains where a segment is or starts with 'github'
|
||||
hostname_parts = hostname_lower.split(".")
|
||||
if (
|
||||
hostname_lower == "github.com"
|
||||
or hostname_lower.endswith(".github.com")
|
||||
or any(
|
||||
part == "github" or part.startswith("github-") for part in hostname_parts
|
||||
)
|
||||
):
|
||||
return "github"
|
||||
|
||||
# Check for GitLab (cloud and self-hosted)
|
||||
# Match gitlab.com, *.gitlab.com, or domains where a segment is or starts with 'gitlab'
|
||||
if (
|
||||
hostname_lower == "gitlab.com"
|
||||
or hostname_lower.endswith(".gitlab.com")
|
||||
or any(
|
||||
part == "gitlab" or part.startswith("gitlab-") for part in hostname_parts
|
||||
)
|
||||
):
|
||||
return "gitlab"
|
||||
|
||||
# Unknown provider
|
||||
return "unknown"
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitLab CLI Executable Finder
|
||||
============================
|
||||
|
||||
Utility to find the glab (GitLab CLI) executable, with platform-specific fallbacks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_cached_glab_path: str | None = None
|
||||
|
||||
|
||||
def invalidate_glab_cache() -> None:
|
||||
"""Invalidate the cached glab executable path.
|
||||
|
||||
Useful when glab may have been uninstalled, updated, or when
|
||||
GITLAB_CLI_PATH environment variable has changed.
|
||||
"""
|
||||
global _cached_glab_path
|
||||
_cached_glab_path = None
|
||||
|
||||
|
||||
def _verify_glab_executable(path: str) -> bool:
|
||||
"""Verify that a path is a valid glab executable by checking version.
|
||||
|
||||
Args:
|
||||
path: Path to the potential glab executable
|
||||
|
||||
Returns:
|
||||
True if the path points to a valid glab executable, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _run_where_command() -> str | None:
|
||||
"""Run Windows 'where glab' command to find glab executable.
|
||||
|
||||
Returns:
|
||||
First path found, or None if command failed
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where glab",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if (
|
||||
found_path
|
||||
and os.path.isfile(found_path)
|
||||
and _verify_glab_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_glab_executable() -> str | None:
|
||||
"""Find the glab executable, with platform-specific fallbacks.
|
||||
|
||||
Returns the path to glab executable, or None if not found.
|
||||
|
||||
Priority order:
|
||||
1. GITLAB_CLI_PATH env var (user-configured path from frontend)
|
||||
2. shutil.which (if glab is in PATH)
|
||||
3. Homebrew paths on macOS
|
||||
4. Windows Program Files paths
|
||||
5. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find. Use invalidate_glab_cache()
|
||||
to force re-detection (e.g., after glab installation/uninstallation).
|
||||
"""
|
||||
global _cached_glab_path
|
||||
|
||||
# Return cached result if available AND still exists
|
||||
if _cached_glab_path is not None and os.path.isfile(_cached_glab_path):
|
||||
return _cached_glab_path
|
||||
|
||||
_cached_glab_path = _find_glab_executable()
|
||||
return _cached_glab_path
|
||||
|
||||
|
||||
def _find_glab_executable() -> str | None:
|
||||
"""Internal function to find glab executable."""
|
||||
# 1. Check GITLAB_CLI_PATH env var (set by Electron frontend)
|
||||
env_path = os.environ.get("GITLAB_CLI_PATH")
|
||||
if env_path and os.path.isfile(env_path) and _verify_glab_executable(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. Try shutil.which (works if glab is in PATH)
|
||||
glab_path = shutil.which("glab")
|
||||
if glab_path and _verify_glab_executable(glab_path):
|
||||
return glab_path
|
||||
|
||||
# 3. macOS-specific: check Homebrew paths
|
||||
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/glab", # Apple Silicon
|
||||
"/usr/local/bin/glab", # Intel Mac
|
||||
"/home/linuxbrew/.linuxbrew/bin/glab", # Linux Homebrew
|
||||
]
|
||||
for path in homebrew_paths:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 4. Windows-specific: check Program Files paths
|
||||
# glab uses Inno Setup with DefaultDirName={autopf}\glab
|
||||
if os.name == "nt":
|
||||
windows_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\glab\glab.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"),
|
||||
]
|
||||
for path in windows_paths:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_glab(
|
||||
args: list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a glab command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: glab command arguments (without 'glab' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
glab = get_glab_executable()
|
||||
if not glab:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["glab"] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
try:
|
||||
return subprocess.run(
|
||||
[glab] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[glab] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[glab] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitLab CLI (glab) executable not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
@@ -23,6 +23,9 @@ class ExecutionPhase(str, Enum):
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
# Pause states for intelligent error recovery
|
||||
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
@@ -31,8 +34,19 @@ def emit_phase(
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
reset_timestamp: int | None = None,
|
||||
profile_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
"""Emit structured phase event to stdout for frontend parsing.
|
||||
|
||||
Args:
|
||||
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
|
||||
message: Optional message describing the phase state
|
||||
progress: Optional progress percentage (0-100)
|
||||
subtask: Optional subtask identifier
|
||||
reset_timestamp: Optional Unix timestamp for rate limit reset time
|
||||
profile_id: Optional profile ID that triggered the pause
|
||||
"""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -48,6 +62,12 @@ def emit_phase(
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
if reset_timestamp is not None:
|
||||
payload["reset_timestamp"] = reset_timestamp
|
||||
|
||||
if profile_id is not None:
|
||||
payload["profile_id"] = profile_id
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
|
||||
@@ -28,9 +28,8 @@ from pathlib import Path
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
from core.platform import validate_cli_path
|
||||
from phase_config import get_thinking_budget
|
||||
@@ -80,18 +79,8 @@ def create_simple_client(
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
|
||||
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
|
||||
oauth_token = require_auth_token(config_dir)
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_token)
|
||||
|
||||
# Ensure SDK can access it via its expected env var
|
||||
# This is required because the SDK doesn't know about per-profile Keychain naming
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Task event protocol for frontend XState synchronization.
|
||||
|
||||
Protocol: __TASK_EVENT__:{...}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
TASK_EVENT_PREFIX = "__TASK_EVENT__:"
|
||||
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskEventContext:
|
||||
task_id: str
|
||||
spec_id: str
|
||||
project_id: str
|
||||
sequence_start: int = 0
|
||||
|
||||
|
||||
def _load_task_metadata(spec_dir: Path) -> dict:
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if not metadata_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _load_last_sequence(spec_dir: Path) -> int:
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
if not plan_path.exists():
|
||||
return 0
|
||||
try:
|
||||
with open(plan_path, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
last_event = plan.get("lastEvent") or {}
|
||||
seq = last_event.get("sequence")
|
||||
if isinstance(seq, int) and seq >= 0:
|
||||
return seq + 1
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def load_task_event_context(spec_dir: Path) -> TaskEventContext:
|
||||
metadata = _load_task_metadata(spec_dir)
|
||||
task_id = metadata.get("taskId") or metadata.get("task_id") or spec_dir.name
|
||||
spec_id = metadata.get("specId") or metadata.get("spec_id") or spec_dir.name
|
||||
project_id = metadata.get("projectId") or metadata.get("project_id") or ""
|
||||
sequence_start = _load_last_sequence(spec_dir)
|
||||
return TaskEventContext(
|
||||
task_id=str(task_id),
|
||||
spec_id=str(spec_id),
|
||||
project_id=str(project_id),
|
||||
sequence_start=sequence_start,
|
||||
)
|
||||
|
||||
|
||||
class TaskEventEmitter:
|
||||
def __init__(self, context: TaskEventContext) -> None:
|
||||
self._context = context
|
||||
self._sequence = context.sequence_start
|
||||
|
||||
@classmethod
|
||||
def from_spec_dir(cls, spec_dir: Path) -> TaskEventEmitter:
|
||||
return cls(load_task_event_context(spec_dir))
|
||||
|
||||
def emit(self, event_type: str, payload: dict | None = None) -> None:
|
||||
event = {
|
||||
"type": event_type,
|
||||
"taskId": self._context.task_id,
|
||||
"specId": self._context.spec_id,
|
||||
"projectId": self._context.project_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"eventId": str(uuid4()),
|
||||
"sequence": self._sequence,
|
||||
}
|
||||
if payload:
|
||||
event.update(payload)
|
||||
|
||||
try:
|
||||
print(f"{TASK_EVENT_PREFIX}{json.dumps(event, default=str)}", flush=True)
|
||||
self._sequence += 1
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
try:
|
||||
sys.stderr.write(f"[task_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Silent on complete I/O failure
|
||||
+202
-175
@@ -121,6 +121,7 @@ from merge import (
|
||||
FileTimelineTracker,
|
||||
MergeOrchestrator,
|
||||
)
|
||||
from merge.progress import MergeProgressCallback, MergeProgressStage, emit_progress
|
||||
|
||||
MODULE = "workspace"
|
||||
|
||||
@@ -145,6 +146,26 @@ MODULE = "workspace"
|
||||
# - _heuristic_merge
|
||||
|
||||
|
||||
def _create_merge_progress_callback() -> MergeProgressCallback | None:
|
||||
"""
|
||||
Create a progress callback for merge operations when running as a subprocess.
|
||||
|
||||
Returns emit_progress (writing JSON to stdout) only when stdout is piped
|
||||
(i.e., running as a subprocess from the Electron frontend). Returns None
|
||||
when running interactively in a terminal to avoid polluting CLI output.
|
||||
|
||||
This function must be called at runtime (not at import time) to ensure
|
||||
sys.stdout state is accurate.
|
||||
"""
|
||||
import sys
|
||||
|
||||
# Only emit progress JSON when stdout is piped (subprocess mode).
|
||||
# In interactive CLI mode (TTY), progress JSON would clutter the output.
|
||||
if not sys.stdout.isatty():
|
||||
return emit_progress
|
||||
return None
|
||||
|
||||
|
||||
def merge_existing_build(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
@@ -252,10 +273,11 @@ def merge_existing_build(
|
||||
had_conflicts = stats.get("conflicts_resolved", 0) > 0
|
||||
ai_assisted = stats.get("ai_assisted", 0) > 0
|
||||
direct_copy = stats.get("direct_copy", False)
|
||||
git_merge_used = stats.get("git_merge", False)
|
||||
|
||||
if had_conflicts or ai_assisted or direct_copy:
|
||||
# AI resolved conflicts, assisted with merges, or direct copy was used
|
||||
# Changes are already written and staged - no need for git merge
|
||||
if had_conflicts or ai_assisted or direct_copy or git_merge_used:
|
||||
# AI resolved conflicts, assisted with merges, git merge was used, or direct copy was used
|
||||
# Changes are already written and staged - no need for additional git merge
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
)
|
||||
@@ -402,9 +424,20 @@ def _try_smart_merge_inner(
|
||||
no_commit=no_commit,
|
||||
)
|
||||
|
||||
# Create progress callback for subprocess mode (Electron frontend).
|
||||
# Only emits JSON to stdout when piped, not in interactive CLI.
|
||||
progress_callback = _create_merge_progress_callback()
|
||||
|
||||
try:
|
||||
print(muted(" Analyzing changes with intent-aware merge..."))
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.ANALYZING,
|
||||
0,
|
||||
"Starting merge analysis",
|
||||
)
|
||||
|
||||
# Capture worktree state in FileTimelineTracker before merge
|
||||
try:
|
||||
timeline_tracker = FileTimelineTracker(project_dir)
|
||||
@@ -440,6 +473,13 @@ def _try_smart_merge_inner(
|
||||
)
|
||||
|
||||
# Check for git-level conflicts first (branch divergence)
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Checking for git-level conflicts",
|
||||
)
|
||||
|
||||
debug(MODULE, "Checking for git-level conflicts")
|
||||
git_conflicts = _check_git_conflicts(project_dir, spec_name)
|
||||
|
||||
@@ -492,12 +532,11 @@ def _try_smart_merge_inner(
|
||||
# If rebase succeeded and now there are no conflicts,
|
||||
# the diverged_but_no_conflicts path will handle the merge
|
||||
else:
|
||||
# Rebase failed - continue with conflict resolution as before
|
||||
# The AI resolver will handle the conflicts
|
||||
print(
|
||||
warning(
|
||||
" Rebase encountered issues, using AI conflict resolution..."
|
||||
)
|
||||
# Rebase failed (likely due to worktree lock) - continue with merge
|
||||
# Git merge or AI resolver will handle it depending on conflict state
|
||||
debug(
|
||||
MODULE,
|
||||
"Rebase skipped or failed, continuing with merge flow",
|
||||
)
|
||||
|
||||
if git_conflicts.get("has_conflicts"):
|
||||
@@ -518,6 +557,18 @@ def _try_smart_merge_inner(
|
||||
num_conflicts=len(git_conflicts.get("conflicting_files", [])),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.RESOLVING,
|
||||
50,
|
||||
f"Resolving {len(git_conflicts.get('conflicting_files', []))} conflicting files with AI",
|
||||
{
|
||||
"conflicts_found": len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
# Try to resolve git conflicts with AI
|
||||
resolution_result = _resolve_git_conflicts_with_ai(
|
||||
project_dir,
|
||||
@@ -535,6 +586,22 @@ def _try_smart_merge_inner(
|
||||
resolved_files=resolution_result.get("resolved_files", []),
|
||||
stats=resolution_result.get("stats", {}),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
stats = resolution_result.get("stats", {})
|
||||
original_conflict_count = len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
"Merge complete",
|
||||
{
|
||||
"conflicts_found": original_conflict_count,
|
||||
"conflicts_resolved": stats.get("conflicts_resolved", 0),
|
||||
},
|
||||
)
|
||||
|
||||
return resolution_result
|
||||
else:
|
||||
# AI couldn't resolve all conflicts
|
||||
@@ -547,6 +614,26 @@ def _try_smart_merge_inner(
|
||||
resolved_files=resolution_result.get("resolved_files", []),
|
||||
error=resolution_result.get("error"),
|
||||
)
|
||||
|
||||
if progress_callback is not None:
|
||||
original_conflict_count = len(
|
||||
git_conflicts.get("conflicting_files", [])
|
||||
)
|
||||
remaining_count = len(
|
||||
resolution_result.get("remaining_conflicts", [])
|
||||
)
|
||||
progress_callback(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
"Some conflicts could not be resolved",
|
||||
{
|
||||
"conflicts_found": original_conflict_count,
|
||||
"conflicts_resolved": original_conflict_count
|
||||
- remaining_count,
|
||||
"conflicts_remaining": remaining_count,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"conflicts": resolution_result.get("remaining_conflicts", []),
|
||||
@@ -555,148 +642,81 @@ def _try_smart_merge_inner(
|
||||
"error": resolution_result.get("error"),
|
||||
}
|
||||
|
||||
# Check if branches diverged but no actual conflicts (can do direct copy)
|
||||
# Check if branches diverged but no actual conflicts (use git merge)
|
||||
if git_conflicts.get("diverged_but_no_conflicts"):
|
||||
debug(MODULE, "Branches diverged but no conflicts - doing direct file copy")
|
||||
debug(MODULE, "Branches diverged but no conflicts - using git merge")
|
||||
print(muted(" Branches diverged but no conflicts detected"))
|
||||
print(muted(" Copying changed files directly from worktree..."))
|
||||
print(muted(" Using git merge to combine changes..."))
|
||||
|
||||
# Get changed files from spec branch
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
base_branch = git_conflicts.get("base_branch", "main")
|
||||
|
||||
# Get merge-base for diff
|
||||
merge_base_result = run_git(
|
||||
["merge-base", base_branch, spec_branch],
|
||||
# Use git merge --no-commit to combine changes from both branches
|
||||
# Since merge-tree confirmed no conflicts, this should succeed cleanly
|
||||
merge_result = run_git(
|
||||
["merge", "--no-commit", "--no-ff", spec_branch],
|
||||
cwd=project_dir,
|
||||
)
|
||||
merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
if merge_base_result.returncode == 0
|
||||
else None
|
||||
)
|
||||
|
||||
if merge_base:
|
||||
# Get list of changed files in spec branch
|
||||
changed_files = _get_changed_files_from_branch(
|
||||
project_dir, merge_base, spec_branch
|
||||
if merge_result.returncode == 0:
|
||||
# Merge succeeded - get list of files that were merged
|
||||
# Use git diff --cached to see what's staged
|
||||
diff_result = run_git(
|
||||
["diff", "--cached", "--name-only"],
|
||||
cwd=project_dir,
|
||||
)
|
||||
merged_files = [
|
||||
f.strip()
|
||||
for f in diff_result.stdout.splitlines()
|
||||
if f.strip() and not _is_auto_claude_file(f.strip())
|
||||
]
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
"Git merge succeeded",
|
||||
merged_files_count=len(merged_files),
|
||||
)
|
||||
|
||||
resolved_files = []
|
||||
skipped_files = [] # Track files that failed to copy
|
||||
files_to_stage = []
|
||||
for file_path, status in changed_files:
|
||||
if _is_auto_claude_file(file_path):
|
||||
continue
|
||||
for file_path in merged_files:
|
||||
print(success(f" ✓ {file_path}"))
|
||||
|
||||
try:
|
||||
target_path = project_dir / file_path
|
||||
|
||||
if status == "D":
|
||||
# Deleted in worktree
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
print(success(f" ✓ {file_path} (deleted)"))
|
||||
else:
|
||||
# New or modified - copy from spec branch
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
status_label = (
|
||||
"new file" if status == "A" else "updated"
|
||||
)
|
||||
print(
|
||||
success(f" ✓ {file_path} ({status_label})")
|
||||
)
|
||||
else:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not retrieve binary content for {file_path}",
|
||||
)
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
files_to_stage.append(file_path)
|
||||
resolved_files.append(file_path)
|
||||
status_label = (
|
||||
"new file" if status == "A" else "updated"
|
||||
)
|
||||
print(
|
||||
success(f" ✓ {file_path} ({status_label})")
|
||||
)
|
||||
else:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not retrieve content for {file_path}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
skipped_files.append(file_path)
|
||||
debug_warning(MODULE, f"Could not copy {file_path}: {e}")
|
||||
|
||||
# Stage all files in a single git add call for efficiency
|
||||
if files_to_stage:
|
||||
add_result = run_git(
|
||||
["add"] + files_to_stage,
|
||||
cwd=project_dir,
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Git merge complete ({len(merged_files)} files)",
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Failed to stage files for direct copy: {add_result.stderr}",
|
||||
)
|
||||
# Return failure - files were written but not staged
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Failed to stage files: {add_result.stderr}",
|
||||
"resolved_files": [],
|
||||
}
|
||||
|
||||
# Build result - check for skipped files to detect partial merges
|
||||
result = {
|
||||
"success": len(skipped_files) == 0,
|
||||
"resolved_files": resolved_files,
|
||||
return {
|
||||
"success": True,
|
||||
"resolved_files": merged_files,
|
||||
"stats": {
|
||||
"files_merged": len(resolved_files),
|
||||
"files_merged": len(merged_files),
|
||||
"conflicts_resolved": 0,
|
||||
"ai_assisted": 0,
|
||||
"auto_merged": len(resolved_files),
|
||||
"direct_copy": True, # Flag indicating direct copy was used
|
||||
"skipped_count": len(skipped_files),
|
||||
"auto_merged": len(merged_files),
|
||||
"git_merge": True, # Flag indicating git merge was used
|
||||
},
|
||||
}
|
||||
if skipped_files:
|
||||
result["skipped_files"] = skipped_files
|
||||
result["partial_success"] = len(resolved_files) > 0
|
||||
print()
|
||||
print(
|
||||
warning(
|
||||
f" ⚠ {len(skipped_files)} file(s) could not be retrieved:"
|
||||
)
|
||||
)
|
||||
for skipped_file in skipped_files:
|
||||
print(muted(f" - {skipped_file}"))
|
||||
print(muted(" These files may need manual review."))
|
||||
return result
|
||||
else:
|
||||
# merge-base failed - branches may not share history
|
||||
# Merge failed unexpectedly - abort and fall back to semantic analysis
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not find merge-base between branches - falling back to semantic analysis",
|
||||
"Git merge failed unexpectedly despite no conflicts detected",
|
||||
stderr=merge_result.stderr[:500] if merge_result.stderr else "",
|
||||
)
|
||||
# Abort the merge to restore clean state
|
||||
abort_result = run_git(["merge", "--abort"], cwd=project_dir)
|
||||
if abort_result.returncode != 0:
|
||||
debug_error(
|
||||
MODULE,
|
||||
"Failed to abort merge - repo may be in inconsistent state",
|
||||
stderr=abort_result.stderr,
|
||||
)
|
||||
return None # Trigger fallback to avoid operating on inconsistent state
|
||||
print(
|
||||
warning(
|
||||
" Git merge failed unexpectedly, falling back to semantic analysis..."
|
||||
)
|
||||
)
|
||||
|
||||
# No git conflicts - proceed with semantic analysis
|
||||
@@ -725,6 +745,14 @@ def _try_smart_merge_inner(
|
||||
|
||||
# All conflicts can be auto-merged or no conflicts
|
||||
print(muted(" All changes compatible, proceeding with merge..."))
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Analysis complete ({files_to_merge} files compatible)",
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stats": {
|
||||
@@ -737,6 +765,13 @@ def _try_smart_merge_inner(
|
||||
# If smart merge fails, fall back to git
|
||||
import traceback
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
f"Smart merge error: {e}",
|
||||
)
|
||||
|
||||
print(muted(f" Smart merge error: {e}"))
|
||||
traceback.print_exc()
|
||||
return None
|
||||
@@ -748,14 +783,11 @@ def _rebase_spec_branch(
|
||||
base_branch: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Rebase the spec branch onto the latest base branch.
|
||||
Attempt to rebase the spec branch onto the latest base branch.
|
||||
|
||||
This performs an automatic rebase of the spec branch onto the current
|
||||
base branch (main/develop) to bring it up to date before merging.
|
||||
If conflicts occur during rebase, the function aborts and returns False
|
||||
so that the caller can fall back to AI conflict resolution.
|
||||
|
||||
The function preserves the current HEAD by restoring it after completion.
|
||||
NOTE: This will fail if the spec branch is checked out in a worktree,
|
||||
which is the normal case. The caller should handle failure gracefully
|
||||
by falling back to git merge or AI conflict resolution.
|
||||
|
||||
Args:
|
||||
project_dir: The project directory
|
||||
@@ -764,22 +796,36 @@ def _rebase_spec_branch(
|
||||
|
||||
Returns:
|
||||
True if rebase succeeded cleanly or branch was already up-to-date,
|
||||
False if rebase failed due to conflicts or other errors (aborted, no ref movement)
|
||||
False if rebase failed (worktree lock, conflicts, or other errors)
|
||||
"""
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
"Rebasing spec branch",
|
||||
"Attempting to rebase spec branch",
|
||||
spec_branch=spec_branch,
|
||||
base_branch=base_branch,
|
||||
)
|
||||
|
||||
# Save original branch to restore after rebase (HIGH: prevents leaving repo on spec branch)
|
||||
# Check if spec branch is used by a worktree (common case)
|
||||
# In this case, we can't checkout/rebase from the main repo
|
||||
worktree_list_result = run_git(["worktree", "list", "--porcelain"], cwd=project_dir)
|
||||
if worktree_list_result.returncode == 0:
|
||||
# Check if spec_branch is in use by a worktree
|
||||
output = worktree_list_result.stdout
|
||||
if f"branch refs/heads/{spec_branch}" in output:
|
||||
debug(
|
||||
MODULE,
|
||||
"Spec branch is checked out in a worktree - skipping rebase",
|
||||
spec_branch=spec_branch,
|
||||
)
|
||||
# This is expected - return False to let caller use git merge instead
|
||||
return False
|
||||
|
||||
# Save original branch to restore after rebase
|
||||
original_branch_result = run_git(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir
|
||||
)
|
||||
# Check returncode and validate stdout before using original_branch
|
||||
if original_branch_result.returncode != 0:
|
||||
debug_error(
|
||||
MODULE,
|
||||
@@ -795,7 +841,6 @@ def _rebase_spec_branch(
|
||||
)
|
||||
return False
|
||||
|
||||
# Save current state for recovery
|
||||
# Get the current commit of spec_branch before rebase
|
||||
before_commit_result = run_git(["rev-parse", spec_branch], cwd=project_dir)
|
||||
if before_commit_result.returncode != 0:
|
||||
@@ -804,8 +849,6 @@ def _rebase_spec_branch(
|
||||
"Could not get spec branch commit before rebase",
|
||||
stderr=before_commit_result.stderr,
|
||||
)
|
||||
# Restore original branch before returning
|
||||
run_git(["checkout", original_branch], cwd=project_dir)
|
||||
return False
|
||||
before_commit = before_commit_result.stdout.strip()
|
||||
|
||||
@@ -813,22 +856,18 @@ def _rebase_spec_branch(
|
||||
print(muted(f" Rebasing {spec_branch} onto {base_branch}..."))
|
||||
|
||||
try:
|
||||
# Perform the rebase using safe/standard invocation:
|
||||
# 1. Checkout the spec branch first
|
||||
# 2. Run standard rebase (no strategy options - let conflicts stop the rebase)
|
||||
# If conflicts occur, we'll abort and let AI handle them during merge
|
||||
# Try to checkout the spec branch
|
||||
checkout_result = run_git(["checkout", spec_branch], cwd=project_dir)
|
||||
if checkout_result.returncode != 0:
|
||||
debug_error(
|
||||
# Checkout failed - likely due to worktree lock
|
||||
debug(
|
||||
MODULE,
|
||||
"Could not checkout spec branch for rebase",
|
||||
stderr=checkout_result.stderr,
|
||||
"Could not checkout spec branch for rebase (likely worktree lock)",
|
||||
stderr=checkout_result.stderr[:200] if checkout_result.stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
# Run standard rebase - will stop on conflicts so we can detect them
|
||||
# Git syntax: git rebase [options] <upstream>
|
||||
# where <upstream> is the branch to rebase onto
|
||||
# Run standard rebase
|
||||
rebase_result = run_git(
|
||||
["rebase", base_branch],
|
||||
cwd=project_dir,
|
||||
@@ -838,9 +877,6 @@ def _rebase_spec_branch(
|
||||
# Rebase failed - check if it was due to conflicts
|
||||
status_result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
|
||||
# MEDIUM: Properly parse git status output for conflict markers
|
||||
# Git status --porcelain uses two-character status codes:
|
||||
# UU = both modified, AA = both added, DD = both deleted, etc.
|
||||
has_unmerged = any(
|
||||
line[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD")
|
||||
for line in status_result.stdout.splitlines()
|
||||
@@ -848,7 +884,6 @@ def _rebase_spec_branch(
|
||||
)
|
||||
|
||||
# Abort the rebase to return to clean state
|
||||
# NEW-002: If abort fails, immediately return False (repo in bad state)
|
||||
abort_result = run_git(["rebase", "--abort"], cwd=project_dir)
|
||||
if abort_result.returncode != 0:
|
||||
debug_error(
|
||||
@@ -856,19 +891,16 @@ def _rebase_spec_branch(
|
||||
"Failed to abort rebase - repo may be in inconsistent state",
|
||||
stderr=abort_result.stderr,
|
||||
)
|
||||
return False # Abort failed - cannot safely continue
|
||||
|
||||
if has_unmerged:
|
||||
# Rebase failed due to conflicts - we aborted, so no ref movement happened
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Rebase encountered conflicts - aborted, will use AI conflict resolution",
|
||||
stderr=rebase_result.stderr[:200] if rebase_result.stderr else "",
|
||||
)
|
||||
# Return False since we aborted - no rebase occurred, caller should use AI
|
||||
return False
|
||||
|
||||
# Other error (not conflict-related)
|
||||
if has_unmerged:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Rebase encountered conflicts - aborted, will use alternative merge",
|
||||
stderr=rebase_result.stderr[:200] if rebase_result.stderr else "",
|
||||
)
|
||||
return False
|
||||
|
||||
debug_error(
|
||||
MODULE,
|
||||
"Rebase failed with unexpected error",
|
||||
@@ -882,9 +914,7 @@ def _rebase_spec_branch(
|
||||
if after_commit_result.returncode == 0:
|
||||
after_commit_hash = after_commit_result.stdout.strip()
|
||||
|
||||
# Verify the branch actually moved (commit changed)
|
||||
if before_commit == after_commit_hash:
|
||||
# MEDIUM: Branch already up-to-date is a success condition, not failure
|
||||
debug(
|
||||
MODULE,
|
||||
"Branch already up-to-date, no rebase needed",
|
||||
@@ -904,8 +934,7 @@ def _rebase_spec_branch(
|
||||
debug_error(MODULE, "Could not verify spec branch commit after rebase")
|
||||
return False
|
||||
finally:
|
||||
# HIGH: Always restore original branch, even on error/exception
|
||||
# NEW-001: Log restoration failure (cannot modify return from finally block)
|
||||
# Always restore original branch
|
||||
if original_branch:
|
||||
restore_result = run_git(["checkout", original_branch], cwd=project_dir)
|
||||
if restore_result.returncode != 0:
|
||||
@@ -914,8 +943,6 @@ def _rebase_spec_branch(
|
||||
f"Failed to restore original branch '{original_branch}'",
|
||||
stderr=restore_result.stderr,
|
||||
)
|
||||
# Note: Cannot modify return value from finally block,
|
||||
# but restoration failure is rare and non-critical (user can manually switch back)
|
||||
|
||||
|
||||
def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
|
||||
@@ -325,6 +325,7 @@ def setup_workspace(
|
||||
mode: WorkspaceMode,
|
||||
source_spec_dir: Path | None = None,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
) -> tuple[Path, WorktreeManager | None, Path | None]:
|
||||
"""
|
||||
Set up the workspace based on user's choice.
|
||||
@@ -337,6 +338,7 @@ def setup_workspace(
|
||||
mode: The workspace mode to use
|
||||
source_spec_dir: Optional source spec directory to copy to worktree
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
use_local_branch: If True, use local branch directly instead of preferring origin/branch
|
||||
|
||||
Returns:
|
||||
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
|
||||
@@ -357,7 +359,9 @@ def setup_workspace(
|
||||
# Ensure timeline tracking hook is installed (once per session)
|
||||
ensure_timeline_hook_installed(project_dir)
|
||||
|
||||
manager = WorktreeManager(project_dir, base_branch=base_branch)
|
||||
manager = WorktreeManager(
|
||||
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
|
||||
)
|
||||
manager.setup()
|
||||
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
|
||||
+457
-28
@@ -15,6 +15,8 @@ This allows:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -28,8 +30,13 @@ from typing import TypedDict, TypeVar
|
||||
|
||||
from core.gh_executable import get_gh_executable, invalidate_gh_cache
|
||||
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
|
||||
from core.git_provider import detect_git_provider
|
||||
from core.glab_executable import get_glab_executable, invalidate_glab_cache
|
||||
from core.model_config import get_utility_model_config
|
||||
from debug import debug_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@@ -136,6 +143,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
|
||||
pushed: bool
|
||||
remote: str
|
||||
branch: str
|
||||
provider: str # 'github', 'gitlab', or 'unknown'
|
||||
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
||||
already_exists: bool
|
||||
error: str
|
||||
@@ -175,12 +183,18 @@ class WorktreeManager:
|
||||
|
||||
# Timeout constants for subprocess operations
|
||||
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
|
||||
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
|
||||
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
|
||||
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
|
||||
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
|
||||
|
||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
):
|
||||
self.project_dir = project_dir
|
||||
self.base_branch = base_branch or self._detect_base_branch()
|
||||
self.use_local_branch = use_local_branch
|
||||
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
self._merge_lock = asyncio.Lock()
|
||||
|
||||
@@ -687,18 +701,23 @@ class WorktreeManager:
|
||||
else:
|
||||
# Branch doesn't exist - create new branch from remote or local base
|
||||
# Determine the start point for the worktree
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
start_point = self.base_branch # Default to local branch
|
||||
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
if self.use_local_branch:
|
||||
# User explicitly requested local branch - skip auto-switch to remote
|
||||
# This preserves gitignored files (.env, configs) that may not exist on remote
|
||||
print(f"Creating worktree from local branch: {self.base_branch}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
|
||||
# Create worktree with new branch from the start point
|
||||
result = self._run_git(
|
||||
@@ -1192,8 +1211,22 @@ class WorktreeManager:
|
||||
target = target_branch or self.base_branch
|
||||
pr_title = title or f"auto-claude: {spec_name}"
|
||||
|
||||
# Get PR body from spec.md if available
|
||||
pr_body = self._extract_spec_summary(spec_name)
|
||||
# Try AI-powered PR body from project's PR template, fall back to spec summary
|
||||
pr_body: str | None = None
|
||||
try:
|
||||
diff_summary, commit_log = self._gather_pr_context(spec_name, target)
|
||||
pr_body = self._try_ai_pr_body(
|
||||
spec_name=spec_name,
|
||||
target_branch=target,
|
||||
branch_name=info.branch,
|
||||
diff_summary=diff_summary,
|
||||
commit_log=commit_log,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"AI PR body generation encountered an error: {e}")
|
||||
|
||||
if not pr_body:
|
||||
pr_body = self._extract_spec_summary(spec_name)
|
||||
|
||||
# Find gh executable before attempting PR creation
|
||||
gh_executable = get_gh_executable()
|
||||
@@ -1236,7 +1269,7 @@ class WorktreeManager:
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_CLI_TIMEOUT,
|
||||
timeout=self.CLI_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
@@ -1312,9 +1345,328 @@ class WorktreeManager:
|
||||
invalidate_gh_cache()
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="gh CLI not found. Install from https://cli.github.com/",
|
||||
error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
||||
)
|
||||
|
||||
def create_merge_request(
|
||||
self,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> PullRequestResult:
|
||||
"""
|
||||
Create a GitLab merge request for a spec's branch using glab CLI with retry logic.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: Target branch for MR (defaults to base_branch)
|
||||
title: MR title (defaults to spec name)
|
||||
draft: Whether to create as draft MR
|
||||
|
||||
Returns:
|
||||
PullRequestResult with keys:
|
||||
- success: bool
|
||||
- pr_url: str (if created)
|
||||
- already_exists: bool (if MR already exists)
|
||||
- error: str (if failed)
|
||||
"""
|
||||
info = self.get_worktree_info(spec_name)
|
||||
if not info:
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"No worktree found for spec: {spec_name}",
|
||||
)
|
||||
|
||||
target = target_branch or self.base_branch
|
||||
mr_title = title or f"auto-claude: {spec_name}"
|
||||
|
||||
# Get MR body from spec.md if available
|
||||
mr_body = self._extract_spec_summary(spec_name)
|
||||
|
||||
# Find glab executable before attempting MR creation
|
||||
glab_executable = get_glab_executable()
|
||||
if not glab_executable:
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
|
||||
# Build glab mr create command
|
||||
glab_args = [
|
||||
glab_executable,
|
||||
"mr",
|
||||
"create",
|
||||
"--target-branch",
|
||||
target,
|
||||
"--source-branch",
|
||||
info.branch,
|
||||
"--title",
|
||||
mr_title,
|
||||
"--description",
|
||||
mr_body,
|
||||
]
|
||||
if draft:
|
||||
glab_args.append("--draft")
|
||||
|
||||
def is_mr_retryable(stderr: str) -> bool:
|
||||
"""Check if MR creation error is retryable (network or HTTP 5xx)."""
|
||||
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
|
||||
stderr
|
||||
)
|
||||
|
||||
def do_create_mr() -> tuple[bool, PullRequestResult | None, str]:
|
||||
"""Execute MR creation for retry wrapper."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
glab_args,
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.CLI_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
|
||||
# Check for "already exists" case (success, no retry needed)
|
||||
if result.returncode != 0 and "already exists" in result.stderr.lower():
|
||||
existing_url = self._get_existing_mr_url(spec_name, target)
|
||||
result_dict = PullRequestResult(
|
||||
success=True,
|
||||
pr_url=existing_url,
|
||||
already_exists=True,
|
||||
)
|
||||
if existing_url is None:
|
||||
result_dict["message"] = (
|
||||
"MR already exists but URL could not be retrieved"
|
||||
)
|
||||
return (True, result_dict, "")
|
||||
|
||||
if result.returncode == 0:
|
||||
# Extract MR URL from output
|
||||
mr_url: str | None = result.stdout.strip()
|
||||
if not mr_url.startswith("http"):
|
||||
# Try to find URL in output
|
||||
# GitLab URL pattern: matches any HTTPS URL with /merge_requests/<number> or /-/merge_requests/<number> path
|
||||
match = re.search(
|
||||
r"https://[^\s]+(?:/merge_requests/|/-/merge_requests/)\d+",
|
||||
result.stdout,
|
||||
)
|
||||
if match:
|
||||
mr_url = match.group(0)
|
||||
else:
|
||||
# Invalid output - no valid URL found
|
||||
mr_url = None
|
||||
|
||||
return (
|
||||
True,
|
||||
PullRequestResult(
|
||||
success=True,
|
||||
pr_url=mr_url,
|
||||
already_exists=False,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
return (False, None, result.stderr)
|
||||
|
||||
except FileNotFoundError:
|
||||
# glab CLI not installed - not retryable, raise to exit retry loop
|
||||
raise
|
||||
|
||||
max_retries = 3
|
||||
try:
|
||||
result, last_error = _with_retry(
|
||||
operation=do_create_mr,
|
||||
max_retries=max_retries,
|
||||
is_retryable=is_mr_retryable,
|
||||
)
|
||||
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Handle timeout error message
|
||||
if last_error == "Operation timed out":
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"MR creation timed out after {max_retries} attempts.",
|
||||
)
|
||||
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"Failed to create MR: {last_error}",
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
# Cached glab path became invalid - clear cache so next call re-discovers
|
||||
invalidate_glab_cache()
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
|
||||
def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]:
|
||||
"""
|
||||
Gather diff summary and commit log for PR template filling.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: The target branch for the PR
|
||||
|
||||
Returns:
|
||||
Tuple of (diff_summary, commit_log)
|
||||
"""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
info = self.get_worktree_info(spec_name)
|
||||
branch = info.branch if info else self.get_branch_name(spec_name)
|
||||
|
||||
# Get diff summary (stat for overview)
|
||||
diff_result = self._run_git(
|
||||
["diff", "--stat", f"{target_branch}...{branch}"],
|
||||
cwd=worktree_path,
|
||||
timeout=30,
|
||||
)
|
||||
diff_summary = diff_result.stdout.strip() if diff_result.returncode == 0 else ""
|
||||
|
||||
# Get shortstat for quick summary
|
||||
shortstat_result = self._run_git(
|
||||
["diff", "--shortstat", f"{target_branch}...{branch}"],
|
||||
cwd=worktree_path,
|
||||
timeout=30,
|
||||
)
|
||||
if shortstat_result.returncode == 0 and shortstat_result.stdout.strip():
|
||||
diff_summary += "\n\n" + shortstat_result.stdout.strip()
|
||||
|
||||
# Get actual code changes (patch format) for better AI context
|
||||
# Truncate to 30k chars to avoid token limits while still providing meaningful context
|
||||
patch_result = self._run_git(
|
||||
["diff", "-p", "--stat-width=999", f"{target_branch}...{branch}"],
|
||||
cwd=worktree_path,
|
||||
timeout=30,
|
||||
)
|
||||
if patch_result.returncode == 0 and patch_result.stdout.strip():
|
||||
patch_content = patch_result.stdout.strip()
|
||||
MAX_DIFF_CHARS = 30_000
|
||||
|
||||
if len(patch_content) > MAX_DIFF_CHARS:
|
||||
# Truncate patch and add notice
|
||||
truncated_patch = patch_content[:MAX_DIFF_CHARS]
|
||||
diff_summary += (
|
||||
"\n\n" + truncated_patch + "\n\n(... diff truncated due to size)"
|
||||
)
|
||||
else:
|
||||
diff_summary += "\n\n" + patch_content
|
||||
|
||||
# Get commit log
|
||||
log_result = self._run_git(
|
||||
[
|
||||
"log",
|
||||
"--oneline",
|
||||
"--no-merges",
|
||||
f"{target_branch}..{branch}",
|
||||
],
|
||||
cwd=worktree_path,
|
||||
timeout=30,
|
||||
)
|
||||
commit_log = log_result.stdout.strip() if log_result.returncode == 0 else ""
|
||||
|
||||
return diff_summary, commit_log
|
||||
|
||||
def _try_ai_pr_body(
|
||||
self,
|
||||
spec_name: str,
|
||||
target_branch: str,
|
||||
branch_name: str,
|
||||
diff_summary: str,
|
||||
commit_log: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Attempt to generate a PR body using the AI template filler agent.
|
||||
|
||||
Runs the async agent synchronously with a 30-second timeout.
|
||||
Returns None on any failure so the caller can fall back gracefully.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: The target branch for the PR
|
||||
branch_name: The source branch name
|
||||
diff_summary: Git diff summary of changes
|
||||
commit_log: Git log of commits
|
||||
|
||||
Returns:
|
||||
The AI-generated PR body string, or None if unavailable.
|
||||
"""
|
||||
try:
|
||||
from agents.pr_template_filler import (
|
||||
detect_pr_template,
|
||||
run_pr_template_filler,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"PR template filler module not available, skipping AI PR body"
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if a PR template exists before doing any heavy lifting
|
||||
template = detect_pr_template(self.project_dir)
|
||||
if template is None:
|
||||
return None
|
||||
|
||||
# Resolve spec directory
|
||||
spec_dir = self.project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.is_dir():
|
||||
# Try worktree-local spec path
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
spec_dir = worktree_path / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.is_dir():
|
||||
logger.warning("Spec directory not found for AI PR body generation")
|
||||
return None
|
||||
|
||||
# Get model configuration from environment (respects user settings)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
async def _run_with_timeout() -> str | None:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
run_pr_template_filler(
|
||||
project_dir=self.project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
branch_name=branch_name,
|
||||
target_branch=target_branch,
|
||||
diff_summary=diff_summary,
|
||||
commit_log=commit_log,
|
||||
verbose=False,
|
||||
),
|
||||
timeout=30.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("PR template filler timed out after 30s")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check if there's already a running event loop
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# We're already inside an async context — run in a new thread
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
future = pool.submit(asyncio.run, _run_with_timeout())
|
||||
return future.result(timeout=35)
|
||||
else:
|
||||
return asyncio.run(_run_with_timeout())
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"AI PR body generation failed: {e}")
|
||||
return None
|
||||
|
||||
def _extract_spec_summary(self, spec_name: str) -> str:
|
||||
"""Extract a summary from spec.md for PR body."""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
@@ -1389,7 +1741,7 @@ class WorktreeManager:
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_QUERY_TIMEOUT,
|
||||
timeout=self.CLI_QUERY_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
@@ -1408,6 +1760,57 @@ class WorktreeManager:
|
||||
|
||||
return None
|
||||
|
||||
def _get_existing_mr_url(self, spec_name: str, target_branch: str) -> str | None:
|
||||
"""Get the URL of an existing MR for this branch."""
|
||||
info = self.get_worktree_info(spec_name)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
glab_executable = get_glab_executable()
|
||||
if not glab_executable:
|
||||
# glab CLI not found - return None and let caller handle it
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
glab_executable,
|
||||
"mr",
|
||||
"view",
|
||||
info.branch,
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.CLI_QUERY_TIMEOUT,
|
||||
env=get_isolated_git_env(),
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
# Parse JSON output to extract web_url (glab uses snake_case)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
return data.get("web_url")
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, return None
|
||||
pass
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
subprocess.SubprocessError,
|
||||
) as e:
|
||||
# Silently ignore errors when fetching existing MR URL - this is a best-effort
|
||||
# lookup that may fail due to network issues, missing glab CLI, or auth problems.
|
||||
# Returning None allows the caller to handle missing URLs gracefully.
|
||||
if isinstance(e, FileNotFoundError):
|
||||
invalidate_glab_cache()
|
||||
debug_warning("worktree", f"Could not get existing MR URL: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def push_and_create_pr(
|
||||
self,
|
||||
spec_name: str,
|
||||
@@ -1417,13 +1820,14 @@ class WorktreeManager:
|
||||
force_push: bool = False,
|
||||
) -> PushAndCreatePRResult:
|
||||
"""
|
||||
Push branch and create a pull request in one operation.
|
||||
Push branch and create a pull request/merge request in one operation.
|
||||
Automatically detects git provider (GitHub or GitLab) and routes to the appropriate CLI.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: Target branch for PR (defaults to base_branch)
|
||||
title: PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
target_branch: Target branch for PR/MR (defaults to base_branch)
|
||||
title: PR/MR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR/MR
|
||||
force_push: Whether to force push the branch
|
||||
|
||||
Returns:
|
||||
@@ -1431,7 +1835,8 @@ class WorktreeManager:
|
||||
- success: bool
|
||||
- pr_url: str (if created)
|
||||
- pushed: bool (if push succeeded)
|
||||
- already_exists: bool (if PR already exists)
|
||||
- provider: str ('github', 'gitlab', or 'unknown')
|
||||
- already_exists: bool (if PR/MR already exists)
|
||||
- error: str (if failed)
|
||||
"""
|
||||
# Step 1: Push the branch
|
||||
@@ -1445,20 +1850,44 @@ class WorktreeManager:
|
||||
error=push_result.get("error", "Push failed"),
|
||||
)
|
||||
|
||||
# Step 2: Create the PR
|
||||
pr_result = self.create_pull_request(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
# Step 2: Detect git provider (use the remote that was pushed to)
|
||||
provider = detect_git_provider(
|
||||
self.project_dir, remote_name=push_result.get("remote")
|
||||
)
|
||||
|
||||
# Step 3: Create the PR/MR based on provider
|
||||
if provider == "github":
|
||||
pr_result = self.create_pull_request(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
elif provider == "gitlab":
|
||||
pr_result = self.create_merge_request(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
else:
|
||||
# Unknown provider
|
||||
return PushAndCreatePRResult(
|
||||
success=False,
|
||||
pushed=True,
|
||||
remote=push_result.get("remote"),
|
||||
branch=push_result.get("branch"),
|
||||
provider=provider,
|
||||
error="Unable to determine git hosting provider. Supported: GitHub, GitLab.",
|
||||
)
|
||||
|
||||
# Combine results
|
||||
return PushAndCreatePRResult(
|
||||
success=pr_result.get("success", False),
|
||||
pushed=True,
|
||||
remote=push_result.get("remote"),
|
||||
branch=push_result.get("branch"),
|
||||
provider=provider,
|
||||
pr_url=pr_result.get("pr_url"),
|
||||
already_exists=pr_result.get("already_exists", False),
|
||||
error=pr_result.get("error"),
|
||||
|
||||
@@ -91,11 +91,14 @@ class IdeationGenerator:
|
||||
prompt += f"\n{additional_context}\n"
|
||||
|
||||
# Create client with thinking budget
|
||||
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
|
||||
# which can cause 60-second timeout delays
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -184,11 +187,13 @@ Common fixes:
|
||||
Write the fixed JSON to the file now.
|
||||
"""
|
||||
|
||||
# Use agent_type="ideation" for recovery agent as well
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .types import IdeationPhaseResult
|
||||
|
||||
# Configuration
|
||||
MAX_RETRIES = 3
|
||||
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
|
||||
|
||||
|
||||
class IdeationOrchestrator:
|
||||
@@ -173,16 +174,45 @@ class IdeationOrchestrator:
|
||||
"progress",
|
||||
)
|
||||
|
||||
# Create tasks for all enabled types
|
||||
ideation_tasks = [
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
# Create tasks explicitly so we can cancel them on timeout
|
||||
ideation_task_objs = [
|
||||
asyncio.create_task(
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
)
|
||||
)
|
||||
for ideation_type in self.enabled_types
|
||||
]
|
||||
|
||||
# Run all ideation types concurrently
|
||||
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
|
||||
# Run all ideation types concurrently with timeout protection
|
||||
# 5 minute timeout prevents infinite hangs if one type stalls
|
||||
try:
|
||||
ideation_results = await asyncio.wait_for(
|
||||
asyncio.gather(*ideation_task_objs, return_exceptions=True),
|
||||
timeout=IDEATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
print_status(
|
||||
"Ideation generation timed out after 5 minutes",
|
||||
"error",
|
||||
)
|
||||
# Cancel all pending tasks to prevent resource leaks
|
||||
for task in ideation_task_objs:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
# Wait for cancellation to complete and preserve results from completed tasks
|
||||
# Tasks that finished before timeout will return their results;
|
||||
# cancelled tasks will return CancelledError
|
||||
results_after_cancel = await asyncio.gather(
|
||||
*ideation_task_objs, return_exceptions=True
|
||||
)
|
||||
# Convert CancelledError to timeout exception, preserve completed results
|
||||
ideation_results = [
|
||||
Exception("Ideation timed out")
|
||||
if isinstance(res, asyncio.CancelledError)
|
||||
else res
|
||||
for res in results_after_cancel
|
||||
]
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(ideation_results):
|
||||
|
||||
@@ -12,12 +12,8 @@ The refactored code is now organized as:
|
||||
- graphiti/search.py - Semantic search logic
|
||||
- graphiti/schema.py - Graph schema definitions
|
||||
|
||||
This facade ensures existing imports continue to work:
|
||||
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
|
||||
|
||||
New code should prefer importing from the graphiti package:
|
||||
from graphiti import GraphitiMemory
|
||||
from graphiti.schema import GroupIdMode
|
||||
Import from this module:
|
||||
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
|
||||
|
||||
For detailed documentation on the memory system architecture and usage,
|
||||
see graphiti/graphiti.py.
|
||||
|
||||
@@ -62,7 +62,7 @@ async def get_graph_hints(
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
# Determine project directory from project_id or use current dir
|
||||
project_dir = Path.cwd()
|
||||
|
||||
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
|
||||
def is_graphiti_memory_enabled() -> bool:
|
||||
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
|
||||
return None
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = spec_dir.parent.parent
|
||||
|
||||
@@ -17,6 +17,7 @@ import logging
|
||||
from .ai_resolver import AIResolver
|
||||
from .auto_merger import AutoMerger, MergeContext
|
||||
from .file_merger import apply_ai_merge, extract_location_content
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .types import (
|
||||
ConflictRegion,
|
||||
ConflictSeverity,
|
||||
@@ -60,6 +61,7 @@ class ConflictResolver:
|
||||
baseline_content: str,
|
||||
task_snapshots: list[TaskSnapshot],
|
||||
conflicts: list[ConflictRegion],
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeResult:
|
||||
"""
|
||||
Resolve conflicts using AutoMerger and AIResolver.
|
||||
@@ -69,6 +71,8 @@ class ConflictResolver:
|
||||
baseline_content: Original file content
|
||||
task_snapshots: Snapshots from all tasks modifying this file
|
||||
conflicts: List of detected conflicts
|
||||
progress_callback: Optional callback for emitting per-conflict
|
||||
resolution progress with details about current file and conflict count
|
||||
|
||||
Returns:
|
||||
MergeResult with resolution details
|
||||
@@ -78,8 +82,23 @@ class ConflictResolver:
|
||||
remaining: list[ConflictRegion] = []
|
||||
ai_calls = 0
|
||||
tokens_used = 0
|
||||
total_conflicts = len(conflicts)
|
||||
|
||||
for conflict in conflicts:
|
||||
for idx, conflict in enumerate(conflicts):
|
||||
if progress_callback:
|
||||
# Emit per-conflict progress within the resolving stage (50-75%)
|
||||
# Calculate progress after processing (idx + 1) to reach 75% on last conflict
|
||||
conflict_percent = 50 + int(((idx + 1) / max(total_conflicts, 1)) * 25)
|
||||
progress_callback(
|
||||
stage=MergeProgressStage.RESOLVING,
|
||||
percent=conflict_percent,
|
||||
message=f"Resolving conflict {idx + 1}/{total_conflicts} in {file_path}",
|
||||
details={
|
||||
"current_file": file_path,
|
||||
"conflicts_found": total_conflicts,
|
||||
"conflicts_resolved": len(resolved),
|
||||
},
|
||||
)
|
||||
# Try auto-merge first
|
||||
if conflict.can_auto_merge and conflict.merge_strategy:
|
||||
context = MergeContext(
|
||||
|
||||
@@ -18,6 +18,7 @@ import logging
|
||||
from .conflict_detector import ConflictDetector
|
||||
from .conflict_resolver import ConflictResolver
|
||||
from .file_merger import apply_single_task_changes, combine_non_conflicting_changes
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .types import (
|
||||
ChangeType,
|
||||
FileAnalysis,
|
||||
@@ -57,6 +58,7 @@ class MergePipeline:
|
||||
file_path: str,
|
||||
baseline_content: str,
|
||||
task_snapshots: list[TaskSnapshot],
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeResult:
|
||||
"""
|
||||
Merge changes from multiple tasks for a single file.
|
||||
@@ -65,6 +67,8 @@ class MergePipeline:
|
||||
file_path: Path to the file
|
||||
baseline_content: Original baseline content
|
||||
task_snapshots: Snapshots from tasks that modified this file
|
||||
progress_callback: Optional callback for emitting per-file progress
|
||||
within the 'resolving' stage (50-75% range)
|
||||
|
||||
Returns:
|
||||
MergeResult with merged content or conflict info
|
||||
@@ -72,6 +76,14 @@ class MergePipeline:
|
||||
task_ids = [s.task_id for s in task_snapshots]
|
||||
logger.info(f"Merging {file_path} with {len(task_snapshots)} task(s)")
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
stage=MergeProgressStage.RESOLVING,
|
||||
percent=50,
|
||||
message=f"Merging file: {file_path}",
|
||||
details={"current_file": file_path},
|
||||
)
|
||||
|
||||
# If only one task modified the file, no conflict possible
|
||||
if len(task_snapshots) == 1:
|
||||
snapshot = task_snapshots[0]
|
||||
@@ -119,6 +131,7 @@ class MergePipeline:
|
||||
baseline_content=baseline_content,
|
||||
task_snapshots=task_snapshots,
|
||||
conflicts=conflicts,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def _build_task_analyses(
|
||||
|
||||
@@ -33,6 +33,7 @@ from .merge_pipeline import MergePipeline
|
||||
|
||||
# Re-export models for backwards compatibility
|
||||
from .models import MergeReport, MergeStats, TaskMergeRequest
|
||||
from .progress import MergeProgressCallback, MergeProgressStage
|
||||
from .semantic_analyzer import SemanticAnalyzer
|
||||
from .types import (
|
||||
ConflictRegion,
|
||||
@@ -260,6 +261,7 @@ class MergeOrchestrator:
|
||||
task_id: str,
|
||||
worktree_path: Path | None = None,
|
||||
target_branch: str = "main",
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeReport:
|
||||
"""
|
||||
Merge a single task's changes into the target branch.
|
||||
@@ -268,6 +270,8 @@ class MergeOrchestrator:
|
||||
task_id: The task identifier
|
||||
worktree_path: Path to the task's worktree (auto-detected if not provided)
|
||||
target_branch: Branch to merge into
|
||||
progress_callback: Optional callback for progress updates.
|
||||
Called with (stage, percent, message, details) at key pipeline stages.
|
||||
|
||||
Returns:
|
||||
MergeReport with results
|
||||
@@ -284,7 +288,20 @@ class MergeOrchestrator:
|
||||
report = MergeReport(started_at=datetime.now(), tasks_merged=[task_id])
|
||||
start_time = datetime.now()
|
||||
|
||||
def _emit(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emit progress if a callback is provided."""
|
||||
if progress_callback is not None:
|
||||
progress_callback(stage, percent, message, details)
|
||||
|
||||
try:
|
||||
# --- ANALYZING stage (0-25%) ---
|
||||
_emit(MergeProgressStage.ANALYZING, 0, "Starting merge analysis")
|
||||
|
||||
# Find worktree if not provided
|
||||
if worktree_path is None:
|
||||
debug_detailed(MODULE, "Auto-detecting worktree path...")
|
||||
@@ -293,16 +310,23 @@ class MergeOrchestrator:
|
||||
debug_error(MODULE, f"Could not find worktree for task {task_id}")
|
||||
report.success = False
|
||||
report.error = f"Could not find worktree for task {task_id}"
|
||||
_emit(
|
||||
MergeProgressStage.ERROR,
|
||||
0,
|
||||
f"Could not find worktree for task {task_id}",
|
||||
)
|
||||
return report
|
||||
debug_detailed(MODULE, f"Found worktree: {worktree_path}")
|
||||
|
||||
# Ensure evolution data is up to date
|
||||
_emit(MergeProgressStage.ANALYZING, 5, "Loading file evolution data")
|
||||
debug(MODULE, "Refreshing evolution data from git...")
|
||||
self.evolution_tracker.refresh_from_git(
|
||||
task_id, worktree_path, target_branch=target_branch
|
||||
)
|
||||
|
||||
# Get files modified by this task
|
||||
_emit(MergeProgressStage.ANALYZING, 15, "Running semantic analysis")
|
||||
modifications = self.evolution_tracker.get_task_modifications(task_id)
|
||||
debug(
|
||||
MODULE,
|
||||
@@ -312,11 +336,39 @@ class MergeOrchestrator:
|
||||
if not modifications:
|
||||
debug_warning(MODULE, f"No modifications found for task {task_id}")
|
||||
logger.info(f"No modifications found for task {task_id}")
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
"No modifications found",
|
||||
)
|
||||
report.completed_at = datetime.now()
|
||||
return report
|
||||
|
||||
# Process each modified file
|
||||
for file_path, snapshot in modifications:
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
25,
|
||||
f"Found {len(modifications)} modified files",
|
||||
)
|
||||
|
||||
# --- DETECTING_CONFLICTS stage (25-50%) ---
|
||||
_emit(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Detecting conflicts",
|
||||
)
|
||||
|
||||
# --- RESOLVING stage (50-75%) ---
|
||||
total_files = len(modifications)
|
||||
for idx, (file_path, snapshot) in enumerate(modifications):
|
||||
# Calculate progress after processing (idx + 1) to reach 75% on last file
|
||||
file_percent = 50 + int(((idx + 1) / max(total_files, 1)) * 25)
|
||||
_emit(
|
||||
MergeProgressStage.RESOLVING,
|
||||
file_percent,
|
||||
f"Merging file {idx + 1}/{total_files}",
|
||||
{"current_file": file_path},
|
||||
)
|
||||
|
||||
debug_detailed(
|
||||
MODULE,
|
||||
f"Processing file: {file_path}",
|
||||
@@ -349,13 +401,31 @@ class MergeOrchestrator:
|
||||
file=file_path,
|
||||
)
|
||||
|
||||
# --- VALIDATING stage (75-100%) ---
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
75,
|
||||
"Validating merge results",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
report.success = report.stats.files_failed == 0
|
||||
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
90,
|
||||
"Validation complete",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Merge failed for task {task_id}", error=str(e))
|
||||
logger.exception(f"Merge failed for task {task_id}")
|
||||
report.success = False
|
||||
report.error = str(e)
|
||||
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
|
||||
|
||||
report.completed_at = datetime.now()
|
||||
report.stats.duration_seconds = (
|
||||
@@ -366,6 +436,18 @@ class MergeOrchestrator:
|
||||
if not self.dry_run:
|
||||
self._save_report(report, task_id)
|
||||
|
||||
# --- COMPLETE stage (100%) ---
|
||||
if report.success:
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Merge complete for {task_id}",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
f"Merge complete for {task_id}",
|
||||
@@ -382,6 +464,7 @@ class MergeOrchestrator:
|
||||
self,
|
||||
requests: list[TaskMergeRequest],
|
||||
target_branch: str = "main",
|
||||
progress_callback: MergeProgressCallback | None = None,
|
||||
) -> MergeReport:
|
||||
"""
|
||||
Merge multiple tasks' changes.
|
||||
@@ -392,6 +475,8 @@ class MergeOrchestrator:
|
||||
Args:
|
||||
requests: List of merge requests (one per task)
|
||||
target_branch: Branch to merge into
|
||||
progress_callback: Optional callback for progress updates.
|
||||
Called with (stage, percent, message, details) at key pipeline stages.
|
||||
|
||||
Returns:
|
||||
MergeReport with combined results
|
||||
@@ -402,11 +487,33 @@ class MergeOrchestrator:
|
||||
)
|
||||
start_time = datetime.now()
|
||||
|
||||
def _emit(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emit progress if a callback is provided."""
|
||||
if progress_callback is not None:
|
||||
progress_callback(stage, percent, message, details)
|
||||
|
||||
try:
|
||||
# --- ANALYZING stage (0-25%) ---
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
0,
|
||||
f"Starting merge analysis for {len(requests)} tasks",
|
||||
)
|
||||
|
||||
# Sort by priority (higher first)
|
||||
requests = sorted(requests, key=lambda r: -r.priority)
|
||||
|
||||
# Refresh evolution data for all tasks
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
5,
|
||||
"Loading file evolution data",
|
||||
)
|
||||
for request in requests:
|
||||
if request.worktree_path and request.worktree_path.exists():
|
||||
self.evolution_tracker.refresh_from_git(
|
||||
@@ -416,11 +523,38 @@ class MergeOrchestrator:
|
||||
)
|
||||
|
||||
# Find all files modified by any task
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
15,
|
||||
"Running semantic analysis",
|
||||
)
|
||||
task_ids = [r.task_id for r in requests]
|
||||
file_tasks = self.evolution_tracker.get_files_modified_by_tasks(task_ids)
|
||||
|
||||
# Process each file
|
||||
for file_path, modifying_tasks in file_tasks.items():
|
||||
_emit(
|
||||
MergeProgressStage.ANALYZING,
|
||||
25,
|
||||
f"Found {len(file_tasks)} files to merge",
|
||||
)
|
||||
|
||||
# --- DETECTING_CONFLICTS stage (25-50%) ---
|
||||
_emit(
|
||||
MergeProgressStage.DETECTING_CONFLICTS,
|
||||
25,
|
||||
"Detecting conflicts across tasks",
|
||||
)
|
||||
|
||||
# --- RESOLVING stage (50-75%) ---
|
||||
total_files = len(file_tasks)
|
||||
for idx, (file_path, modifying_tasks) in enumerate(file_tasks.items()):
|
||||
file_percent = 50 + int((idx / max(total_files, 1)) * 25)
|
||||
_emit(
|
||||
MergeProgressStage.RESOLVING,
|
||||
file_percent,
|
||||
f"Merging file {idx + 1}/{total_files}",
|
||||
{"current_file": file_path},
|
||||
)
|
||||
|
||||
# Get snapshots from all tasks that modified this file
|
||||
evolution = self.evolution_tracker.get_file_evolution(file_path)
|
||||
if not evolution:
|
||||
@@ -466,8 +600,25 @@ class MergeOrchestrator:
|
||||
report.file_results[file_path] = result
|
||||
self._update_stats(report.stats, result)
|
||||
|
||||
# --- VALIDATING stage (75-100%) ---
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
75,
|
||||
"Validating merge results",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
report.success = report.stats.files_failed == 0
|
||||
|
||||
_emit(
|
||||
MergeProgressStage.VALIDATING,
|
||||
90,
|
||||
"Validation complete",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
MODULE,
|
||||
@@ -478,6 +629,7 @@ class MergeOrchestrator:
|
||||
logger.exception("Merge failed")
|
||||
report.success = False
|
||||
report.error = str(e)
|
||||
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
|
||||
|
||||
report.completed_at = datetime.now()
|
||||
report.stats.duration_seconds = (
|
||||
@@ -489,6 +641,18 @@ class MergeOrchestrator:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self._save_report(report, f"multi_{timestamp}")
|
||||
|
||||
# --- COMPLETE stage (100%) ---
|
||||
if report.success:
|
||||
_emit(
|
||||
MergeProgressStage.COMPLETE,
|
||||
100,
|
||||
f"Merge complete for {len(requests)} tasks",
|
||||
{
|
||||
"conflicts_found": report.stats.conflicts_detected,
|
||||
"conflicts_resolved": report.stats.conflicts_auto_resolved,
|
||||
},
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
def _merge_file(
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
Merge Progress Emission
|
||||
=======================
|
||||
|
||||
Structured progress event emission for the merge pipeline.
|
||||
|
||||
This module provides the progress reporting infrastructure used by the
|
||||
merge orchestrator to communicate real-time status updates to the
|
||||
Electron frontend via stdout JSON lines.
|
||||
|
||||
Progress events are emitted as JSON lines to stdout with type='progress',
|
||||
allowing the frontend to parse them separately from the final merge result.
|
||||
|
||||
Components:
|
||||
- MergeProgressStage: Enum of pipeline stages
|
||||
- MergeProgressCallback: Protocol for type-safe callback threading
|
||||
- emit_progress: Function to emit structured progress events to stdout
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class MergeProgressStage(Enum):
|
||||
"""
|
||||
Stages of the merge pipeline.
|
||||
|
||||
Each stage corresponds to a phase of the merge process and maps
|
||||
to a percentage range for progress reporting:
|
||||
- ANALYZING: 0-25% — Loading file evolution, running semantic analysis
|
||||
- DETECTING_CONFLICTS: 25-50% — Conflict detection and compatibility checks
|
||||
- RESOLVING: 50-75% — Auto-merge and AI resolution of conflicts
|
||||
- VALIDATING: 75-100% — Final validation of merged results
|
||||
- COMPLETE: 100% — Merge finished successfully
|
||||
- ERROR: N/A — Merge failed with an error
|
||||
"""
|
||||
|
||||
ANALYZING = "analyzing"
|
||||
DETECTING_CONFLICTS = "detecting_conflicts"
|
||||
RESOLVING = "resolving"
|
||||
VALIDATING = "validating"
|
||||
COMPLETE = "complete"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class MergeProgressCallback(Protocol):
|
||||
"""
|
||||
Protocol for type-safe progress callback threading.
|
||||
|
||||
Implementations receive structured progress updates from the merge
|
||||
pipeline stages and can forward them to any output channel.
|
||||
|
||||
Args:
|
||||
stage: Current pipeline stage
|
||||
percent: Progress percentage (0-100)
|
||||
message: Human-readable status message
|
||||
details: Optional additional context (conflicts_found, conflicts_resolved, current_file)
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
def emit_progress(
|
||||
stage: MergeProgressStage,
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Emit a progress event as a JSON line to stdout.
|
||||
|
||||
The Electron main process parses these JSON lines from the merge
|
||||
subprocess stdout and forwards them to the renderer via IPC.
|
||||
|
||||
Args:
|
||||
stage: Current pipeline stage
|
||||
percent: Progress percentage (0-100), clamped to valid range
|
||||
message: Human-readable status message
|
||||
details: Optional dict with additional context. Supported keys:
|
||||
- conflicts_found (int): Number of conflicts detected
|
||||
- conflicts_resolved (int): Number of conflicts resolved so far
|
||||
- current_file (str): File currently being processed
|
||||
"""
|
||||
percent = max(0, min(100, percent))
|
||||
|
||||
event: dict[str, Any] = {
|
||||
"type": "progress",
|
||||
"stage": stage.value,
|
||||
"percent": percent,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if details:
|
||||
event["details"] = details
|
||||
|
||||
print(json.dumps(event), flush=True)
|
||||
@@ -13,15 +13,58 @@ environment at the start of each prompt in the "YOUR ENVIRONMENT" section. Pay c
|
||||
|
||||
- **Working Directory**: This is your root - all paths are relative to here
|
||||
- **Spec Location**: Where your spec files live (usually `./auto-claude/specs/{spec-name}/`)
|
||||
- **Isolation Mode**: If present, you are in an isolated worktree (see below)
|
||||
|
||||
**RULES:**
|
||||
1. ALWAYS use relative paths starting with `./`
|
||||
2. NEVER use absolute paths (like `/Users/...`)
|
||||
2. NEVER use absolute paths (like `/Users/...` or `/e/projects/...`)
|
||||
3. NEVER assume paths exist - check with `ls` first
|
||||
4. If a file doesn't exist where expected, check the spec location from YOUR ENVIRONMENT section
|
||||
|
||||
---
|
||||
|
||||
## ⛔ WORKTREE ISOLATION (When Applicable)
|
||||
|
||||
If your environment shows **"Isolation Mode: WORKTREE"**, you are working in an **isolated git worktree**.
|
||||
This is a complete copy of the project created for safe, isolated development.
|
||||
|
||||
### Critical Rules for Worktree Mode:
|
||||
|
||||
1. **NEVER navigate to the parent project path** shown in "FORBIDDEN PATH"
|
||||
- If you see `cd /path/to/main/project` in your context, DO NOT run it
|
||||
- The parent project is OFF LIMITS
|
||||
|
||||
2. **All files exist locally via relative paths**
|
||||
- `./prod/...` ✅ CORRECT
|
||||
- `/path/to/main/project/prod/...` ❌ WRONG (escapes isolation)
|
||||
|
||||
3. **Git commits in the wrong location = disaster**
|
||||
- Commits made after escaping go to the WRONG branch
|
||||
- This defeats the entire isolation system
|
||||
|
||||
### Why You Might Be Tempted to Escape:
|
||||
|
||||
You may see absolute paths like `/e/projects/myapp/prod/src/file.ts` in:
|
||||
- `spec.md` (file references)
|
||||
- `context.json` (discovered files)
|
||||
- Error messages
|
||||
|
||||
**DO NOT** `cd` to these paths. Instead, convert them to relative paths:
|
||||
- `/e/projects/myapp/prod/src/file.ts` → `./prod/src/file.ts`
|
||||
|
||||
### Quick Check:
|
||||
|
||||
```bash
|
||||
# Verify you're still in the worktree
|
||||
pwd
|
||||
# Should show: .../.auto-claude/worktrees/tasks/{spec-name}/
|
||||
# Or (legacy): .../.worktrees/{spec-name}/
|
||||
# Or (PR review): .../.auto-claude/github/pr/worktrees/{pr-number}/
|
||||
# NOT: /path/to/main/project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
|
||||
|
||||
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
|
||||
@@ -84,65 +127,6 @@ git add [verified-path]
|
||||
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: WORKTREE ISOLATION 🚨
|
||||
|
||||
**You may be in an ISOLATED GIT WORKTREE environment.**
|
||||
|
||||
Check the "YOUR ENVIRONMENT" section at the top of this prompt. If you see an
|
||||
**"ISOLATED WORKTREE - CRITICAL"** section, you are in a worktree.
|
||||
|
||||
### What is a Worktree?
|
||||
|
||||
A worktree is a **complete copy of the project** isolated from the main project.
|
||||
This allows safe development without affecting the main branch.
|
||||
|
||||
### Worktree Rules (CRITICAL)
|
||||
|
||||
**If you are in a worktree, the environment section will show:**
|
||||
|
||||
* **YOUR LOCATION:** The path to your isolated worktree
|
||||
* **FORBIDDEN:** The parent project path you must NEVER `cd` to
|
||||
|
||||
**CRITICAL RULES:**
|
||||
* **NEVER** `cd` to the forbidden parent path
|
||||
* **NEVER** use `cd ../..` to escape the worktree
|
||||
* **STAY** within your working directory at all times
|
||||
* **ALL** file operations use paths relative to your current location
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Escaping the worktree causes:
|
||||
* ❌ Git commits going to the wrong branch
|
||||
* ❌ Files created/modified in the wrong location
|
||||
* ❌ Breaking worktree isolation guarantees
|
||||
* ❌ Losing the safety of isolated development
|
||||
|
||||
### How to Stay Safe
|
||||
|
||||
**Before ANY `cd` command:**
|
||||
|
||||
```bash
|
||||
# 1. Check where you are
|
||||
pwd
|
||||
|
||||
# 2. Verify the target is within your worktree
|
||||
# If pwd shows: /path/to/.auto-claude/worktrees/tasks/spec-name/
|
||||
# Then: cd ./apps/backend ✅ SAFE
|
||||
# But: cd /path/to/parent/project ❌ FORBIDDEN - ESCAPES ISOLATION
|
||||
|
||||
# 3. When in doubt, don't use cd at all
|
||||
# Use relative paths from your current directory instead
|
||||
git add ./apps/backend/file.py # Works from anywhere in worktree
|
||||
```
|
||||
|
||||
### The Golden Rule in Worktrees
|
||||
|
||||
**If you're in a worktree, pretend the parent project doesn't exist.**
|
||||
|
||||
Everything you need is in your worktree, accessible via relative paths.
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
First, check your environment. The prompt should tell you your working directory and spec location.
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
|
||||
|
||||
## CRITICAL: Tool Execution Strategy
|
||||
|
||||
**IMPORTANT: Execute tool calls ONE AT A TIME, waiting for each result before making the next call.**
|
||||
|
||||
When you need to use multiple tools (Read, Grep, Glob, Task):
|
||||
- ✅ Make ONE tool call, wait for the result
|
||||
- ✅ Process the result, then make the NEXT tool call
|
||||
- ❌ Do NOT make multiple tool calls in a single response
|
||||
|
||||
**Why this matters:** Parallel tool execution can cause API errors when some tools fail while others succeed. Sequential execution ensures reliable operation and proper error handling.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# PR Template Filler Agent
|
||||
|
||||
## Your Role
|
||||
|
||||
You are an expert developer filling out a GitHub Pull Request template. You receive the repository's PR template along with comprehensive context about the changes — git diff summary, spec overview, commit history, and branch information. Your job is to produce a complete, accurate PR body that matches the template structure exactly, with every section filled intelligently and every relevant checkbox checked.
|
||||
|
||||
## Input Context
|
||||
|
||||
You will receive:
|
||||
|
||||
1. **PR Template** — The repository's `.github/PULL_REQUEST_TEMPLATE.md` content
|
||||
2. **Git Diff Summary** — A summary of all code changes (files changed, insertions, deletions)
|
||||
3. **Spec Overview** — The specification document describing the feature/fix being implemented
|
||||
4. **Commit History** — The list of commits included in this PR
|
||||
5. **Branch Context** — Source branch name, target branch name
|
||||
|
||||
## Methodology
|
||||
|
||||
### Step 1: Understand the Changes
|
||||
|
||||
Before filling anything:
|
||||
|
||||
1. **Read the spec overview** to understand the purpose and scope of the work
|
||||
2. **Analyze the diff summary** to identify what files changed and what kind of changes were made
|
||||
3. **Review the commit history** to understand the progression of work
|
||||
4. **Note the branch names** to infer the PR target and type of change
|
||||
|
||||
### Step 2: Fill Every Section
|
||||
|
||||
For each section in the template:
|
||||
|
||||
1. **Identify the section type** — Is it a description field, a checkbox list, a free-text area, or a conditional section?
|
||||
2. **Select the appropriate content** based on the change context
|
||||
3. **Be specific and accurate** — Reference actual files, components, and behaviors from the diff
|
||||
4. **Never leave a section empty** — If a section is not applicable, explicitly state "N/A" or "Not applicable"
|
||||
|
||||
### Step 3: Check Appropriate Checkboxes
|
||||
|
||||
For checkbox lists (`- [ ]` items):
|
||||
|
||||
1. **Check boxes that apply** by changing `- [ ]` to `- [x]`
|
||||
2. **Leave unchecked** boxes that don't apply
|
||||
3. **Base decisions on evidence** from the diff and spec, not assumptions
|
||||
4. **When uncertain**, leave unchecked rather than incorrectly checking
|
||||
|
||||
### Step 4: Validate Output
|
||||
|
||||
Before returning:
|
||||
|
||||
1. **Verify markdown structure** matches the template exactly (same headings, same order)
|
||||
2. **Ensure no template placeholders remain** (no `<!-- comments -->` left unfilled where content is expected)
|
||||
3. **Check that descriptions are concise** but informative (2-3 sentences for summaries)
|
||||
4. **Confirm all checkboxes reflect reality** based on the provided context
|
||||
|
||||
## Section-Specific Guidelines
|
||||
|
||||
### Description Sections
|
||||
|
||||
- Write 2-3 clear sentences explaining what the PR does and why
|
||||
- Reference the spec or task if available
|
||||
- Focus on the "what" and "why", not implementation details
|
||||
|
||||
### Type of Change
|
||||
|
||||
- Determine from the spec and diff whether this is a bug fix, feature, refactor, docs, or test change
|
||||
- Check exactly one type unless the PR genuinely spans multiple types
|
||||
- Use the spec's `workflow_type` field as a strong signal
|
||||
|
||||
### Area / Service
|
||||
|
||||
- Analyze which directories were modified in the diff
|
||||
- `frontend` = changes in `apps/frontend/`
|
||||
- `backend` = changes in `apps/backend/`
|
||||
- `fullstack` = changes in both
|
||||
|
||||
### Related Issues
|
||||
|
||||
- Extract issue numbers from branch names (e.g., `feature/123-description` → `#123`)
|
||||
- Extract from spec metadata if available
|
||||
- Use `Closes #N` format for issues that will be closed by this PR
|
||||
|
||||
### Checklists
|
||||
|
||||
- **Testing checklists**: Check items that the commit history and diff evidence support
|
||||
- **Platform checklists**: Check platforms that CI covers; note if manual testing is needed
|
||||
- **Code quality checklists**: Check if the diff shows adherence to the principles mentioned
|
||||
|
||||
### AI Disclosure
|
||||
|
||||
- Always check the AI disclosure box — this PR is generated by Auto Claude
|
||||
- Set tool to "Auto Claude (Claude Agent SDK)"
|
||||
- Set testing level based on whether QA was run (check spec context for QA status)
|
||||
- Always check "I understand what this PR does" — the AI agent analyzed the changes
|
||||
|
||||
### Screenshots
|
||||
|
||||
- If the diff includes UI changes (frontend components, styles), note that screenshots should be added
|
||||
- If no UI changes, write "N/A - No UI changes" or remove the section if the template allows
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Analyze the diff for API changes, removed exports, changed interfaces, or modified database schemas
|
||||
- If no breaking changes are evident, mark as "No"
|
||||
- If breaking changes exist, describe what breaks and suggest migration steps
|
||||
|
||||
### Feature Toggle
|
||||
|
||||
- Check the spec for mentions of feature flags, localStorage flags, or environment variables
|
||||
- If the feature is complete and ready, check "N/A - Feature is complete and ready for all users"
|
||||
|
||||
## Output Format
|
||||
|
||||
Return **only** the filled PR template as valid markdown. Do not include any preamble, explanation, or wrapper — just the completed template content ready to be used as a GitHub PR body.
|
||||
|
||||
## Quality Standards
|
||||
|
||||
1. **Accuracy over completeness** — It's better to leave a checkbox unchecked than to incorrectly check it
|
||||
2. **Evidence-based** — Every filled section should be traceable to the provided context
|
||||
3. **Professional tone** — Write as a senior developer would in a real PR
|
||||
4. **Concise but informative** — Don't pad sections with filler text
|
||||
5. **Valid markdown** — The output must render correctly on GitHub
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### DO NOT:
|
||||
|
||||
- **Invent information** not present in the provided context
|
||||
- **Leave template placeholders** like `<!-- What does this PR do? -->` without replacing them with actual content
|
||||
- **Check every checkbox** — only check those supported by evidence
|
||||
- **Write vague descriptions** like "This PR makes some changes" — be specific
|
||||
- **Add sections** not present in the original template
|
||||
- **Remove sections** from the original template — fill or mark as N/A
|
||||
- **Hallucinate file names** or components not mentioned in the diff
|
||||
- **Guess issue numbers** — only reference issues you can confirm from the branch name or spec
|
||||
|
||||
---
|
||||
|
||||
Remember: Your output becomes the PR body on GitHub. It should be professional, accurate, and immediately useful for reviewers. Every section should help a reviewer understand what changed, why it changed, and what to look for during review.
|
||||
@@ -159,7 +159,7 @@ This allows safe development without affecting the main branch.
|
||||
**If you are in a worktree, the environment section will show:**
|
||||
|
||||
* **YOUR LOCATION:** The path to your isolated worktree
|
||||
* **FORBIDDEN:** The parent project path you must NEVER `cd` to
|
||||
* **FORBIDDEN PATH:** The parent project path you must NEVER `cd` to
|
||||
|
||||
**CRITICAL RULES:**
|
||||
* **NEVER** `cd` to the forbidden parent path
|
||||
|
||||
@@ -13,40 +13,103 @@ This approach:
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Worktree path patterns for detection
|
||||
# Matches paths like: .auto-claude/worktrees/tasks/{spec-name}/
|
||||
WORKTREE_PATH_PATTERNS = [
|
||||
r"[/\\]\.auto-claude[/\\]worktrees[/\\]tasks[/\\]",
|
||||
r"[/\\]\.auto-claude[/\\]github[/\\]pr[/\\]worktrees[/\\]", # PR review worktrees
|
||||
r"[/\\]\.worktrees[/\\]", # Legacy worktree location
|
||||
]
|
||||
|
||||
def detect_worktree_mode(spec_dir: Path) -> tuple[bool, str | None]:
|
||||
|
||||
def detect_worktree_isolation(project_dir: Path) -> tuple[bool, Path | None]:
|
||||
"""
|
||||
Detect if running in isolated worktree mode.
|
||||
Detect if the project_dir is inside an isolated worktree.
|
||||
|
||||
When running in a worktree, the agent should NOT escape to the parent project.
|
||||
This function detects worktree mode and extracts the forbidden parent path.
|
||||
|
||||
Args:
|
||||
spec_dir: Absolute path to spec directory
|
||||
project_dir: The working directory for the AI
|
||||
|
||||
Returns:
|
||||
(is_worktree, forbidden_parent_path) tuple:
|
||||
- is_worktree: True if running in a worktree
|
||||
- forbidden_parent_path: The parent project path to forbid, or None
|
||||
Tuple of (is_worktree, parent_project_path)
|
||||
- is_worktree: True if running in an isolated worktree
|
||||
- parent_project_path: The forbidden parent project path (None if not in worktree)
|
||||
"""
|
||||
# Check if spec_dir contains worktree path patterns
|
||||
# Normalize path separators to forward slashes for consistent matching
|
||||
spec_str = str(spec_dir).replace("\\", "/")
|
||||
# Resolve the path first for consistent matching across platforms
|
||||
# This handles Windows drive letters, symlinks, and relative paths
|
||||
resolved_dir = project_dir.resolve()
|
||||
project_str = str(resolved_dir)
|
||||
|
||||
# New worktree location: .auto-claude/worktrees/tasks/{spec-name}/
|
||||
new_worktree_marker = "/.auto-claude/worktrees/tasks/"
|
||||
if new_worktree_marker in spec_str:
|
||||
parent_path = spec_str.split(new_worktree_marker, 1)[0]
|
||||
return True, parent_path
|
||||
|
||||
# Legacy worktree location: .worktrees/{spec-name}/
|
||||
legacy_worktree_marker = "/.worktrees/"
|
||||
if legacy_worktree_marker in spec_str:
|
||||
parent_path = spec_str.split(legacy_worktree_marker, 1)[0]
|
||||
return True, parent_path
|
||||
for pattern in WORKTREE_PATH_PATTERNS:
|
||||
match = re.search(pattern, project_str)
|
||||
if match:
|
||||
# Extract the parent project path (everything before the worktree marker)
|
||||
parent_path = project_str[: match.start()]
|
||||
# Handle edge case where worktree is at filesystem root
|
||||
if not parent_path:
|
||||
parent_path = resolved_dir.anchor
|
||||
return True, Path(parent_path)
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
def generate_worktree_isolation_warning(
|
||||
project_dir: Path, parent_project_path: Path
|
||||
) -> str:
|
||||
"""
|
||||
Generate the worktree isolation warning section for prompts.
|
||||
|
||||
This warning explicitly tells the agent that it's in an isolated worktree
|
||||
and must NOT escape to the parent project directory.
|
||||
|
||||
Args:
|
||||
project_dir: The worktree directory (agent's working directory)
|
||||
parent_project_path: The forbidden parent project path
|
||||
|
||||
Returns:
|
||||
Markdown string with isolation warning
|
||||
"""
|
||||
return f"""## ⛔ ISOLATED WORKTREE - CRITICAL
|
||||
|
||||
You are in an **ISOLATED GIT WORKTREE** - a complete copy of the project for safe development.
|
||||
|
||||
**YOUR LOCATION:** `{project_dir}`
|
||||
**FORBIDDEN PATH:** `{parent_project_path}`
|
||||
|
||||
### Rules:
|
||||
1. **NEVER** use `cd {parent_project_path}` or any path starting with `{parent_project_path}`
|
||||
2. **NEVER** use absolute paths that reference the parent project
|
||||
3. **ALL** project files exist HERE via relative paths
|
||||
|
||||
### Why This Matters:
|
||||
- Git commits made in the parent project go to the WRONG branch
|
||||
- File changes in the parent project escape isolation
|
||||
- This defeats the entire purpose of safe, isolated development
|
||||
|
||||
### Correct Usage:
|
||||
```bash
|
||||
# ✅ CORRECT - Use relative paths from your worktree
|
||||
./prod/src/file.ts
|
||||
./apps/frontend/src/component.tsx
|
||||
|
||||
# ❌ WRONG - These escape isolation!
|
||||
cd {parent_project_path}
|
||||
{parent_project_path}/prod/src/file.ts
|
||||
```
|
||||
|
||||
If you see absolute paths in spec.md or context.json that reference `{parent_project_path}`,
|
||||
convert them to relative paths from YOUR current location.
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_relative_spec_path(spec_dir: Path, project_dir: Path) -> str:
|
||||
"""
|
||||
Get the spec directory path relative to the project/working directory.
|
||||
@@ -75,6 +138,7 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
|
||||
Generate environment context header for prompts.
|
||||
|
||||
This explicitly tells the AI where it is working, preventing path confusion.
|
||||
When running in a worktree, includes an isolation warning to prevent escaping.
|
||||
|
||||
Args:
|
||||
project_dir: The working directory for the AI
|
||||
@@ -85,14 +149,21 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
|
||||
"""
|
||||
relative_spec = get_relative_spec_path(spec_dir, project_dir)
|
||||
|
||||
# Detect worktree mode and get forbidden parent path
|
||||
is_worktree, forbidden_parent = detect_worktree_mode(spec_dir)
|
||||
# Check if we're in an isolated worktree
|
||||
is_worktree, parent_project_path = detect_worktree_isolation(project_dir)
|
||||
|
||||
# Build the environment context
|
||||
context = f"""## YOUR ENVIRONMENT
|
||||
# Start with worktree isolation warning if applicable
|
||||
sections = []
|
||||
if is_worktree and parent_project_path:
|
||||
sections.append(
|
||||
generate_worktree_isolation_warning(project_dir, parent_project_path)
|
||||
)
|
||||
|
||||
sections.append(f"""## YOUR ENVIRONMENT
|
||||
|
||||
**Working Directory:** `{project_dir}`
|
||||
**Spec Location:** `{relative_spec}/`
|
||||
{"**Isolation Mode:** WORKTREE (changes are isolated from main project)" if is_worktree else ""}
|
||||
|
||||
Your filesystem is restricted to your working directory. All file paths should be
|
||||
relative to this location. Do NOT use absolute paths.
|
||||
@@ -110,35 +181,9 @@ coder prompt for detailed examples.
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
""")
|
||||
|
||||
# Add worktree isolation warning if in worktree mode
|
||||
if is_worktree and forbidden_parent:
|
||||
context += f"""## 🚨 ISOLATED WORKTREE - CRITICAL
|
||||
|
||||
You are in an **ISOLATED GIT WORKTREE** - a complete copy of the project.
|
||||
|
||||
**YOUR LOCATION:** `{project_dir}`
|
||||
**FORBIDDEN:** Do NOT use `cd {forbidden_parent}` or `cd ../..` - this **ESCAPES ISOLATION**
|
||||
|
||||
All project files exist HERE via relative paths from your working directory.
|
||||
|
||||
**CRITICAL RULES:**
|
||||
* **NEVER** `cd {forbidden_parent}` or any path traversal outside your worktree
|
||||
* **STAY** within your working directory at all times
|
||||
* **ALL** file operations use paths relative to your current location
|
||||
* Before any `cd` command, run `pwd` and verify the target is within your worktree
|
||||
|
||||
**VIOLATION WARNING:** Escaping the worktree will cause:
|
||||
* Git commits going to wrong branch
|
||||
* Files created/modified in the wrong location
|
||||
* Breaking worktree isolation guarantees
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
return context
|
||||
return "".join(sections)
|
||||
|
||||
|
||||
def generate_subtask_prompt(
|
||||
|
||||
@@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_use_local_branch_from_metadata(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Read useLocalBranch from task_metadata.json if it exists.
|
||||
|
||||
When True, the worktree should be created from the local branch directly
|
||||
instead of preferring origin/branch. This preserves gitignored files
|
||||
(.env, configs) that may not exist on the remote.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
True if useLocalBranch is set in metadata, False otherwise
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
return bool(metadata.get("useLocalBranch", False))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# Alias for backwards compatibility (internal use)
|
||||
_get_base_branch_from_metadata = get_base_branch_from_metadata
|
||||
|
||||
|
||||
+603
-531
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
# Auto-Build Framework Dependencies
|
||||
# SDK 0.1.22+ required for custom subagent support (bundles CLI 2.1.19+)
|
||||
# Earlier versions bundled CLI 2.1.9 which didn't properly register --agents flag
|
||||
claude-agent-sdk>=0.1.22
|
||||
# SDK 0.1.25+ required for improved tool use concurrency handling
|
||||
# Earlier versions had 400 errors when tool_use blocks had partial failures
|
||||
claude-agent-sdk>=0.1.25
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# TOML parsing fallback for Python < 3.11
|
||||
|
||||
@@ -10,6 +10,8 @@ Key Features:
|
||||
- Skips re-reviewing bot commits
|
||||
- Implements "cooling off" period to prevent rapid re-reviews
|
||||
- Tracks reviewed commits to avoid duplicate reviews
|
||||
- In-progress tracking to prevent concurrent reviews
|
||||
- Stale review detection with automatic cleanup
|
||||
|
||||
Usage:
|
||||
detector = BotDetector(bot_token="ghp_...")
|
||||
@@ -20,8 +22,16 @@ Usage:
|
||||
print(f"Skipping PR: {reason}")
|
||||
return
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
detector.mark_review_started(pr_number)
|
||||
|
||||
# Perform review...
|
||||
|
||||
# After successful review, mark as reviewed
|
||||
detector.mark_reviewed(pr_number, head_sha)
|
||||
|
||||
# Or if review failed:
|
||||
detector.mark_review_finished(pr_number, success=False)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -55,11 +65,15 @@ class BotDetectionState:
|
||||
# PR number -> last review timestamp (ISO format)
|
||||
last_review_times: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
# PR number -> in-progress review start time (ISO format)
|
||||
in_progress_reviews: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
"reviewed_commits": self.reviewed_commits,
|
||||
"last_review_times": self.last_review_times,
|
||||
"in_progress_reviews": self.in_progress_reviews,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -68,6 +82,7 @@ class BotDetectionState:
|
||||
return cls(
|
||||
reviewed_commits=data.get("reviewed_commits", {}),
|
||||
last_review_times=data.get("last_review_times", {}),
|
||||
in_progress_reviews=data.get("in_progress_reviews", {}),
|
||||
)
|
||||
|
||||
def save(self, state_dir: Path) -> None:
|
||||
@@ -104,11 +119,16 @@ class BotDetector:
|
||||
- 1-minute cooling off period between reviews of same PR (for testing)
|
||||
- Tracks reviewed commit SHAs to avoid duplicate reviews
|
||||
- Identifies bot user from token to skip bot-authored content
|
||||
- In-progress tracking to prevent concurrent reviews
|
||||
- Stale review detection (30-minute timeout)
|
||||
"""
|
||||
|
||||
# Cooling off period in minutes (reduced to 1 for testing large PRs)
|
||||
COOLING_OFF_MINUTES = 1
|
||||
|
||||
# Timeout for in-progress reviews in minutes (after this, review is considered stale/crashed)
|
||||
IN_PROGRESS_TIMEOUT_MINUTES = 30
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_dir: Path,
|
||||
@@ -301,6 +321,104 @@ class BotDetector:
|
||||
reviewed = self.state.reviewed_commits.get(str(pr_number), [])
|
||||
return commit_sha in reviewed
|
||||
|
||||
def is_review_in_progress(self, pr_number: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if a review is currently in progress for this PR.
|
||||
|
||||
Also detects stale reviews (started > IN_PROGRESS_TIMEOUT_MINUTES ago).
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
|
||||
Returns:
|
||||
Tuple of (is_in_progress, reason_message)
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
start_time_str = self.state.in_progress_reviews.get(pr_key)
|
||||
|
||||
if not start_time_str:
|
||||
return False, ""
|
||||
|
||||
try:
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
time_elapsed = datetime.now() - start_time
|
||||
|
||||
# Check if review is stale (timeout exceeded)
|
||||
if time_elapsed > timedelta(minutes=self.IN_PROGRESS_TIMEOUT_MINUTES):
|
||||
# Mark as stale and clear the in-progress state
|
||||
print(
|
||||
f"[BotDetector] Review for PR #{pr_number} is stale "
|
||||
f"(started {int(time_elapsed.total_seconds() / 60)}m ago, "
|
||||
f"timeout: {self.IN_PROGRESS_TIMEOUT_MINUTES}m) - clearing in-progress state",
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.mark_review_finished(pr_number, success=False)
|
||||
return False, ""
|
||||
|
||||
# Review is actively in progress
|
||||
minutes_elapsed = int(time_elapsed.total_seconds() / 60)
|
||||
reason = f"Review already in progress (started {minutes_elapsed}m ago)"
|
||||
print(f"[BotDetector] PR #{pr_number}: {reason}", file=sys.stderr)
|
||||
return True, reason
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
print(
|
||||
f"[BotDetector] Error parsing in-progress start time: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Clear invalid state
|
||||
self.mark_review_finished(pr_number, success=False)
|
||||
return False, ""
|
||||
|
||||
def mark_review_started(self, pr_number: int) -> None:
|
||||
"""
|
||||
Mark a review as started for this PR.
|
||||
|
||||
This should be called when beginning a review to prevent concurrent reviews.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
|
||||
# Record start time
|
||||
self.state.in_progress_reviews[pr_key] = datetime.now().isoformat()
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
logger.info(f"[BotDetector] Marked PR #{pr_number} review as started")
|
||||
print(f"[BotDetector] Started review for PR #{pr_number}", file=sys.stderr)
|
||||
|
||||
def mark_review_finished(self, pr_number: int, success: bool = True) -> None:
|
||||
"""
|
||||
Mark a review as finished for this PR.
|
||||
|
||||
This clears the in-progress state. Should be called when review completes
|
||||
(successfully or with error) or when detected as stale.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
success: Whether the review completed successfully
|
||||
"""
|
||||
pr_key = str(pr_number)
|
||||
|
||||
# Clear in-progress state
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
status = "successfully" if success else "with error/timeout"
|
||||
logger.info(
|
||||
f"[BotDetector] Marked PR #{pr_number} review as finished ({status})"
|
||||
)
|
||||
print(
|
||||
f"[BotDetector] Finished review for PR #{pr_number} ({status})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def should_skip_pr_review(
|
||||
self,
|
||||
pr_number: int,
|
||||
@@ -335,13 +453,19 @@ class BotDetector:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 3: Are we in the cooling off period?
|
||||
# Check 3: Is a review already in progress?
|
||||
is_in_progress, reason = self.is_review_in_progress(pr_number)
|
||||
if is_in_progress:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 4: Are we in the cooling off period?
|
||||
is_cooling, reason = self.is_within_cooling_off(pr_number)
|
||||
if is_cooling:
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
return True, reason
|
||||
|
||||
# Check 4: Have we already reviewed this exact commit?
|
||||
# Check 5: Have we already reviewed this exact commit?
|
||||
head_sha = self.get_last_commit_sha(commits) if commits else None
|
||||
if head_sha and self.has_reviewed_commit(pr_number, head_sha):
|
||||
reason = f"Already reviewed commit {head_sha[:8]}"
|
||||
@@ -357,6 +481,7 @@ class BotDetector:
|
||||
Mark a PR as reviewed at a specific commit.
|
||||
|
||||
This should be called after successfully posting a review.
|
||||
Also clears the in-progress state.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number
|
||||
@@ -374,6 +499,10 @@ class BotDetector:
|
||||
# Update last review time
|
||||
self.state.last_review_times[pr_key] = datetime.now().isoformat()
|
||||
|
||||
# Clear in-progress state
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
# Save state
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
@@ -397,6 +526,9 @@ class BotDetector:
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
self.state.save(self.state_dir)
|
||||
|
||||
print(f"[BotDetector] Cleared state for PR #{pr_number}")
|
||||
@@ -412,13 +544,16 @@ class BotDetector:
|
||||
total_reviews = sum(
|
||||
len(commits) for commits in self.state.reviewed_commits.values()
|
||||
)
|
||||
in_progress_count = len(self.state.in_progress_reviews)
|
||||
|
||||
return {
|
||||
"bot_username": self.bot_username,
|
||||
"review_own_prs": self.review_own_prs,
|
||||
"total_prs_tracked": total_prs,
|
||||
"total_reviews_performed": total_reviews,
|
||||
"in_progress_reviews": in_progress_count,
|
||||
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
|
||||
"in_progress_timeout_minutes": self.IN_PROGRESS_TIMEOUT_MINUTES,
|
||||
}
|
||||
|
||||
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
|
||||
@@ -428,6 +563,9 @@ class BotDetector:
|
||||
This prevents unbounded growth of the state file by cleaning up
|
||||
entries for PRs that are likely closed/merged.
|
||||
|
||||
Also cleans up stale in-progress reviews (reviews that have been
|
||||
in progress for longer than IN_PROGRESS_TIMEOUT_MINUTES).
|
||||
|
||||
Args:
|
||||
max_age_days: Remove PRs not reviewed in this many days (default: 30)
|
||||
|
||||
@@ -435,8 +573,13 @@ class BotDetector:
|
||||
Number of PRs cleaned up
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=max_age_days)
|
||||
in_progress_cutoff = datetime.now() - timedelta(
|
||||
minutes=self.IN_PROGRESS_TIMEOUT_MINUTES
|
||||
)
|
||||
prs_to_remove: list[str] = []
|
||||
stale_in_progress: list[str] = []
|
||||
|
||||
# Find stale reviewed PRs
|
||||
for pr_key, last_review_str in self.state.last_review_times.items():
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
@@ -446,18 +589,43 @@ class BotDetector:
|
||||
# Invalid timestamp - mark for removal
|
||||
prs_to_remove.append(pr_key)
|
||||
|
||||
# Find stale in-progress reviews
|
||||
for pr_key, start_time_str in self.state.in_progress_reviews.items():
|
||||
try:
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
if start_time < in_progress_cutoff:
|
||||
stale_in_progress.append(pr_key)
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp - mark for removal
|
||||
stale_in_progress.append(pr_key)
|
||||
|
||||
# Remove stale PRs
|
||||
for pr_key in prs_to_remove:
|
||||
if pr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[pr_key]
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
if prs_to_remove:
|
||||
# Remove stale in-progress reviews
|
||||
for pr_key in stale_in_progress:
|
||||
if pr_key in self.state.in_progress_reviews:
|
||||
del self.state.in_progress_reviews[pr_key]
|
||||
|
||||
total_cleaned = len(prs_to_remove) + len(stale_in_progress)
|
||||
|
||||
if total_cleaned > 0:
|
||||
self.state.save(self.state_dir)
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
if prs_to_remove:
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
if stale_in_progress:
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(stale_in_progress)} stale in-progress reviews "
|
||||
f"(older than {self.IN_PROGRESS_TIMEOUT_MINUTES} minutes)"
|
||||
)
|
||||
|
||||
return len(prs_to_remove)
|
||||
return total_cleaned
|
||||
|
||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
||||
import ast
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -825,41 +824,11 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files related to the changes.
|
||||
|
||||
This includes:
|
||||
- Test files for changed source files
|
||||
- Imported modules and dependencies
|
||||
- Configuration files in the same directory
|
||||
- Related type definition files
|
||||
- Reverse dependencies (files that import changed files)
|
||||
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
|
||||
This method returns an empty list - agents have domain expertise to find what's relevant.
|
||||
"""
|
||||
related = set()
|
||||
|
||||
for changed_file in changed_files:
|
||||
path = Path(changed_file.path)
|
||||
|
||||
# Find test files
|
||||
related.update(self._find_test_files(path))
|
||||
|
||||
# Find imported files (for supported languages)
|
||||
if path.suffix in [".ts", ".tsx", ".js", ".jsx", ".py"]:
|
||||
related.update(self._find_imports(changed_file.content, path))
|
||||
|
||||
# Find config files in same directory
|
||||
related.update(self._find_config_files(path.parent))
|
||||
|
||||
# Find type definition files
|
||||
if path.suffix in [".ts", ".tsx"]:
|
||||
related.update(self._find_type_definitions(path))
|
||||
|
||||
# Find reverse dependencies (files that import this file)
|
||||
related.update(self._find_dependents(changed_file.path))
|
||||
|
||||
# Remove files that are already in changed_files
|
||||
changed_paths = {cf.path for cf in changed_files}
|
||||
related = {r for r in related if r not in changed_paths}
|
||||
|
||||
# Use smart prioritization with increased limit (50 instead of 20)
|
||||
return self._prioritize_related_files(related, limit=50)
|
||||
# Return empty list - LLM agents will discover files via their tools
|
||||
return []
|
||||
|
||||
def _find_test_files(self, source_path: Path) -> set[str]:
|
||||
"""Find test files related to a source file."""
|
||||
@@ -1071,170 +1040,35 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files that import the given file (reverse dependencies).
|
||||
|
||||
Uses pure Python to search for import statements referencing this file.
|
||||
Cross-platform compatible (Windows, macOS, Linux).
|
||||
Limited to prevent performance issues on large codebases.
|
||||
DEPRECATED: LLM agents now discover reverse dependencies themselves using Grep and Read tools.
|
||||
Returns empty set - agents can search the codebase with their domain expertise.
|
||||
|
||||
Args:
|
||||
file_path: Path of the file to find dependents for
|
||||
max_results: Maximum number of dependents to return
|
||||
|
||||
Returns:
|
||||
Set of file paths that import this file.
|
||||
Empty set - LLM agents will discover dependents via Grep tool.
|
||||
"""
|
||||
dependents: set[str] = set()
|
||||
path_obj = Path(file_path)
|
||||
stem = path_obj.stem # e.g., 'helpers' from 'utils/helpers.ts'
|
||||
|
||||
# NOTE: We no longer skip generic filenames like "utils", "types", "index".
|
||||
# The LLM-driven exploration system decides what's relevant based on the PR context.
|
||||
# Widely-used utilities are often the MOST important files to track dependents for.
|
||||
|
||||
# Build regex patterns and file extensions based on file type
|
||||
pattern = None
|
||||
file_extensions = []
|
||||
|
||||
if path_obj.suffix in [".ts", ".tsx", ".js", ".jsx"]:
|
||||
# Match various import styles for JS/TS
|
||||
# from './helpers', from '../utils/helpers', from '@/utils/helpers'
|
||||
# Escape stem for regex safety
|
||||
escaped_stem = re.escape(stem)
|
||||
pattern = re.compile(rf"['\"].*{escaped_stem}['\"]")
|
||||
file_extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
elif path_obj.suffix == ".py":
|
||||
# Match Python imports: from .helpers import, import helpers
|
||||
escaped_stem = re.escape(stem)
|
||||
pattern = re.compile(rf"(from.*{escaped_stem}|import.*{escaped_stem})")
|
||||
file_extensions = [".py"]
|
||||
else:
|
||||
return dependents
|
||||
|
||||
# Directories to exclude
|
||||
exclude_dirs = {
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
}
|
||||
|
||||
# Walk the project directory
|
||||
project_path = Path(self.project_dir)
|
||||
files_checked = 0
|
||||
max_files_to_check = 2000 # Prevent infinite scanning on large codebases
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(project_path):
|
||||
# Modify dirs in-place to exclude certain directories
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
for filename in files:
|
||||
# Check if we've hit the file limit
|
||||
if files_checked >= max_files_to_check:
|
||||
safe_print(
|
||||
f"[Context] File scan limit ({max_files_to_check}) reached for {file_path}. "
|
||||
f"Found {len(dependents)} dependents. "
|
||||
f"LLM agents can explore additional callers if needed via Read/Grep tools."
|
||||
)
|
||||
return dependents
|
||||
|
||||
# Check if file has the right extension
|
||||
if not any(filename.endswith(ext) for ext in file_extensions):
|
||||
continue
|
||||
|
||||
file_full_path = Path(root) / filename
|
||||
files_checked += 1
|
||||
|
||||
# Get relative path from project root
|
||||
try:
|
||||
relative_path = file_full_path.relative_to(project_path)
|
||||
relative_path_str = str(relative_path).replace("\\", "/")
|
||||
|
||||
# Don't include the file itself
|
||||
if relative_path_str == file_path:
|
||||
continue
|
||||
|
||||
# Search for the pattern in the file
|
||||
try:
|
||||
with open(
|
||||
file_full_path, encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
content = f.read()
|
||||
if pattern.search(content):
|
||||
dependents.add(relative_path_str)
|
||||
if len(dependents) >= max_results:
|
||||
return dependents
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Skip files that can't be read
|
||||
continue
|
||||
|
||||
except ValueError:
|
||||
# File is not relative to project_path, skip it
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
safe_print(f"[Context] Error finding dependents: {e}")
|
||||
|
||||
return dependents
|
||||
# Return empty set - LLM agents will use Grep to find importers when needed
|
||||
return set()
|
||||
|
||||
def _prioritize_related_files(self, files: set[str], limit: int = 50) -> list[str]:
|
||||
"""
|
||||
Prioritize related files by relevance.
|
||||
|
||||
Priority order:
|
||||
1. Test files (most important for review context)
|
||||
2. Type definition files (.d.ts)
|
||||
3. Configuration files
|
||||
4. Direct imports/dependents
|
||||
5. Other files
|
||||
DEPRECATED: LLM agents now prioritize exploration based on their domain expertise.
|
||||
Returns empty list since _find_related_files no longer populates files.
|
||||
|
||||
Args:
|
||||
files: Set of file paths to prioritize
|
||||
limit: Maximum number of files to return
|
||||
|
||||
Returns:
|
||||
List of files sorted by priority, limited to `limit`.
|
||||
Empty list - LLM agents handle prioritization via their tools.
|
||||
"""
|
||||
test_files = []
|
||||
type_files = []
|
||||
config_files = []
|
||||
other_files = []
|
||||
|
||||
for f in files:
|
||||
path = Path(f)
|
||||
name_lower = path.name.lower()
|
||||
|
||||
# Test files
|
||||
if (
|
||||
".test." in name_lower
|
||||
or ".spec." in name_lower
|
||||
or name_lower.startswith("test_")
|
||||
or name_lower.endswith("_test.py")
|
||||
or "__tests__" in f
|
||||
):
|
||||
test_files.append(f)
|
||||
# Type definition files
|
||||
elif name_lower.endswith(".d.ts") or "types" in name_lower:
|
||||
type_files.append(f)
|
||||
# Config files
|
||||
elif name_lower in [
|
||||
n.lower() for n in CONFIG_FILE_NAMES
|
||||
] or name_lower.endswith((".config.js", ".config.ts", "rc", "rc.json")):
|
||||
config_files.append(f)
|
||||
else:
|
||||
other_files.append(f)
|
||||
|
||||
# Sort within each category alphabetically for consistency, then combine
|
||||
prioritized = (
|
||||
sorted(test_files)
|
||||
+ sorted(type_files)
|
||||
+ sorted(config_files)
|
||||
+ sorted(other_files)
|
||||
)
|
||||
|
||||
return prioritized[:limit]
|
||||
# Return empty list - LLM agents will prioritize exploration themselves
|
||||
return []
|
||||
|
||||
def _load_json_safe(self, filename: str) -> dict | None:
|
||||
"""
|
||||
@@ -1462,59 +1296,18 @@ class PRContextGatherer:
|
||||
"""
|
||||
Find files related to the changes using a specific project root.
|
||||
|
||||
This static method allows finding related files AFTER a worktree
|
||||
has been created, ensuring files exist in the worktree filesystem.
|
||||
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
|
||||
This method returns an empty list - agents have domain expertise to find what's relevant.
|
||||
|
||||
Args:
|
||||
changed_files: List of changed files from the PR
|
||||
project_root: Path to search for related files (e.g., worktree path)
|
||||
|
||||
Returns:
|
||||
List of related file paths (relative to project root)
|
||||
Empty list - LLM agents will discover files via their tools.
|
||||
"""
|
||||
related: set[str] = set()
|
||||
|
||||
for changed_file in changed_files:
|
||||
path = Path(changed_file.path)
|
||||
|
||||
# Find test files
|
||||
test_patterns = [
|
||||
# Jest/Vitest patterns
|
||||
path.parent / f"{path.stem}.test{path.suffix}",
|
||||
path.parent / f"{path.stem}.spec{path.suffix}",
|
||||
path.parent / "__tests__" / f"{path.name}",
|
||||
# Python patterns
|
||||
path.parent / f"test_{path.stem}.py",
|
||||
path.parent / f"{path.stem}_test.py",
|
||||
# Go patterns
|
||||
path.parent / f"{path.stem}_test.go",
|
||||
]
|
||||
|
||||
for test_path in test_patterns:
|
||||
full_path = project_root / test_path
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(test_path))
|
||||
|
||||
# Find config files in same directory
|
||||
for name in CONFIG_FILE_NAMES:
|
||||
config_path = path.parent / name
|
||||
full_path = project_root / config_path
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(config_path))
|
||||
|
||||
# Find type definition files
|
||||
if path.suffix in [".ts", ".tsx"]:
|
||||
type_def = path.parent / f"{path.stem}.d.ts"
|
||||
full_path = project_root / type_def
|
||||
if full_path.exists() and full_path.is_file():
|
||||
related.add(str(type_def))
|
||||
|
||||
# Remove files that are already in changed_files
|
||||
changed_paths = {cf.path for cf in changed_files}
|
||||
related = {r for r in related if r not in changed_paths}
|
||||
|
||||
# Limit to 50 most relevant files (increased from 20)
|
||||
return sorted(related)[:50]
|
||||
# Return empty list - LLM agents will discover files via their tools
|
||||
return []
|
||||
|
||||
|
||||
class FollowupContextGatherer:
|
||||
|
||||
@@ -396,9 +396,15 @@ class GitHubOrchestrator:
|
||||
# No existing review found, create skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
else:
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
# For other skip reasons (bot-authored, cooling off, in-progress), create a skip result
|
||||
return await self._create_skip_result(pr_number, skip_reason)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
self.bot_detector.mark_review_started(pr_number)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} review as started", flush=True
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzing", 30, "Running multi-pass review...", pr_number=pr_number
|
||||
)
|
||||
@@ -572,6 +578,13 @@ class GitHubOrchestrator:
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
# Mark review as finished with error
|
||||
self.bot_detector.mark_review_finished(pr_number, success=False)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} review as finished (error)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Log full exception details for debugging
|
||||
error_details = f"{type(e).__name__}: {e}"
|
||||
full_traceback = traceback.format_exc()
|
||||
@@ -634,6 +647,13 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Mark review as started (prevents concurrent reviews)
|
||||
self.bot_detector.mark_review_started(pr_number)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as started",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Import here to avoid circular imports at module level
|
||||
try:
|
||||
@@ -956,6 +976,13 @@ class GitHubOrchestrator:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Mark review as finished with error
|
||||
self.bot_detector.mark_review_finished(pr_number, success=False)
|
||||
safe_print(
|
||||
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as finished (error)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
|
||||
@@ -17,19 +17,24 @@ Key Design:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
# Note: AgentDefinition import kept for backwards compatibility but no longer used
|
||||
# The Task tool's custom subagent_type feature is broken in Claude Code CLI
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
from claude_agent_sdk import AgentDefinition # noqa: F401
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget, resolve_model_id
|
||||
from ..context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
|
||||
from ..context_gatherer import PRContext, _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
@@ -48,10 +53,11 @@ try:
|
||||
AgentAgreement,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
|
||||
from context_gatherer import PRContext, _validate_git_ref
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
@@ -72,10 +78,56 @@ except (ImportError, ValueError, SystemError):
|
||||
AgentAgreement,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Specialist Configuration for Parallel SDK Sessions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistConfig:
|
||||
"""Configuration for a specialist agent in parallel SDK sessions."""
|
||||
|
||||
name: str
|
||||
prompt_file: str
|
||||
tools: list[str]
|
||||
description: str
|
||||
|
||||
|
||||
# Define specialist configurations
|
||||
# Each specialist runs as its own SDK session with its own system prompt and tools
|
||||
SPECIALIST_CONFIGS: list[SpecialistConfig] = [
|
||||
SpecialistConfig(
|
||||
name="security",
|
||||
prompt_file="pr_security_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Security vulnerabilities, OWASP Top 10, auth issues, injection, XSS",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="quality",
|
||||
prompt_file="pr_quality_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Code quality, complexity, duplication, error handling, patterns",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="logic",
|
||||
prompt_file="pr_logic_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Logic correctness, edge cases, algorithms, race conditions",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="codebase-fit",
|
||||
prompt_file="pr_codebase_fit_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Naming conventions, ecosystem fit, architectural alignment",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
@@ -255,9 +307,8 @@ class ParallelOrchestratorReviewer:
|
||||
"Security specialist. Use for OWASP Top 10, authentication, "
|
||||
"injection, cryptographic issues, and sensitive data exposure. "
|
||||
"Invoke when PR touches auth, API endpoints, user input, database queries, "
|
||||
"or file operations. IMPORTANT: Also check related files listed in the "
|
||||
"PR context - callers may be affected by security changes, and tests "
|
||||
"should verify security behavior."
|
||||
"or file operations. Use Read, Grep, and Glob tools to explore related files, "
|
||||
"callers, and tests as needed."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
security_prompt, "You are a security expert. Find vulnerabilities."
|
||||
@@ -269,9 +320,8 @@ class ParallelOrchestratorReviewer:
|
||||
description=(
|
||||
"Code quality expert. Use for complexity, duplication, error handling, "
|
||||
"maintainability, and pattern adherence. Invoke when PR has complex logic, "
|
||||
"large functions, or significant business logic changes. IMPORTANT: Check "
|
||||
"related files for pattern consistency - if a pattern is changed, similar "
|
||||
"code elsewhere should be updated too."
|
||||
"large functions, or significant business logic changes. Use Grep to search "
|
||||
"for similar patterns across the codebase for consistency checks."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
quality_prompt,
|
||||
@@ -285,8 +335,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Logic and correctness specialist. Use for algorithm verification, "
|
||||
"edge cases, state management, and race conditions. Invoke when PR has "
|
||||
"algorithmic changes, data transformations, concurrent operations, or bug fixes. "
|
||||
"IMPORTANT: Check callers and dependents in related files - logic changes "
|
||||
"may break assumptions made by code that uses this file."
|
||||
"Use Grep to find callers and dependents that may be affected by logic changes."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
logic_prompt, "You are a logic expert. Find correctness issues."
|
||||
@@ -299,8 +348,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Codebase consistency expert. Use for naming conventions, ecosystem fit, "
|
||||
"architectural alignment, and avoiding reinvention. Invoke when PR introduces "
|
||||
"new patterns, large additions, or code that might duplicate existing functionality. "
|
||||
"IMPORTANT: Use related files to understand existing patterns - new code "
|
||||
"should match established conventions in the codebase."
|
||||
"Use Grep and Glob to explore existing patterns and conventions in the codebase."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
codebase_fit_prompt,
|
||||
@@ -329,7 +377,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Reads the ACTUAL CODE at the finding location with fresh eyes. "
|
||||
"CRITICAL: Invoke for ALL findings after specialist agents complete. "
|
||||
"Can confirm findings as valid OR dismiss them as false positives. "
|
||||
"Check related files for mitigations the original agent missed."
|
||||
"Use Read, Grep, and Glob to check for mitigations the original agent missed."
|
||||
),
|
||||
prompt=with_working_dir(
|
||||
validator_prompt, "You validate whether findings are real issues."
|
||||
@@ -339,6 +387,304 @@ class ParallelOrchestratorReviewer:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Parallel SDK Sessions Implementation
|
||||
# =========================================================================
|
||||
# This replaces the broken Task tool subagent approach.
|
||||
# Each specialist runs as its own SDK session in parallel via asyncio.gather()
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
|
||||
def _build_specialist_prompt(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
"""Build the full prompt for a specialist agent.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context with files and patches
|
||||
project_root: Working directory for the agent
|
||||
|
||||
Returns:
|
||||
Full system prompt with context injected
|
||||
"""
|
||||
# Load base prompt from file
|
||||
base_prompt = self._load_prompt(config.prompt_file)
|
||||
if not base_prompt:
|
||||
base_prompt = f"You are a {config.name} specialist for PR review."
|
||||
|
||||
# Inject working directory using the existing helper
|
||||
with_working_dir = create_working_dir_injector(project_root)
|
||||
prompt_with_cwd = with_working_dir(
|
||||
base_prompt,
|
||||
f"You are a {config.name} specialist. Find {config.description}.",
|
||||
)
|
||||
|
||||
# Build file list
|
||||
files_list = []
|
||||
for file in context.changed_files:
|
||||
files_list.append(
|
||||
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
|
||||
)
|
||||
|
||||
# Build diff content (limited to avoid context overflow)
|
||||
patches = []
|
||||
MAX_DIFF_CHARS = 150_000 # Smaller limit per specialist
|
||||
|
||||
for file in context.changed_files:
|
||||
if file.patch:
|
||||
patches.append(f"\n### File: {file.path}\n{file.patch}")
|
||||
|
||||
diff_content = "\n".join(patches)
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
# Compose full prompt with PR context
|
||||
pr_context = f"""
|
||||
## PR Context
|
||||
|
||||
**PR #{context.pr_number}**: {context.title}
|
||||
|
||||
**Description:**
|
||||
{context.description or "(No description provided)"}
|
||||
|
||||
### Changed Files ({len(context.changed_files)} files, +{context.total_additions}/-{context.total_deletions})
|
||||
{chr(10).join(files_list)}
|
||||
|
||||
### Diff
|
||||
{diff_content}
|
||||
|
||||
## Your Task
|
||||
|
||||
Analyze this PR for {config.description}.
|
||||
Use the Read, Grep, and Glob tools to explore the codebase as needed.
|
||||
Report findings with specific file paths, line numbers, and code evidence.
|
||||
"""
|
||||
|
||||
return prompt_with_cwd + pr_context
|
||||
|
||||
async def _run_specialist_session(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[str, list[PRReviewFinding]]:
|
||||
"""Run a single specialist as its own SDK session.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (specialist_name, findings)
|
||||
"""
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Starting analysis...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Build the specialist prompt with PR context
|
||||
prompt = self._build_specialist_prompt(config, context, project_root)
|
||||
|
||||
try:
|
||||
# Create SDK client for this specialist
|
||||
# Note: Agent type uses the generic "pr_reviewer" since individual
|
||||
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
|
||||
# system prompt handles differentiation.
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": SpecialistResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Process SDK stream
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name=f"Specialist:{config.name}",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions={}, # No subagents for specialists
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] SDK stream failed: {error}"
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Analysis failed: {error}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
|
||||
# Parse structured output
|
||||
structured_output = stream_result.get("structured_output")
|
||||
findings = self._parse_specialist_output(
|
||||
config.name, structured_output, stream_result.get("result_text", "")
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Complete: {len(findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (config.name, findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] Session failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Error: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
|
||||
def _parse_specialist_output(
|
||||
self,
|
||||
specialist_name: str,
|
||||
structured_output: dict[str, Any] | None,
|
||||
result_text: str,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Parse findings from specialist output.
|
||||
|
||||
Args:
|
||||
specialist_name: Name of the specialist
|
||||
structured_output: Structured JSON output if available
|
||||
result_text: Raw text output as fallback
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding objects
|
||||
"""
|
||||
findings = []
|
||||
|
||||
if structured_output:
|
||||
try:
|
||||
result = SpecialistResponse.model_validate(structured_output)
|
||||
|
||||
for f in result.findings:
|
||||
finding_id = hashlib.md5(
|
||||
f"{f.file}:{f.line}:{f.title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f.category)
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(f.severity.lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=f.file,
|
||||
line=f.line,
|
||||
end_line=f.end_line,
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f.suggested_fix or "",
|
||||
evidence=f.evidence,
|
||||
source_agents=[specialist_name],
|
||||
is_impact_finding=f.is_impact_finding,
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
logger.info(
|
||||
f"[Specialist:{specialist_name}] Parsed {len(findings)} findings from structured output"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
|
||||
)
|
||||
# Fall through to text parsing
|
||||
|
||||
if not findings and result_text:
|
||||
# Fallback to text parsing
|
||||
findings = self._parse_text_output(result_text)
|
||||
for f in findings:
|
||||
f.source_agents = [specialist_name]
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_specialists(
|
||||
self,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[list[PRReviewFinding], list[str]]:
|
||||
"""Run all specialists in parallel and collect findings.
|
||||
|
||||
Args:
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (all_findings, agents_invoked)
|
||||
"""
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Launching {len(SPECIALIST_CONFIGS)} specialists in parallel...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Create tasks for all specialists
|
||||
tasks = [
|
||||
self._run_specialist_session(
|
||||
config=config,
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
for config in SPECIALIST_CONFIGS
|
||||
]
|
||||
|
||||
# Run all specialists in parallel
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Collect findings and track which agents ran
|
||||
all_findings: list[PRReviewFinding] = []
|
||||
agents_invoked: list[str] = []
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"[ParallelOrchestrator] Specialist task failed: {result}")
|
||||
continue
|
||||
|
||||
specialist_name, findings = result
|
||||
agents_invoked.append(specialist_name)
|
||||
all_findings.extend(findings)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] All specialists complete. "
|
||||
f"Total findings: {len(all_findings)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (all_findings, agents_invoked)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: PRContext) -> str:
|
||||
"""Build full prompt for orchestrator with PR context."""
|
||||
# Load orchestrator prompt
|
||||
@@ -397,80 +743,10 @@ Found {len(context.ai_bot_comments)} comments from AI tools.
|
||||
{chr(10).join(commits_list)}
|
||||
"""
|
||||
|
||||
# Build related files section (CONTEXT-02)
|
||||
# Removed: Related files and import graph sections
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
related_files_section = ""
|
||||
if context.related_files:
|
||||
# Categorize by type
|
||||
tests = [
|
||||
f
|
||||
for f in context.related_files
|
||||
if ".test." in f
|
||||
or "_test." in f
|
||||
or f.startswith("test")
|
||||
or "/tests/" in f
|
||||
or "\\tests\\" in f
|
||||
]
|
||||
deps = [f for f in context.related_files if f not in tests]
|
||||
|
||||
# Limit to avoid context overflow
|
||||
tests = tests[:15]
|
||||
deps = deps[:15]
|
||||
|
||||
tests_str = ", ".join(f"`{t}`" for t in tests) if tests else "None found"
|
||||
deps_str = ", ".join(f"`{d}`" for d in deps) if deps else "None found"
|
||||
|
||||
related_files_section = f"""
|
||||
### Related Files to Investigate
|
||||
These files are related to the changes (imports, tests, dependents). **Pass relevant files to specialists when delegating.**
|
||||
|
||||
**Tests** ({len(tests)} files): {tests_str}
|
||||
**Dependencies/Callers** ({len(deps)} files): {deps_str}
|
||||
|
||||
**When delegating to specialists, include relevant files:**
|
||||
- **security-reviewer**: Mention files that handle the same data flow
|
||||
- **logic-reviewer**: Mention callers that depend on changed function signatures
|
||||
- **quality-reviewer**: Mention files with similar patterns for consistency check
|
||||
- **codebase-fit-reviewer**: Mention existing implementations of similar features
|
||||
|
||||
Example delegation: "Review the auth changes in login.ts. Also check auth_middleware.ts and auth.test.ts which use this module."
|
||||
"""
|
||||
|
||||
# Build import graph summary (CONTEXT-03)
|
||||
import_graph_section = ""
|
||||
import_entries = []
|
||||
changed_paths = {f.path for f in context.changed_files}
|
||||
|
||||
for file in context.changed_files[:10]: # Limit to 10 files
|
||||
# Find what this file imports (look for related files it references)
|
||||
imports_this = [
|
||||
r
|
||||
for r in context.related_files
|
||||
if r in (file.content or "") and r not in changed_paths
|
||||
][:5]
|
||||
# Find what imports this file (reverse deps in related_files)
|
||||
# Match by filename stem to catch imports without extension
|
||||
file_stem = file.path.split("/")[-1].split(".")[0]
|
||||
imported_by = [
|
||||
r
|
||||
for r in context.related_files
|
||||
if file_stem in r and r not in changed_paths
|
||||
][:5]
|
||||
|
||||
if imports_this or imported_by:
|
||||
entry = f"**{file.path}**"
|
||||
if imports_this:
|
||||
entry += f"\n - Imports: {', '.join(imports_this)}"
|
||||
if imported_by:
|
||||
entry += f"\n - Imported by: {', '.join(imported_by)}"
|
||||
import_entries.append(entry)
|
||||
|
||||
if import_entries:
|
||||
import_graph_section = f"""
|
||||
### Import Relationships
|
||||
How the changed files connect to the codebase:
|
||||
|
||||
{chr(10).join(import_entries[:20])}
|
||||
"""
|
||||
|
||||
pr_context = f"""
|
||||
---
|
||||
@@ -790,30 +1066,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Rescan for related files using the worktree/project root
|
||||
# This fixes the issue where related files were 0 because context gathering
|
||||
# happened BEFORE the worktree was created (PR files didn't exist locally)
|
||||
if context.changed_files:
|
||||
new_related_files = PRContextGatherer.find_related_files_for_root(
|
||||
context.changed_files,
|
||||
project_root,
|
||||
)
|
||||
# Always log rescan result (not gated by DEBUG_MODE)
|
||||
if new_related_files:
|
||||
context.related_files = new_related_files
|
||||
safe_print(
|
||||
f"[PRReview] Rescanned in worktree: found {len(new_related_files)} related files"
|
||||
)
|
||||
else:
|
||||
safe_print(
|
||||
f"[PRReview] Rescanned in worktree: found 0 related files "
|
||||
f"(initial scan found {len(context.related_files)})"
|
||||
)
|
||||
|
||||
# Build orchestrator prompt AFTER worktree creation and related files rescan
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
# Capture agent definitions for debug logging (with worktree path)
|
||||
agent_defs = self._define_specialist_agents(project_root)
|
||||
# Removed: Related files rescanning
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
# No need to pre-scan the codebase programmatically
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
# Resolve model shorthand via environment variable override if configured
|
||||
@@ -827,48 +1082,39 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
# SDK handles parallel execution when Claude invokes multiple Task tools
|
||||
client = self._create_sdk_client(project_root, model, thinking_budget)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
"Running specialist agents in parallel...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
# =================================================================
|
||||
# PARALLEL SDK SESSIONS APPROACH
|
||||
# =================================================================
|
||||
# Instead of using broken Task tool subagents, we spawn each
|
||||
# specialist as its own SDK session and run them in parallel.
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
#
|
||||
# This gives us:
|
||||
# - True parallel execution via asyncio.gather()
|
||||
# - Full control over each specialist's tools and prompts
|
||||
# - No dependency on broken CLI features
|
||||
# =================================================================
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
# Run all specialists in parallel
|
||||
findings, agents_invoked = await self._run_parallel_specialists(
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions=agent_defs,
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
# Log results
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Parallel specialists complete: "
|
||||
f"{len(findings)} findings from {len(agents_invoked)} agents"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
@@ -877,20 +1123,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (structured output also returns agents)
|
||||
findings, agents_from_structured = self._extract_structured_output(
|
||||
structured_output, result_text
|
||||
)
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
final_agents = (
|
||||
agents_from_structured if agents_from_structured else agents_invoked
|
||||
)
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
# Log completion with agent info
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -993,7 +1228,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
agents_invoked=final_agents,
|
||||
agents_invoked=agents_invoked,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
@@ -1427,6 +1662,10 @@ The SDK will run invoked agents in parallel automatically.
|
||||
if not findings:
|
||||
return []
|
||||
|
||||
# Retry configuration for API errors
|
||||
MAX_VALIDATION_RETRIES = 2
|
||||
VALIDATOR_MAX_MESSAGES = 200 # Lower limit for validator (simpler task)
|
||||
|
||||
# Build validation prompt with all findings
|
||||
findings_json = []
|
||||
for f in findings:
|
||||
@@ -1466,47 +1705,101 @@ For EACH finding above:
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
model = resolve_model_id(model_shorthand)
|
||||
|
||||
# Create validator client (inherits worktree filesystem access)
|
||||
try:
|
||||
validator_client = create_client(
|
||||
project_dir=worktree_path,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_finding_validator",
|
||||
max_thinking_tokens=get_thinking_budget("medium"),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FindingValidationResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to create validator client: {e}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
# Run validation
|
||||
try:
|
||||
async with validator_client:
|
||||
await validator_client.query(prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=validator_client,
|
||||
context_name="FindingValidator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
# Retry loop for transient API errors
|
||||
last_error = None
|
||||
for attempt in range(MAX_VALIDATION_RETRIES + 1):
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"[PRReview] Validation retry {attempt}/{MAX_VALIDATION_RETRIES}"
|
||||
)
|
||||
safe_print(
|
||||
f"[FindingValidator] Retry attempt {attempt}/{MAX_VALIDATION_RETRIES}"
|
||||
)
|
||||
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[PRReview] Validation failed: {stream_result['error']}"
|
||||
# Create validator client (inherits worktree filesystem access)
|
||||
try:
|
||||
validator_client = create_client(
|
||||
project_dir=worktree_path,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_finding_validator",
|
||||
max_thinking_tokens=get_thinking_budget("medium"),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": FindingValidationResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to create validator client: {e}")
|
||||
last_error = e
|
||||
continue # Try again
|
||||
|
||||
# Run validation
|
||||
try:
|
||||
async with validator_client:
|
||||
await validator_client.query(prompt)
|
||||
|
||||
stream_result = await process_sdk_stream(
|
||||
client=validator_client,
|
||||
context_name="FindingValidator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
max_messages=VALIDATOR_MAX_MESSAGES,
|
||||
)
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# Check for specific error types that warrant retry
|
||||
error_str = str(error).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Validation stream error: {e}")
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(
|
||||
f"[PRReview] Retryable validation error: {error}"
|
||||
)
|
||||
last_error = Exception(error)
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation failed: {error}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
|
||||
# Success - break out of retry loop
|
||||
if structured_output:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "rate" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(f"[PRReview] Retryable stream error: {e}")
|
||||
last_error = e
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation stream error: {e}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
else:
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"[PRReview] Validation failed after {MAX_VALIDATION_RETRIES} retries. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
safe_print(
|
||||
f"[FindingValidator] ERROR: Validation failed after {MAX_VALIDATION_RETRIES} retries"
|
||||
)
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
|
||||
@@ -139,18 +139,9 @@ class PRReviewEngine:
|
||||
files_list.append(f"- ... and {len(context.changed_files) - 20} more files")
|
||||
files_str = "\n".join(files_list)
|
||||
|
||||
# NEW: Format related files (imports, tests, etc.)
|
||||
# Removed: Related files section
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
related_files_str = ""
|
||||
if context.related_files:
|
||||
related_files_list = [f"- `{f}`" for f in context.related_files[:10]]
|
||||
if len(context.related_files) > 10:
|
||||
related_files_list.append(
|
||||
f"- ... and {len(context.related_files) - 10} more"
|
||||
)
|
||||
related_files_str = f"""
|
||||
### Related Files (imports, tests, configs)
|
||||
{chr(10).join(related_files_list)}
|
||||
"""
|
||||
|
||||
# NEW: Format commits for context
|
||||
commits_str = ""
|
||||
|
||||
@@ -537,6 +537,51 @@ class ValidationSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SpecialistFinding(BaseModel):
|
||||
"""A finding from a specialist agent (used in parallel SDK sessions)."""
|
||||
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line number if multi-line")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet examined that shows the issue. Required.",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
|
||||
class SpecialistResponse(BaseModel):
|
||||
"""Response schema for individual specialist agent (parallel SDK sessions).
|
||||
|
||||
Used when each specialist runs as its own SDK session rather than via Task tool.
|
||||
"""
|
||||
|
||||
specialist_name: str = Field(
|
||||
description="Name of the specialist (security, quality, logic, codebase-fit)"
|
||||
)
|
||||
analysis_summary: str = Field(description="Brief summary of what was analyzed")
|
||||
files_examined: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of files that were examined",
|
||||
)
|
||||
findings: list[SpecialistFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues found during analysis",
|
||||
)
|
||||
|
||||
|
||||
class ParallelOrchestratorResponse(BaseModel):
|
||||
"""Complete response schema for parallel orchestrator PR review."""
|
||||
|
||||
|
||||
@@ -25,243 +25,6 @@ logger = logging.getLogger(__name__)
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# ── TEMPORARY: Per-PR full agent communication logger (v2) ────────────
|
||||
# Writes every message to .auto-claude/github/pr/debug_logs/<context>_<ts>.log
|
||||
# Remove after measurement phase is complete.
|
||||
import datetime as _dt
|
||||
import json as _json
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# Derive project root dynamically from this file's location
|
||||
# sdk_utils.py is at: apps/backend/runners/github/services/sdk_utils.py
|
||||
# So project root is 5 levels up
|
||||
_PROJECT_ROOT = _Path(__file__).resolve().parent.parent.parent.parent.parent
|
||||
_PR_LOG_DIR = _PROJECT_ROOT / ".auto-claude" / "github" / "pr" / "debug_logs"
|
||||
|
||||
|
||||
class _PRDebugLogger:
|
||||
"""Writes full agent communication to a log file for review.
|
||||
|
||||
Improvements (v2):
|
||||
- System prompt and agent definitions logged at session start
|
||||
- No truncation on thinking, text, tool input, or tool results
|
||||
- No duplicate logging (single structured dump per message)
|
||||
- Empty/whitespace content shown via repr()
|
||||
- Agent attribution via subagent_tool_ids mapping
|
||||
"""
|
||||
|
||||
def __init__(self, context_name: str, model: str | None = None):
|
||||
self._f = None
|
||||
self._subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
|
||||
try:
|
||||
_PR_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.path = _PR_LOG_DIR / f"{context_name}_{ts}.log"
|
||||
self._f = open(self.path, "w", encoding="utf-8")
|
||||
self._write(
|
||||
f"=== {context_name} Session Started at {_dt.datetime.now().isoformat()} ==="
|
||||
)
|
||||
if model:
|
||||
self._write(f"Model: {model}")
|
||||
self._write("")
|
||||
except OSError as e:
|
||||
# Failed to create directory or open file - logging disabled
|
||||
logger.warning(f"PR debug logger disabled: {e}")
|
||||
self.path = None
|
||||
|
||||
def _write(self, text: str):
|
||||
# Skip logging if file handle was not created successfully
|
||||
if self._f is None:
|
||||
return
|
||||
try:
|
||||
self._f.write(text + "\n")
|
||||
self._f.flush()
|
||||
except (OSError, ValueError) as e:
|
||||
# File write failed (file closed, disk full, etc.) - disable logging
|
||||
logger.warning(f"PR debug logger write failed: {e}")
|
||||
self._f = None
|
||||
|
||||
# ── Session preamble loggers ──────────────────────────────────────
|
||||
|
||||
def log_system_prompt(self, prompt: str):
|
||||
"""Log the full system prompt (no truncation)."""
|
||||
self._write(f"\n{'#' * 80}")
|
||||
self._write("# SYSTEM PROMPT (full orchestrator instructions + PR context)")
|
||||
self._write(f"# Length: {len(prompt)} chars")
|
||||
self._write(f"{'#' * 80}")
|
||||
self._write(prompt)
|
||||
self._write(f"{'#' * 80}\n")
|
||||
|
||||
def log_agent_definitions(self, agents: dict):
|
||||
"""Log all specialist agent definitions (prompts, tools, descriptions)."""
|
||||
self._write(f"\n{'#' * 80}")
|
||||
self._write(f"# AGENT DEFINITIONS ({len(agents)} specialists)")
|
||||
self._write(f"{'#' * 80}")
|
||||
for name, defn in agents.items():
|
||||
self._write(f"\n--- Agent: {name} ---")
|
||||
self._write(f" description: {getattr(defn, 'description', 'N/A')}")
|
||||
self._write(f" model: {getattr(defn, 'model', 'N/A')}")
|
||||
self._write(f" tools: {getattr(defn, 'tools', 'N/A')}")
|
||||
prompt = getattr(defn, "prompt", "")
|
||||
self._write(f" prompt ({len(prompt)} chars):")
|
||||
self._write(prompt)
|
||||
self._write(f"{'#' * 80}\n")
|
||||
|
||||
# ── Agent attribution ─────────────────────────────────────────────
|
||||
|
||||
def set_subagent_mapping(self, mapping: dict[str, str]):
|
||||
"""Update the tool_id -> agent_name mapping for attribution."""
|
||||
self._subagent_tool_ids = mapping
|
||||
|
||||
def _get_agent_label(self, tool_id: str) -> str:
|
||||
"""Return agent label if this tool_id belongs to a known subagent."""
|
||||
agent = self._subagent_tool_ids.get(tool_id)
|
||||
return f" [Agent:{agent}]" if agent else ""
|
||||
|
||||
# ── Per-message logger (single structured dump) ───────────────────
|
||||
|
||||
def log_message(self, msg_count: int, msg_type: str, msg: object):
|
||||
self._write(f"\n{'=' * 80}")
|
||||
self._write(f"--- Message #{msg_count} [{msg_type}] ---")
|
||||
self._write(f"{'=' * 80}")
|
||||
self._dump_raw(msg)
|
||||
|
||||
def _dump_raw(self, msg: object, indent: int = 0):
|
||||
"""Dump full raw message content recursively — NO truncation."""
|
||||
prefix = " " * indent
|
||||
# Content blocks
|
||||
if hasattr(msg, "content"):
|
||||
content = msg.content
|
||||
if isinstance(content, list):
|
||||
self._write(f"{prefix}[content] ({len(content)} blocks):")
|
||||
for i, block in enumerate(content):
|
||||
block_type = type(block).__name__
|
||||
self._write(f"{prefix} [{i}] {block_type}:")
|
||||
self._dump_block(block, indent + 2)
|
||||
elif isinstance(content, str):
|
||||
if not content or content.isspace():
|
||||
self._write(
|
||||
f"{prefix}[content] (string, {len(content)} chars): {repr(content)}"
|
||||
)
|
||||
else:
|
||||
self._write(f"{prefix}[content] (string, {len(content)} chars):")
|
||||
self._write(content)
|
||||
else:
|
||||
self._write(f"{prefix}[content] ({type(content).__name__}):")
|
||||
self._write(f"{prefix} {str(content)}")
|
||||
|
||||
# Role / type
|
||||
if hasattr(msg, "role"):
|
||||
self._write(f"{prefix}[role] {msg.role}")
|
||||
if hasattr(msg, "type") and not hasattr(msg, "content"):
|
||||
self._write(f"{prefix}[type] {msg.type}")
|
||||
|
||||
# Structured output
|
||||
if hasattr(msg, "structured_output") and msg.structured_output:
|
||||
self._write(f"{prefix}[structured_output]:")
|
||||
try:
|
||||
self._write(_json.dumps(msg.structured_output, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(f"{prefix} {str(msg.structured_output)}")
|
||||
|
||||
# Result message fields
|
||||
if hasattr(msg, "subtype"):
|
||||
self._write(f"{prefix}[subtype] {msg.subtype}")
|
||||
if hasattr(msg, "is_error"):
|
||||
self._write(f"{prefix}[is_error] {msg.is_error}")
|
||||
if hasattr(msg, "duration_ms"):
|
||||
self._write(f"{prefix}[duration_ms] {msg.duration_ms}")
|
||||
if hasattr(msg, "session_id"):
|
||||
self._write(f"{prefix}[session_id] {msg.session_id}")
|
||||
|
||||
# Catch-all for messages without content blocks
|
||||
for attr in ("text", "thinking", "name", "id", "input", "tool_use_id"):
|
||||
if hasattr(msg, attr) and not hasattr(msg, "content"):
|
||||
val = getattr(msg, attr)
|
||||
if val is not None:
|
||||
self._write(f"{prefix}[{attr}] {str(val)}")
|
||||
|
||||
def _dump_block(self, block: object, indent: int = 0):
|
||||
"""Dump a single content block — NO truncation."""
|
||||
prefix = " " * indent
|
||||
block_type = getattr(block, "type", type(block).__name__)
|
||||
|
||||
if block_type in ("text", "TextBlock") and hasattr(block, "text"):
|
||||
text = block.text
|
||||
if not text or text.isspace():
|
||||
self._write(f"{prefix}[text] ({len(text)} chars): {repr(text)}")
|
||||
else:
|
||||
self._write(f"{prefix}[text] ({len(text)} chars):")
|
||||
self._write(text)
|
||||
|
||||
elif block_type in ("thinking", "ThinkingBlock") and hasattr(block, "thinking"):
|
||||
text = block.thinking or getattr(block, "text", "")
|
||||
self._write(f"{prefix}[thinking] ({len(text)} chars):")
|
||||
self._write(text)
|
||||
|
||||
elif block_type in ("tool_use", "ToolUseBlock"):
|
||||
tool_name = getattr(block, "name", "unknown")
|
||||
tool_id = getattr(block, "id", "unknown")
|
||||
tool_input = getattr(block, "input", {})
|
||||
agent_label = self._get_agent_label(tool_id)
|
||||
self._write(f"{prefix}[tool_use] {tool_name} (id={tool_id}){agent_label}")
|
||||
try:
|
||||
self._write(_json.dumps(tool_input, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(str(tool_input))
|
||||
|
||||
elif block_type in ("tool_result", "ToolResultBlock"):
|
||||
tool_id = getattr(block, "tool_use_id", "unknown")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
result = getattr(block, "content", "")
|
||||
if isinstance(result, list):
|
||||
result = " ".join(str(getattr(c, "text", c)) for c in result)
|
||||
status = "ERROR" if is_error else "OK"
|
||||
agent_label = self._get_agent_label(tool_id)
|
||||
self._write(
|
||||
f"{prefix}[tool_result] (tool_id={tool_id}) {status}{agent_label}"
|
||||
)
|
||||
self._write(str(result))
|
||||
|
||||
else:
|
||||
# Unknown block type — dump everything we can
|
||||
self._write(f"{prefix}[{block_type}] (raw dump):")
|
||||
for attr in dir(block):
|
||||
if not attr.startswith("_"):
|
||||
try:
|
||||
val = getattr(block, attr)
|
||||
if not callable(val):
|
||||
self._write(f"{prefix} {attr}: {str(val)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Structured output (standalone, for final result) ──────────────
|
||||
|
||||
def log_structured_output(self, output: dict):
|
||||
self._write("[STRUCTURED_OUTPUT]")
|
||||
try:
|
||||
self._write(_json.dumps(output, indent=2, default=str))
|
||||
except Exception:
|
||||
self._write(str(output))
|
||||
|
||||
# ── Session close ─────────────────────────────────────────────────
|
||||
|
||||
def close(self, summary: dict):
|
||||
self._write("\n=== Session Ended ===")
|
||||
self._write(f"Messages: {summary.get('msg_count', '?')}")
|
||||
self._write(f"Agents invoked: {summary.get('agents_invoked', [])}")
|
||||
self._write(f"Error: {summary.get('error')}")
|
||||
self._write(f"Log file: {self.path}")
|
||||
if self._f is not None:
|
||||
try:
|
||||
self._f.close()
|
||||
except OSError as e:
|
||||
logger.warning(f"PR debug logger close failed: {e}")
|
||||
|
||||
|
||||
# ── END TEMPORARY ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _short_model_name(model: str | None) -> str:
|
||||
"""Convert full model name to a short display name for logs.
|
||||
@@ -361,6 +124,49 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
|
||||
return f"Using tool: {tool_name}"
|
||||
|
||||
|
||||
# Circuit breaker threshold - abort if message count exceeds this
|
||||
# Prevents runaway retry loops from consuming unbounded resources
|
||||
MAX_MESSAGE_COUNT = 500
|
||||
|
||||
|
||||
def _is_tool_concurrency_error(text: str) -> bool:
|
||||
"""
|
||||
Detect the specific tool use concurrency error pattern.
|
||||
|
||||
This error occurs when Claude makes multiple parallel tool_use blocks
|
||||
and some fail, corrupting the tool_use/tool_result message pairing.
|
||||
|
||||
Args:
|
||||
text: Text to check for error pattern
|
||||
|
||||
Returns:
|
||||
True if this is the tool concurrency error, False otherwise
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
# Check for the specific error message pattern
|
||||
# Pattern 1: Explicit concurrency or tool_use errors with 400
|
||||
has_400 = "400" in text_lower
|
||||
has_tool = "tool" in text_lower
|
||||
|
||||
if has_400 and has_tool:
|
||||
# Look for specific keywords indicating tool concurrency issues
|
||||
error_keywords = [
|
||||
"concurrency",
|
||||
"tool_use",
|
||||
"tool use",
|
||||
"tool_result",
|
||||
"tool result",
|
||||
]
|
||||
if any(keyword in text_lower for keyword in error_keywords):
|
||||
return True
|
||||
|
||||
# Pattern 2: API error with 400 and tool mention
|
||||
if "api error" in text_lower and has_400 and has_tool:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def process_sdk_stream(
|
||||
client: Any,
|
||||
on_thinking: Callable[[str], None] | None = None,
|
||||
@@ -370,8 +176,10 @@ async def process_sdk_stream(
|
||||
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
|
||||
context_name: str = "SDK",
|
||||
model: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
agent_definitions: dict | None = None,
|
||||
max_messages: int | None = None,
|
||||
# Deprecated parameters (kept for backwards compatibility, no longer used)
|
||||
system_prompt: str | None = None, # noqa: ARG001
|
||||
agent_definitions: dict | None = None, # noqa: ARG001
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process SDK response stream with customizable callbacks.
|
||||
@@ -392,8 +200,7 @@ async def process_sdk_stream(
|
||||
on_structured_output: Callback for structured output - receives dict
|
||||
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
|
||||
model: Model name for logging (e.g., "claude-sonnet-4-5-20250929")
|
||||
system_prompt: Full system prompt sent to the agent (logged at session start)
|
||||
agent_definitions: Dict of agent name -> AgentDefinition (logged at session start)
|
||||
max_messages: Optional override for max message count circuit breaker (default: MAX_MESSAGE_COUNT)
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
@@ -412,15 +219,11 @@ async def process_sdk_stream(
|
||||
# Track subagent tool IDs to log their results
|
||||
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
completed_agent_tool_ids: set[str] = set() # tool_ids of completed agents
|
||||
# Track tool concurrency errors for retry logic
|
||||
detected_concurrency_error = False
|
||||
|
||||
# TEMPORARY: per-PR debug file logger
|
||||
_dbg = _PRDebugLogger(context_name, model=model)
|
||||
|
||||
# Log session preamble: system prompt and agent definitions
|
||||
if system_prompt:
|
||||
_dbg.log_system_prompt(system_prompt)
|
||||
if agent_definitions:
|
||||
_dbg.log_agent_definitions(agent_definitions)
|
||||
# Circuit breaker: max messages before aborting
|
||||
message_limit = max_messages if max_messages is not None else MAX_MESSAGE_COUNT
|
||||
|
||||
safe_print(f"[{context_name}] Processing SDK stream...")
|
||||
if DEBUG_MODE:
|
||||
@@ -435,7 +238,17 @@ async def process_sdk_stream(
|
||||
try:
|
||||
msg_type = type(msg).__name__
|
||||
msg_count += 1
|
||||
_dbg.log_message(msg_count, msg_type, msg)
|
||||
|
||||
# CIRCUIT BREAKER: Abort if message count exceeds threshold
|
||||
# This prevents runaway retry loops (e.g., 400 errors causing infinite retries)
|
||||
if msg_count > message_limit:
|
||||
stream_error = (
|
||||
f"Circuit breaker triggered: message count ({msg_count}) "
|
||||
f"exceeded limit ({message_limit}). Possible retry loop detected."
|
||||
)
|
||||
logger.error(f"[{context_name}] {stream_error}")
|
||||
safe_print(f"[{context_name}] ERROR: {stream_error}")
|
||||
break
|
||||
|
||||
# Log progress periodically so user knows AI is working
|
||||
if msg_count - last_progress_log >= PROGRESS_LOG_INTERVAL:
|
||||
@@ -504,7 +317,6 @@ async def process_sdk_stream(
|
||||
agents_invoked.append(agent_name)
|
||||
# Track this tool ID to log its result later
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
_dbg.set_subagent_mapping(subagent_tool_ids)
|
||||
# Log with model info if available
|
||||
model_info = f" [{_short_model_name(model)}]" if model else ""
|
||||
safe_print(
|
||||
@@ -611,6 +423,15 @@ async def process_sdk_stream(
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Check for tool concurrency error pattern in text output
|
||||
if _is_tool_concurrency_error(block.text):
|
||||
detected_concurrency_error = True
|
||||
logger.warning(
|
||||
f"[{context_name}] Detected tool use concurrency error in response"
|
||||
)
|
||||
safe_print(
|
||||
f"[{context_name}] WARNING: Tool concurrency error detected"
|
||||
)
|
||||
# Always print text content preview (not just in DEBUG_MODE)
|
||||
text_preview = block.text[:500].replace("\n", " ").strip()
|
||||
if text_preview:
|
||||
@@ -655,7 +476,6 @@ async def process_sdk_stream(
|
||||
# Only capture if we don't already have it (avoid duplicates)
|
||||
if structured_output is None:
|
||||
structured_output = msg.structured_output
|
||||
_dbg.log_structured_output(msg.structured_output)
|
||||
safe_print(f"[{context_name}] Received structured output")
|
||||
if on_structured_output:
|
||||
on_structured_output(msg.structured_output)
|
||||
@@ -728,7 +548,14 @@ async def process_sdk_stream(
|
||||
|
||||
safe_print(f"[{context_name}] Session ended. Total messages: {msg_count}")
|
||||
|
||||
result = {
|
||||
# Set error flag if tool concurrency error was detected
|
||||
if detected_concurrency_error and not stream_error:
|
||||
stream_error = "tool_use_concurrency_error"
|
||||
logger.warning(
|
||||
f"[{context_name}] Tool use concurrency error detected - caller should retry"
|
||||
)
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
@@ -736,6 +563,3 @@ async def process_sdk_stream(
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
}
|
||||
_dbg.close(result)
|
||||
safe_print(f"[{context_name}] Full debug log: {_dbg.path}")
|
||||
return result
|
||||
|
||||
@@ -532,5 +532,176 @@ class TestGhExecutableDetection:
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInProgressTracking:
|
||||
"""Test in-progress review tracking."""
|
||||
|
||||
def test_mark_review_started(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as started."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
# Check state
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
start_time_str = mock_bot_detector.state.in_progress_reviews["123"]
|
||||
start_time = datetime.fromisoformat(start_time_str)
|
||||
|
||||
# Should be very recent (within last 5 seconds)
|
||||
time_diff = datetime.now() - start_time
|
||||
assert time_diff.total_seconds() < 5
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir):
|
||||
"""Test marking review as finished successfully."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_review_finished(123, success=True)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
# Check persistence
|
||||
loaded = BotDetectionState.load(temp_state_dir)
|
||||
assert "123" not in loaded.in_progress_reviews
|
||||
|
||||
def test_mark_review_finished_error(self, mock_bot_detector):
|
||||
"""Test marking review as finished with error."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_finished(123, success=False)
|
||||
|
||||
# In-progress state should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_active(self, mock_bot_detector):
|
||||
"""Test detecting active in-progress review."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
assert is_in_progress is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_is_review_in_progress_not_started(self, mock_bot_detector):
|
||||
"""Test checking in-progress when review not started."""
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(999)
|
||||
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
|
||||
def test_is_review_in_progress_stale(self, mock_bot_detector):
|
||||
"""Test detecting stale in-progress review."""
|
||||
# Set review start time to 31 minutes ago (past timeout)
|
||||
stale_time = datetime.now() - timedelta(minutes=31)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should detect as stale and clear it
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
# Should be removed from state
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector):
|
||||
"""Test handling invalid timestamp in in-progress state."""
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp"
|
||||
|
||||
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
|
||||
|
||||
# Should clear invalid state
|
||||
assert is_in_progress is False
|
||||
assert reason == ""
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
def test_should_skip_review_in_progress(self, mock_bot_detector):
|
||||
"""Test skipping PR when review is in progress."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
pr_number=123,
|
||||
pr_data=pr_data,
|
||||
commits=commits,
|
||||
)
|
||||
|
||||
assert should_skip is True
|
||||
assert "already in progress" in reason.lower()
|
||||
|
||||
def test_mark_reviewed_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that mark_reviewed also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# In-progress should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
# Reviewed state should be set
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
|
||||
|
||||
def test_clear_pr_state_clears_in_progress(self, mock_bot_detector):
|
||||
"""Test that clear_pr_state also clears in-progress state."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_reviewed(123, "abc123")
|
||||
|
||||
assert (
|
||||
"123" in mock_bot_detector.state.in_progress_reviews or True
|
||||
) # May be cleared by mark_reviewed
|
||||
assert "123" in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
# Start another review
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
assert "123" in mock_bot_detector.state.in_progress_reviews
|
||||
|
||||
mock_bot_detector.clear_pr_state(123)
|
||||
|
||||
# Everything should be cleared
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert "123" not in mock_bot_detector.state.reviewed_commits
|
||||
assert "123" not in mock_bot_detector.state.last_review_times
|
||||
|
||||
def test_get_stats_includes_in_progress(self, mock_bot_detector):
|
||||
"""Test that get_stats includes in-progress count."""
|
||||
mock_bot_detector.mark_review_started(123)
|
||||
mock_bot_detector.mark_review_started(456)
|
||||
mock_bot_detector.mark_reviewed(789, "abc123")
|
||||
|
||||
stats = mock_bot_detector.get_stats()
|
||||
|
||||
assert stats["in_progress_reviews"] == 2
|
||||
assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed
|
||||
assert stats["in_progress_timeout_minutes"] == 30
|
||||
|
||||
def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector):
|
||||
"""Test that cleanup_stale_prs removes stale in-progress reviews."""
|
||||
# Add a stale in-progress review (32 minutes ago)
|
||||
stale_time = datetime.now() - timedelta(minutes=32)
|
||||
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
|
||||
|
||||
# Add an active in-progress review (5 minutes ago)
|
||||
active_time = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat()
|
||||
|
||||
# Add a stale reviewed PR (40 days ago)
|
||||
stale_review_time = datetime.now() - timedelta(days=40)
|
||||
mock_bot_detector.state.reviewed_commits["789"] = ["abc123"]
|
||||
mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat()
|
||||
|
||||
cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30)
|
||||
|
||||
# Should remove stale in-progress and stale reviewed PR
|
||||
assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed
|
||||
assert "123" not in mock_bot_detector.state.in_progress_reviews
|
||||
assert (
|
||||
"456" in mock_bot_detector.state.in_progress_reviews
|
||||
) # Active one remains
|
||||
assert "789" not in mock_bot_detector.state.reviewed_commits
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -46,6 +46,7 @@ if sys.version_info < (3, 10): # noqa: UP036
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
@@ -252,9 +253,17 @@ Examples:
|
||||
# Find project root (look for auto-claude folder)
|
||||
project_dir = args.project_dir
|
||||
|
||||
# Auto-detect if running from within auto-claude directory (the source code)
|
||||
if project_dir.name == "auto-claude" and (project_dir / "run.py").exists():
|
||||
# Running from within auto-claude/ source directory, go up 1 level
|
||||
# Auto-detect if running from within auto-claude/apps/backend/ source directory.
|
||||
# This must be specific: check for run.py FILE (not dir) AND core/client.py to confirm
|
||||
# we're in the actual backend source tree, not just a project named "auto-claude".
|
||||
run_py_path = project_dir / "run.py"
|
||||
if (
|
||||
project_dir.name == "auto-claude"
|
||||
and run_py_path.exists()
|
||||
and run_py_path.is_file()
|
||||
and (project_dir / "core" / "client.py").exists()
|
||||
):
|
||||
# Running from within auto-claude/apps/backend/ source directory, go up 1 level
|
||||
project_dir = project_dir.parent
|
||||
elif not (project_dir / ".auto-claude").exists():
|
||||
# No .auto-claude folder found - try to find project root
|
||||
@@ -351,6 +360,36 @@ Examples:
|
||||
"--auto-continue", # Non-interactive mode for chained execution
|
||||
]
|
||||
|
||||
# Bypass approval re-validation when all conditions are met:
|
||||
# 1. Spec was auto-approved (no human review required)
|
||||
# 2. Spec creation succeeded (we're past the success check above)
|
||||
# 3. No review-before-coding gate was requested
|
||||
# This prevents hash mismatch failures when spec files are
|
||||
# touched between auto-approval and run.py startup.
|
||||
if args.auto_approve:
|
||||
# Default to requiring review (fail-closed) - only skip if explicitly disabled
|
||||
require_review = True
|
||||
task_meta_path = orchestrator.spec_dir / "task_metadata.json"
|
||||
if task_meta_path.exists():
|
||||
try:
|
||||
with open(task_meta_path, encoding="utf-8") as f:
|
||||
task_meta = json.load(f)
|
||||
require_review = task_meta.get(
|
||||
"requireReviewBeforeCoding", False
|
||||
)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
# On parse error, keep require_review=True (fail-closed)
|
||||
debug(
|
||||
"spec_runner",
|
||||
f"Failed to parse task_metadata.json, not adding --force: {e}",
|
||||
)
|
||||
if not require_review:
|
||||
run_cmd.append("--force")
|
||||
debug(
|
||||
"spec_runner",
|
||||
"Adding --force: auto-approved, no review required, spec completed",
|
||||
)
|
||||
|
||||
# Pass base branch if specified (for worktree creation)
|
||||
if args.base_branch:
|
||||
run_cmd.extend(["--base-branch", args.base_branch])
|
||||
|
||||
@@ -38,7 +38,7 @@ async def bash_security_hook(
|
||||
context: Optional context
|
||||
|
||||
Returns:
|
||||
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
|
||||
Empty dict to allow, or hookSpecificOutput with permissionDecision "deny" to block
|
||||
"""
|
||||
if input_data.get("tool_name") != "Bash":
|
||||
return {}
|
||||
@@ -49,15 +49,21 @@ async def bash_security_hook(
|
||||
# Check if tool_input is None (malformed tool call)
|
||||
if tool_input is None:
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": "Bash tool_input is None - malformed tool call from SDK",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": "Bash tool_input is None - malformed tool call from SDK",
|
||||
}
|
||||
}
|
||||
|
||||
# Check if tool_input is a dict
|
||||
if not isinstance(tool_input, dict):
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
|
||||
}
|
||||
}
|
||||
|
||||
# Now safe to access command
|
||||
@@ -97,8 +103,11 @@ async def bash_security_hook(
|
||||
if not commands:
|
||||
# Could not parse - fail safe by blocking
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": f"Could not parse command for security validation: {command}",
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": f"Could not parse command for security validation: {command}",
|
||||
}
|
||||
}
|
||||
|
||||
# Split into segments for per-command validation
|
||||
@@ -114,8 +123,11 @@ async def bash_security_hook(
|
||||
|
||||
if not is_allowed:
|
||||
return {
|
||||
"decision": "block",
|
||||
"reason": reason,
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
}
|
||||
|
||||
# Additional validation for sensitive commands
|
||||
@@ -127,7 +139,13 @@ async def bash_security_hook(
|
||||
validator = VALIDATORS[cmd]
|
||||
allowed, reason = validator(cmd_segment)
|
||||
if not allowed:
|
||||
return {"decision": "block", "reason": reason}
|
||||
return {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from analysis.analyzers import analyze_project
|
||||
from core.task_event import TaskEventEmitter
|
||||
from core.workspace.models import SpecNumberLock
|
||||
from phase_config import get_thinking_budget
|
||||
from prompts_pkg.project_context import should_refresh_project_index
|
||||
@@ -234,6 +235,7 @@ class SpecOrchestrator:
|
||||
# Initialize task logger for planning phase
|
||||
task_logger = get_task_logger(self.spec_dir)
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting spec creation process")
|
||||
TaskEventEmitter.from_spec_dir(self.spec_dir).emit("PLANNING_STARTED")
|
||||
|
||||
print(
|
||||
box(
|
||||
@@ -408,6 +410,28 @@ class SpecOrchestrator:
|
||||
LogPhase.PLANNING, success=True, message="Spec creation complete"
|
||||
)
|
||||
|
||||
# Load task metadata to check requireReviewBeforeCoding setting
|
||||
task_metadata_file = self.spec_dir / "task_metadata.json"
|
||||
require_review_before_coding = False
|
||||
if task_metadata_file.exists():
|
||||
with open(task_metadata_file, encoding="utf-8") as f:
|
||||
task_metadata = json.load(f)
|
||||
require_review_before_coding = task_metadata.get(
|
||||
"requireReviewBeforeCoding", False
|
||||
)
|
||||
|
||||
# Emit PLANNING_COMPLETE event for XState machine transition
|
||||
# This signals the frontend that spec creation is done
|
||||
task_emitter = TaskEventEmitter.from_spec_dir(self.spec_dir)
|
||||
task_emitter.emit(
|
||||
"PLANNING_COMPLETE",
|
||||
{
|
||||
"hasSubtasks": False, # Spec creation doesn't have subtasks yet
|
||||
"subtaskCount": 0,
|
||||
"requireReviewBeforeCoding": require_review_before_coding,
|
||||
},
|
||||
)
|
||||
|
||||
# === HUMAN REVIEW CHECKPOINT ===
|
||||
return self._run_review_checkpoint(auto_approve)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# XState Task State Machine Migration - Summary
|
||||
|
||||
**Issue:** #1338
|
||||
**PR:** #1575
|
||||
**Date:** 2026-01-28
|
||||
**Branch:** fix/1524-xstate-clean
|
||||
|
||||
## Overview
|
||||
|
||||
Migrated task status management from scattered decision logic across multiple handler files to a centralized XState v5 state machine. This eliminates race conditions, inconsistent status updates, and makes the task lifecycle formally defined and testable.
|
||||
|
||||
## Critical Dependencies & Blockers
|
||||
|
||||
### 1. Windows Credential Manager Fix (Required for Testing)
|
||||
**PR:** #1569 - fix(windows): fix Windows Credential Manager authentication
|
||||
**Issue:** #1525
|
||||
|
||||
This PR includes changes that depend on the Windows authentication fix. We could not complete end-to-end testing without this fix in place. If a different solution is implemented for #1525, we can remove these changes and resubmit.
|
||||
|
||||
### 2. spec_runner.py Project Detection Fix
|
||||
**Issue:** #1570 - spec_runner.py incorrectly detects auto-claude project as source directory
|
||||
|
||||
We encountered and fixed this bug during development as it was blocking our test workflow. The fix is included in this PR.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| Phase 1 | Create XState machine definition (task-machine.ts) | ✅ Complete |
|
||||
| Phase 2 | Create TaskStateManager singleton wrapper | ✅ Complete |
|
||||
| Phase 3 | Integrate into agent-events-handlers.ts | ⏸️ Partially done |
|
||||
| Phase 4 | Remove legacy TaskStateMachine class | ❌ Not started |
|
||||
|
||||
### Why We Stopped at Phase 2
|
||||
|
||||
The original scope was to introduce XState as the new state management approach. Full integration (Phase 3-4) requires:
|
||||
|
||||
- Extensive refactoring of agent-events-handlers.ts to remove all legacy decision logic
|
||||
- Removing the old TaskStateMachine class entirely
|
||||
- Migration of all status persistence to go through XState
|
||||
|
||||
We delivered Phases 1-2 to establish the foundation. The current state has both systems running in parallel with XState as primary:
|
||||
|
||||
- **XState is primary:** When TaskStateManager returns a valid state transition, that decision is used
|
||||
- **Legacy as fallback:** The old TaskStateMachine logic only applies when XState doesn't produce a decision
|
||||
- **Safe rollback:** If XState causes issues, the legacy system is still present and can take over
|
||||
|
||||
This dual-system approach allows:
|
||||
- Validation that XState produces correct state transitions in production
|
||||
- Safe rollback if issues arise
|
||||
- Incremental adoption path for Phase 3-4
|
||||
|
||||
## What Changed
|
||||
|
||||
### Before (Old Architecture)
|
||||
- Status decisions scattered across agent-events-handlers.ts, execution-handlers.ts, worktree-handlers.ts
|
||||
- `validateStatusTransition()` function with complex conditional logic
|
||||
- TaskStateMachine class that was essentially an event emitter wrapper
|
||||
- Multiple places persisting status to implementation_plan.json
|
||||
- Race conditions possible when multiple handlers tried to update status
|
||||
|
||||
### After (New Architecture)
|
||||
- **Single source of truth:** TaskStateManager (XState-based singleton)
|
||||
- **Formal state machine:** taskMachine with explicit states and transitions
|
||||
- **Centralized persistence:** Status written to JSON from one place
|
||||
- **Testable:** Unit tests verify all state transitions
|
||||
- **Observable:** XState actors can be inspected/visualized
|
||||
|
||||
## State Machine States
|
||||
|
||||
```
|
||||
backlog → planning → coding → qa_review → qa_fixing → human_review → done
|
||||
↘ plan_review ↗ ↓
|
||||
error
|
||||
```
|
||||
|
||||
| State | Maps to Legacy Status | reviewReason |
|
||||
|-------|----------------------|--------------|
|
||||
| backlog | backlog | - |
|
||||
| planning | in_progress | - |
|
||||
| coding | in_progress | - |
|
||||
| plan_review | human_review | plan_review |
|
||||
| qa_review | ai_review | - |
|
||||
| qa_fixing | ai_review | - |
|
||||
| human_review | human_review | completed or stopped |
|
||||
| creating_pr | human_review | completed |
|
||||
| pr_created | pr_created | - |
|
||||
| error | human_review | errors |
|
||||
| done | done | - |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/frontend/src/shared/state-machines/task-machine.ts` | XState machine definition |
|
||||
| `apps/frontend/src/main/task-state-manager.ts` | Singleton service wrapping XState actors |
|
||||
| `apps/frontend/src/shared/state-machines/__tests__/task-machine.test.ts` | State machine unit tests (35 tests) |
|
||||
| `apps/frontend/src/main/__tests__/task-state-manager.test.ts` | Manager service unit tests (20 tests) |
|
||||
| `apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts` | Refactored to call TaskStateManager |
|
||||
|
||||
## Events
|
||||
|
||||
The state machine responds to these events:
|
||||
|
||||
| Event | Triggered By |
|
||||
|-------|-------------|
|
||||
| PLANNING_STARTED | Execution progress phase=planning |
|
||||
| PLANNING_COMPLETE | Execution progress moving past planning |
|
||||
| PLAN_APPROVED | User clicks "Proceed to Coding" from plan_review |
|
||||
| CODING_STARTED | Execution progress phase=coding |
|
||||
| QA_STARTED | Execution progress phase=qa_review |
|
||||
| QA_PASSED | Execution progress phase=complete |
|
||||
| QA_FAILED | Execution progress phase=qa_fixing |
|
||||
| PROCESS_EXITED | Agent process exit event |
|
||||
| USER_STOPPED | User clicks stop |
|
||||
| USER_RESUMED | User resumes task |
|
||||
| MARK_DONE | User marks task as done |
|
||||
| CREATE_PR | User initiates PR creation |
|
||||
| PR_CREATED | PR successfully created |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise post-merge:
|
||||
|
||||
1. **Quick rollback:** `git revert <merge-commit>`
|
||||
2. **Restore point:** Commit 3e5f004a has old code intact
|
||||
3. **Legacy persistence still works:** implementation_plan.json continues to store status
|
||||
|
||||
## Testing
|
||||
|
||||
| Test Suite | Result |
|
||||
|------------|--------|
|
||||
| Frontend unit tests | ✅ 2579 passed |
|
||||
| TypeScript strict mode | ✅ Pass |
|
||||
| Biome lint | ✅ Pass |
|
||||
| XState machine tests | ✅ 35 passed |
|
||||
| TaskStateManager tests | ✅ 20 passed |
|
||||
| Python backend tests | ✅ Pass |
|
||||
|
||||
## Session Fixes (2026-01-28)
|
||||
|
||||
### Fixed Issues
|
||||
|
||||
1. **Badge showing "Needs Review" instead of "Complete"** - Added `effectiveReviewReason` logic in TaskCard.tsx that sets 'completed' when phase === 'complete'
|
||||
|
||||
2. **Task showing "Incomplete" badge for plan_review** - Added 'plan_review' to exclusion list in `isIncompleteHumanReview`
|
||||
|
||||
3. **Missing "Proceed to Coding" button** - Restored in WorkspaceMessages.tsx for plan_review flow
|
||||
|
||||
4. **Wrong XState event for plan_review → coding** - Fixed to send PLAN_APPROVED instead of PLANNING_STARTED when starting from plan_review state
|
||||
|
||||
5. **Stuck detection logic** - Reverted useTaskDetail.ts to simpler logic from working branch (only skip 'planning' phase, 2s timeout)
|
||||
|
||||
## Outstanding Items (Requires PM Input)
|
||||
|
||||
### 1. Future: Subtask XState Migration
|
||||
- **Issue:** `subtask.status` is checked directly in UI code
|
||||
- **Recommendation:** Should be managed by state machine for consistency
|
||||
- **Status:** Out of scope for current PR, document for future work
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- Add @stately-ai/inspect for runtime devtools
|
||||
- **Subtask state management** - Track individual subtask states within the machine using XState parallel states
|
||||
- Add more granular QA states (qa_round_1, qa_round_2, etc.)
|
||||
- Complete Phase 3-4: Full integration and removal of legacy TaskStateMachine class
|
||||
|
||||
## Visualization
|
||||
|
||||
The state machine can be visualized at [Stately.ai Editor](https://stately.ai/editor):
|
||||
1. Paste the contents of task-machine.ts
|
||||
2. Click "Visualize" to see the state diagram
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
|
||||
// Load .env file for build-time constants (Sentry DSN, etc.)
|
||||
dotenvConfig({ path: resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Sentry configuration embedded at build time.
|
||||
@@ -43,7 +47,9 @@ export default defineConfig({
|
||||
'debug',
|
||||
'ms',
|
||||
// Minimatch for glob pattern matching in worktree handlers
|
||||
'minimatch'
|
||||
'minimatch',
|
||||
// XState for task state machine
|
||||
'xstate'
|
||||
]
|
||||
})],
|
||||
build: {
|
||||
|
||||
+37
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.5",
|
||||
"version": "2.7.6-beta.2",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
@@ -36,6 +36,8 @@
|
||||
"package:win": "node scripts/package-with-python.cjs --win",
|
||||
"package:linux": "node scripts/package-with-python.cjs --linux",
|
||||
"package:flatpak": "node scripts/package-with-python.cjs --linux flatpak",
|
||||
"verify:linux": "node scripts/verify-linux-packages.cjs dist",
|
||||
"test:verify-linux": "node --test scripts/verify-linux-packages.test.mjs",
|
||||
"start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app",
|
||||
"start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"",
|
||||
"start:packaged:linux": "./dist/linux-unpacked/auto-claude",
|
||||
@@ -94,10 +96,13 @@
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.2.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"xstate": "^5.26.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -165,14 +170,6 @@
|
||||
"from": "resources/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/python",
|
||||
"to": "python"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/site-packages",
|
||||
"to": "python-site-packages"
|
||||
},
|
||||
{
|
||||
"from": "../backend",
|
||||
"to": "backend",
|
||||
@@ -202,6 +199,16 @@
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/python",
|
||||
"to": "python"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/site-packages",
|
||||
"to": "python-site-packages"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
@@ -209,6 +216,16 @@
|
||||
"target": [
|
||||
"nsis",
|
||||
"zip"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/python",
|
||||
"to": "python"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/site-packages",
|
||||
"to": "python-site-packages"
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
@@ -218,7 +235,17 @@
|
||||
"deb",
|
||||
"flatpak"
|
||||
],
|
||||
"category": "Development"
|
||||
"category": "Development",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/python",
|
||||
"to": "python"
|
||||
},
|
||||
{
|
||||
"from": "python-runtime/${os}-${arch}/site-packages",
|
||||
"to": "python-site-packages"
|
||||
}
|
||||
]
|
||||
},
|
||||
"flatpak": {
|
||||
"runtime": "org.freedesktop.Platform",
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Linux package contents to ensure alignment between AppImage, deb, and Flatpak.
|
||||
*
|
||||
* This script extracts and inspects each Linux package format to verify that critical
|
||||
* files (Python binary, backend code, Python packages) are present and correctly bundled.
|
||||
*
|
||||
* Usage: node scripts/verify-linux-packages.cjs [dist-dir]
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
// Critical Python packages that must be present
|
||||
const CRITICAL_PACKAGES = [
|
||||
'secretstorage', // Linux OAuth token storage
|
||||
'pydantic_core',
|
||||
'claude_agent_sdk',
|
||||
'dotenv',
|
||||
];
|
||||
|
||||
// Minimum expected Flatpak file size (50 MB)
|
||||
// Flatpak files are large OCI archives; anything smaller is suspicious
|
||||
// Based on observed minimum sizes of valid builds
|
||||
const FLATPAK_MIN_SIZE_MB = 50;
|
||||
|
||||
// Colors for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
function log(message, color = colors.reset) {
|
||||
console.log(`${color}${message}${colors.reset}`);
|
||||
}
|
||||
|
||||
function logSuccess(message) {
|
||||
log(`✓ ${message}`, colors.green);
|
||||
}
|
||||
|
||||
function logError(message) {
|
||||
log(`✗ ${message}`, colors.red);
|
||||
}
|
||||
|
||||
function logWarning(message) {
|
||||
log(`⚠ ${message}`, colors.yellow);
|
||||
}
|
||||
|
||||
function logInfo(message) {
|
||||
log(`ℹ ${message}`, colors.cyan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command exists
|
||||
* Uses 'which' directly without shell interpolation to prevent command injection
|
||||
*/
|
||||
function commandExists(cmd) {
|
||||
const result = spawnSync('which', [cmd], { stdio: 'ignore' });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all Linux packages in the dist directory
|
||||
*/
|
||||
function findPackages(distDir) {
|
||||
const packages = {
|
||||
appImage: null,
|
||||
deb: null,
|
||||
flatpak: null,
|
||||
};
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
logError(`Distribution directory not found: ${distDir}`);
|
||||
return packages;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(distDir);
|
||||
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(distDir, file);
|
||||
|
||||
if (file.endsWith('.AppImage')) {
|
||||
if (!packages.appImage) {
|
||||
packages.appImage = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple AppImage files found, using first: ${path.basename(packages.appImage)}`);
|
||||
}
|
||||
} else if (file.endsWith('.deb')) {
|
||||
if (!packages.deb) {
|
||||
packages.deb = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple deb files found, using first: ${path.basename(packages.deb)}`);
|
||||
}
|
||||
} else if (file.endsWith('.flatpak')) {
|
||||
if (!packages.flatpak) {
|
||||
packages.flatpak = fullPath;
|
||||
} else {
|
||||
logWarning(`Multiple Flatpak files found, using first: ${path.basename(packages.flatpak)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common file list verification logic
|
||||
* @param {string[]} files - List of files from package
|
||||
* @param {string} packageType - Type of package (for error messages)
|
||||
* @returns {Object} Verification result with verified flag and issues array
|
||||
*
|
||||
* File formats:
|
||||
* - AppImage (bsdtar): './resources/python', './resources/backend/file.py'
|
||||
* - deb (dpkg-deb -c): 'resources/python', 'resources/backend/file.py' (in last column)
|
||||
*/
|
||||
function verifyFileList(files, packageType) {
|
||||
const issues = [];
|
||||
|
||||
// Normalize paths by removing trailing slashes (archive tools commonly add these)
|
||||
const normalizePath = (p) => p.replace(/\/+$/, '');
|
||||
|
||||
// Check for Python binary directory
|
||||
// AppImage: './resources/python' or './resources/python/' (with trailing slash)
|
||||
// deb: 'resources/python' or 'resources/python/' (with trailing slash)
|
||||
// Must NOT match 'resources/python-site-packages'
|
||||
const pythonBinFound = files.some((f) => {
|
||||
const normalized = normalizePath(f);
|
||||
return (
|
||||
(normalized === './resources/python' ||
|
||||
normalized === 'resources/python' ||
|
||||
normalized.endsWith('/resources/python')) &&
|
||||
!f.includes('python-site-packages')
|
||||
);
|
||||
});
|
||||
if (!pythonBinFound) {
|
||||
issues.push(`Python binary directory not found in ${packageType}`);
|
||||
}
|
||||
|
||||
// Check for backend directory (must be under resources/)
|
||||
const backendFound = files.some((f) => {
|
||||
const normalized = normalizePath(f);
|
||||
return (
|
||||
f.includes('./resources/backend/') ||
|
||||
f.includes('resources/backend/') ||
|
||||
normalized === './resources/backend' ||
|
||||
normalized === 'resources/backend'
|
||||
);
|
||||
});
|
||||
if (!backendFound) {
|
||||
issues.push(`Backend directory not found in ${packageType}`);
|
||||
}
|
||||
|
||||
// Check for critical Python packages (must be under python-site-packages/)
|
||||
for (const pkg of CRITICAL_PACKAGES) {
|
||||
// Match: './resources/python-site-packages/secretstorage/__init__.py'
|
||||
// Match: 'resources/python-site-packages/secretstorage/__init__.py'
|
||||
// Don't match: '/some/other/path/secretstorage/'
|
||||
const found = files.some(
|
||||
(f) => f.includes(`python-site-packages/${pkg}/`) || f.includes(`python-site-packages/${pkg}.`),
|
||||
);
|
||||
if (!found) {
|
||||
issues.push(`Python package not found: ${pkg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
verified: issues.length === 0,
|
||||
issues,
|
||||
fileCount: files.filter((f) => f.trim()).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify AppImage contents using bsdtar (libarchive)
|
||||
*/
|
||||
function verifyAppImage(appImagePath) {
|
||||
logInfo(`Verifying AppImage: ${path.basename(appImagePath)}`);
|
||||
|
||||
// Check if bsdtar is available
|
||||
if (!commandExists('bsdtar')) {
|
||||
logWarning('bsdtar not found. Install with: sudo apt-get install libarchive-tools');
|
||||
logWarning('Skipping AppImage verification');
|
||||
return { verified: false, reason: 'bsdtar not available', critical: true };
|
||||
}
|
||||
|
||||
// Extract file list from AppImage using bsdtar
|
||||
const result = spawnSync('bsdtar', ['-t', '-f', appImagePath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings
|
||||
});
|
||||
|
||||
// Check for spawn errors (e.g., permission denied, memory issues)
|
||||
if (result.error) {
|
||||
logError(`Failed to execute bsdtar: ${result.error.message}`);
|
||||
return { verified: false, reason: `Command execution failed: ${result.error.message}` };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
logError(`Failed to read AppImage: ${result.stderr}`);
|
||||
return { verified: false, reason: 'Failed to extract file list' };
|
||||
}
|
||||
|
||||
const files = result.stdout.split('\n');
|
||||
return verifyFileList(files, 'AppImage');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify deb package contents
|
||||
*/
|
||||
function verifyDeb(debPath) {
|
||||
logInfo(`Verifying deb package: ${path.basename(debPath)}`);
|
||||
|
||||
// Check if dpkg is available
|
||||
if (!commandExists('dpkg-deb')) {
|
||||
logWarning('dpkg-deb not found. Skipping deb verification');
|
||||
return { verified: false, reason: 'dpkg-deb not available', critical: true };
|
||||
}
|
||||
|
||||
// List contents of deb package
|
||||
const result = spawnSync('dpkg-deb', ['-c', debPath], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings
|
||||
});
|
||||
|
||||
// Check for spawn errors (e.g., permission denied, memory issues)
|
||||
if (result.error) {
|
||||
logError(`Failed to execute dpkg-deb: ${result.error.message}`);
|
||||
return { verified: false, reason: `Command execution failed: ${result.error.message}` };
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
logError(`Failed to read deb package: ${result.stderr}`);
|
||||
return { verified: false, reason: 'Failed to extract file list' };
|
||||
}
|
||||
|
||||
const files = result.stdout.split('\n');
|
||||
return verifyFileList(files, 'deb package');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Flatpak package contents
|
||||
* Note: Flatpak is more complex to inspect, so we do basic validation
|
||||
*/
|
||||
function verifyFlatpak(flatpakPath) {
|
||||
logInfo(`Verifying Flatpak package: ${path.basename(flatpakPath)}`);
|
||||
|
||||
const issues = [];
|
||||
|
||||
// Check if flatpak command is available for detailed validation
|
||||
const hasFlatpakCli = commandExists('flatpak');
|
||||
if (!hasFlatpakCli) {
|
||||
logWarning('flatpak command not found. Skipping detailed Flatpak verification');
|
||||
// Continue with basic file existence/size checks
|
||||
}
|
||||
|
||||
// Check if file exists and is not empty
|
||||
if (!fs.existsSync(flatpakPath)) {
|
||||
return { verified: false, issues: ['Flatpak file does not exist'] };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(flatpakPath);
|
||||
if (stats.size === 0) {
|
||||
return { verified: false, issues: ['Flatpak file is empty'] };
|
||||
}
|
||||
|
||||
// Flatpak files are large OCI archives, so we just verify file size and basic structure
|
||||
// Detailed content inspection would require mounting or extracting the flatpak
|
||||
if (stats.size < FLATPAK_MIN_SIZE_MB * 1024 * 1024) {
|
||||
// Less than minimum size is suspicious
|
||||
issues.push(
|
||||
`Flatpak file seems too small (${(stats.size / 1024 / 1024).toFixed(2)} MB, expected at least ${FLATPAK_MIN_SIZE_MB} MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
verified: issues.length === 0,
|
||||
issues,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main verification function
|
||||
*/
|
||||
function main() {
|
||||
const distDir = process.argv[2] || path.join(__dirname, '..', 'dist');
|
||||
|
||||
log('\n=== Linux Package Verification ===\n', colors.blue);
|
||||
logInfo(`Distribution directory: ${distDir}\n`);
|
||||
|
||||
const packages = findPackages(distDir);
|
||||
|
||||
// Report found packages
|
||||
if (packages.appImage) {
|
||||
logSuccess(`Found AppImage: ${path.basename(packages.appImage)}`);
|
||||
} else {
|
||||
logWarning('No AppImage found');
|
||||
}
|
||||
|
||||
if (packages.deb) {
|
||||
logSuccess(`Found deb: ${path.basename(packages.deb)}`);
|
||||
} else {
|
||||
logWarning('No deb package found');
|
||||
}
|
||||
|
||||
if (packages.flatpak) {
|
||||
logSuccess(`Found Flatpak: ${path.basename(packages.flatpak)}`);
|
||||
} else {
|
||||
logWarning('No Flatpak package found');
|
||||
}
|
||||
|
||||
if (!packages.appImage && !packages.deb && !packages.flatpak) {
|
||||
logError('\nNo Linux packages found to verify!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('');
|
||||
|
||||
// Verify each package
|
||||
const results = {};
|
||||
|
||||
if (packages.appImage) {
|
||||
results.appImage = verifyAppImage(packages.appImage);
|
||||
}
|
||||
|
||||
if (packages.deb) {
|
||||
results.deb = verifyDeb(packages.deb);
|
||||
}
|
||||
|
||||
if (packages.flatpak) {
|
||||
results.flatpak = verifyFlatpak(packages.flatpak);
|
||||
}
|
||||
|
||||
// Print results
|
||||
log('\n=== Verification Results ===\n', colors.blue);
|
||||
|
||||
let hasFailures = false;
|
||||
let hasCriticalSkips = false;
|
||||
|
||||
for (const [type, result] of Object.entries(results)) {
|
||||
if (result.reason) {
|
||||
if (result.critical) {
|
||||
logError(`${type}: CRITICAL - SKIPPED (${result.reason})`);
|
||||
hasCriticalSkips = true;
|
||||
} else {
|
||||
logWarning(`${type}: SKIPPED (${result.reason})`);
|
||||
}
|
||||
} else if (result.verified) {
|
||||
logSuccess(`${type}: VERIFIED`);
|
||||
if (result.fileCount) {
|
||||
logInfo(` Files: ${result.fileCount}`);
|
||||
}
|
||||
if (result.size) {
|
||||
logInfo(` Size: ${(result.size / 1024 / 1024).toFixed(2)} MB`);
|
||||
}
|
||||
} else {
|
||||
logError(`${type}: FAILED`);
|
||||
hasFailures = true;
|
||||
for (const issue of result.issues || []) {
|
||||
logError(` - ${issue}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log('');
|
||||
|
||||
if (hasFailures || hasCriticalSkips) {
|
||||
logError('\n=== VERIFICATION FAILED ===\n');
|
||||
if (hasFailures) {
|
||||
log('Some packages are missing critical files. This will cause runtime errors.\n', colors.red);
|
||||
}
|
||||
if (hasCriticalSkips) {
|
||||
log('Some packages could not be verified due to missing required tools.\n', colors.red);
|
||||
log('Install required tools:\n', colors.red);
|
||||
log(' - bsdtar: sudo apt-get install libarchive-tools\n', colors.red);
|
||||
log(' - dpkg-deb: sudo apt-get install dpkg\n', colors.red);
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
logSuccess('\n=== ALL PACKAGES VERIFIED ===\n');
|
||||
log('All Linux packages contain the required files.\n', colors.green);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Only run main if this file is executed directly (not imported)
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = {
|
||||
CRITICAL_PACKAGES,
|
||||
findPackages,
|
||||
verifyFileList,
|
||||
verifyAppImage,
|
||||
verifyDeb,
|
||||
verifyFlatpak,
|
||||
};
|
||||
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Tests for verify-linux-packages.cjs
|
||||
*
|
||||
* These tests cover the core logic by calling the actual exported functions.
|
||||
*/
|
||||
|
||||
import { describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Get child_process and save original spawnSync
|
||||
const childProcess = require('child_process');
|
||||
const originalSpawnSync = childProcess.spawnSync;
|
||||
|
||||
// Helper to reload the verification module with a mocked spawnSync
|
||||
function loadWithMockedSpawnSync(mockFn) {
|
||||
// Set the mock before requiring
|
||||
childProcess.spawnSync = mockFn;
|
||||
// Clear the module cache
|
||||
delete require.cache[require.resolve('./verify-linux-packages.cjs')];
|
||||
// Re-require the module
|
||||
return require('./verify-linux-packages.cjs');
|
||||
}
|
||||
|
||||
function restoreSpawnSync() {
|
||||
childProcess.spawnSync = originalSpawnSync;
|
||||
delete require.cache[require.resolve('./verify-linux-packages.cjs')];
|
||||
}
|
||||
|
||||
// Load the module normally for tests that don't need spawnSync mocking
|
||||
const {
|
||||
CRITICAL_PACKAGES,
|
||||
findPackages,
|
||||
verifyFileList,
|
||||
verifyFlatpak,
|
||||
} = require('./verify-linux-packages.cjs');
|
||||
|
||||
describe('verify-linux-packages', () => {
|
||||
describe('package finding logic', () => {
|
||||
it('should identify all three Linux package types', () => {
|
||||
// Test that findPackages correctly identifies .AppImage, .deb, and .flatpak files
|
||||
const mockFiles = [
|
||||
'Auto-Claude-2.7.5-linux-x86_64.AppImage',
|
||||
'auto-claude_2.7.5_amd64.deb',
|
||||
'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak',
|
||||
'latest-mac.yml',
|
||||
'latest.yml',
|
||||
];
|
||||
|
||||
const distDir = '/test/dist';
|
||||
|
||||
// Mock fs.existsSync to return true (directory exists)
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
// Mock fs.readdirSync to return our test files
|
||||
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
|
||||
|
||||
try {
|
||||
const result = findPackages(distDir);
|
||||
|
||||
// Verify the expected results
|
||||
assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage');
|
||||
assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb');
|
||||
assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak');
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
readdirSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing packages gracefully', () => {
|
||||
// Test behavior when packages are missing
|
||||
const mockFiles = ['latest-mac.yml', 'latest.yml'];
|
||||
const distDir = '/test/dist';
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
|
||||
|
||||
try {
|
||||
const result = findPackages(distDir);
|
||||
|
||||
assert.equal(result.appImage, null);
|
||||
assert.equal(result.deb, null);
|
||||
assert.equal(result.flatpak, null);
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
readdirSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing dist directory', () => {
|
||||
// Test behavior when dist directory doesn't exist
|
||||
const distDir = '/test/dist';
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false));
|
||||
|
||||
try {
|
||||
const result = findPackages(distDir);
|
||||
|
||||
// Should return empty packages object without error
|
||||
assert.equal(result.appImage, null);
|
||||
assert.equal(result.deb, null);
|
||||
assert.equal(result.flatpak, null);
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about duplicate packages', () => {
|
||||
// Test behavior when multiple packages of same type exist
|
||||
const mockFiles = [
|
||||
'Auto-Claude-2.7.5-linux-x86_64.AppImage',
|
||||
'Auto-Claude-2.7.5-linux-x86_64.AppImage', // Duplicate
|
||||
'auto-claude_2.7.5_amd64.deb',
|
||||
'auto-claude_2.7.5_amd64.deb', // Duplicate
|
||||
'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak',
|
||||
];
|
||||
const distDir = '/test/dist';
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
|
||||
|
||||
try {
|
||||
const result = findPackages(distDir);
|
||||
|
||||
// Should still find packages, using first occurrence
|
||||
assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage');
|
||||
assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb');
|
||||
assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak');
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
readdirSync.mock.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('critical packages list', () => {
|
||||
it('should contain all required Linux packages', () => {
|
||||
assert.ok(CRITICAL_PACKAGES.includes('secretstorage'), 'secretstorage must be present for Linux OAuth');
|
||||
assert.ok(CRITICAL_PACKAGES.includes('pydantic_core'), 'pydantic_core must be present');
|
||||
assert.ok(CRITICAL_PACKAGES.includes('claude_agent_sdk'), 'claude_agent_sdk must be present');
|
||||
assert.ok(CRITICAL_PACKAGES.includes('dotenv'), 'dotenv must be present');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file content verification logic', () => {
|
||||
it('should detect Python binary in file list', () => {
|
||||
// AppImage format uses './' prefix
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
assert.ok(result.verified, 'Should detect Python binary directory');
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
it('should detect Python binary with trailing slashes', () => {
|
||||
// Archive tools like bsdtar/dpkg-deb commonly output directories with trailing slashes
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python/', // Trailing slash
|
||||
'resources/backend/', // Trailing slash
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
assert.ok(result.verified, 'Should detect Python binary directory with trailing slash');
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
it('should detect backend directory in file list', () => {
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
assert.ok(result.verified, 'Should detect backend directory');
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
it('should detect critical Python packages', () => {
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
assert.ok(result.verified, 'Should detect all critical packages');
|
||||
assert.equal(result.issues.length, 0);
|
||||
});
|
||||
|
||||
it('should report missing packages', () => {
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
|
||||
assert.ok(!result.verified, 'Should fail verification');
|
||||
assert.ok(result.issues.includes('Python package not found: secretstorage'));
|
||||
assert.ok(result.issues.includes('Python package not found: pydantic_core'));
|
||||
assert.ok(result.issues.includes('Python package not found: claude_agent_sdk'));
|
||||
assert.ok(!result.issues.some((i) => i.includes('dotenv')));
|
||||
});
|
||||
|
||||
it('should not match python-site-packages when looking for python binary', () => {
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
// Note: NO './resources/python' entry
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
|
||||
assert.ok(!result.verified, 'Should fail verification');
|
||||
assert.ok(result.issues.some((i) => i.includes('Python binary directory not found')));
|
||||
});
|
||||
|
||||
it('should not match unrelated paths when looking for packages', () => {
|
||||
const mockFiles = [
|
||||
'usr/bin/auto-claude',
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
// These paths end with package names but are NOT under python-site-packages
|
||||
'./some/other/path/secretstorage/file.txt',
|
||||
'./unrelated/dotenv/config',
|
||||
'./another/pydantic_core/standalone/__init__.py',
|
||||
];
|
||||
|
||||
const result = verifyFileList(mockFiles, 'test-package');
|
||||
|
||||
assert.ok(!result.verified, 'Should fail verification');
|
||||
assert.ok(result.issues.some((i) => i.includes('Python package not found: secretstorage')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Flatpak file validation', () => {
|
||||
it('should reject empty Flatpak files', () => {
|
||||
const flatpakPath = '/test/app.flatpak';
|
||||
const mockStat = { size: 0 };
|
||||
|
||||
// Mock fs.existsSync to return true
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
// Mock fs.statSync to return empty file stats
|
||||
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
|
||||
|
||||
try {
|
||||
const result = verifyFlatpak(flatpakPath);
|
||||
|
||||
assert.ok(!result.verified, 'Should reject empty Flatpak files');
|
||||
assert.ok(result.issues.includes('Flatpak file is empty'));
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
statSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should warn about suspiciously small Flatpak files', () => {
|
||||
const flatpakPath = '/test/app.flatpak';
|
||||
const mockStat = { size: 10 * 1024 * 1024 }; // 10 MB
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
|
||||
|
||||
try {
|
||||
const result = verifyFlatpak(flatpakPath);
|
||||
|
||||
assert.ok(!result.verified, 'Should fail verification for too-small files');
|
||||
assert.ok(result.issues.some((i) => i.includes('too small')));
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
statSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept reasonable Flatpak file sizes', () => {
|
||||
const flatpakPath = '/test/app.flatpak';
|
||||
const mockStat = { size: 133 * 1024 * 1024 }; // 133 MB (typical size)
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
|
||||
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
|
||||
|
||||
try {
|
||||
const result = verifyFlatpak(flatpakPath);
|
||||
|
||||
assert.ok(result.verified, 'Should accept reasonable Flatpak file sizes');
|
||||
assert.equal(result.issues.length, 0);
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
statSync.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-existent Flatpak files', () => {
|
||||
const flatpakPath = '/test/nonexistent.flatpak';
|
||||
|
||||
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false));
|
||||
|
||||
try {
|
||||
const result = verifyFlatpak(flatpakPath);
|
||||
|
||||
assert.ok(!result.verified, 'Should reject non-existent Flatpak files');
|
||||
assert.ok(result.issues.includes('Flatpak file does not exist'));
|
||||
} finally {
|
||||
existsSync.mock.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppImage verification', () => {
|
||||
const appImagePath = '/test/Auto-Claude-2.7.5-linux-x86_64.AppImage';
|
||||
|
||||
it('should successfully verify valid AppImage', () => {
|
||||
const mockFiles = [
|
||||
'./resources/python',
|
||||
'./resources/backend/core/client.py',
|
||||
'./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const mockFn = (cmd, args) => {
|
||||
if (cmd === 'which' && args[0] === 'bsdtar') {
|
||||
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '' };
|
||||
}
|
||||
if (cmd === 'bsdtar') {
|
||||
return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyAppImage(appImagePath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(result.verified, 'Should verify valid AppImage');
|
||||
assert.equal(result.issues.length, 0);
|
||||
assert.equal(result.fileCount, mockFiles.length);
|
||||
});
|
||||
|
||||
it('should handle spawn errors (OS-level failures)', () => {
|
||||
const spawnError = new Error('EACCES: permission denied');
|
||||
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined };
|
||||
}
|
||||
if (cmd === 'bsdtar') {
|
||||
return { status: null, stdout: '', stderr: '', error: spawnError };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyAppImage(appImagePath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail on spawn error');
|
||||
assert.ok(result.reason.includes('Command execution failed'));
|
||||
assert.ok(result.reason.includes('permission denied'));
|
||||
});
|
||||
|
||||
it('should handle non-zero exit status from bsdtar', () => {
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined };
|
||||
}
|
||||
if (cmd === 'bsdtar') {
|
||||
return { status: 1, stdout: '', stderr: 'bsdtar: Error: Not an AppImage file', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyAppImage(appImagePath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail on non-zero status');
|
||||
assert.equal(result.reason, 'Failed to extract file list');
|
||||
});
|
||||
|
||||
it('should handle missing bsdtar tool', () => {
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 1, stdout: '', stderr: '', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyAppImage(appImagePath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail when bsdtar is missing');
|
||||
assert.equal(result.reason, 'bsdtar not available');
|
||||
assert.ok(result.critical, 'Should be marked as critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deb package verification', () => {
|
||||
const debPath = '/test/auto-claude_2.7.5_amd64.deb';
|
||||
|
||||
it('should successfully verify valid deb package', () => {
|
||||
const mockFiles = [
|
||||
'drwxr-xr-x root/root 0 2025-01-01 00:00 ./resources/python',
|
||||
'-rw-r--r-- root/root 1234 2025-01-01 00:00 ./resources/backend/core/client.py',
|
||||
'-rw-r--r-- root/root 567 2025-01-01 00:00 ./resources/python-site-packages/secretstorage/__init__.py',
|
||||
'-rw-r--r-- root/root 789 2025-01-01 00:00 ./resources/python-site-packages/pydantic_core/__init__.py',
|
||||
'-rw-r--r-- root/root 456 2025-01-01 00:00 ./resources/python-site-packages/claude_agent_sdk/__init__.py',
|
||||
'-rw-r--r-- root/root 321 2025-01-01 00:00 ./resources/python-site-packages/dotenv/__init__.py',
|
||||
];
|
||||
|
||||
const mockFn = (cmd, args) => {
|
||||
if (cmd === 'which' && args[0] === 'dpkg-deb') {
|
||||
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
|
||||
}
|
||||
if (cmd === 'dpkg-deb') {
|
||||
return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyDeb(debPath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(result.verified, 'Should verify valid deb package');
|
||||
assert.equal(result.issues.length, 0);
|
||||
assert.equal(result.fileCount, mockFiles.length);
|
||||
});
|
||||
|
||||
it('should handle spawn errors (OS-level failures)', () => {
|
||||
const spawnError = new Error('ENOMEM: Cannot allocate memory');
|
||||
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
|
||||
}
|
||||
if (cmd === 'dpkg-deb') {
|
||||
return { status: null, stdout: '', stderr: '', error: spawnError };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyDeb(debPath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail on spawn error');
|
||||
assert.ok(result.reason.includes('Command execution failed'));
|
||||
assert.ok(result.reason.includes('Cannot allocate memory'));
|
||||
});
|
||||
|
||||
it('should handle non-zero exit status from dpkg-deb', () => {
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
|
||||
}
|
||||
if (cmd === 'dpkg-deb') {
|
||||
return { status: 2, stdout: '', stderr: 'dpkg-deb: error: cannot read archive', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyDeb(debPath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail on non-zero status');
|
||||
assert.equal(result.reason, 'Failed to extract file list');
|
||||
});
|
||||
|
||||
it('should handle missing dpkg-deb tool', () => {
|
||||
const mockFn = (cmd) => {
|
||||
if (cmd === 'which') {
|
||||
return { status: 1, stdout: '', stderr: '', error: undefined };
|
||||
}
|
||||
return { status: 1, stderr: 'Unknown command' };
|
||||
};
|
||||
|
||||
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
|
||||
const result = verifyDeb(debPath);
|
||||
|
||||
restoreSpawnSync();
|
||||
|
||||
assert.ok(!result.verified, 'Should fail when dpkg-deb is missing');
|
||||
assert.equal(result.reason, 'dpkg-deb not available');
|
||||
assert.ok(result.critical, 'Should be marked as critical');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -396,7 +396,7 @@ describe('E2E Smoke Tests', () => {
|
||||
statusHandler({}, 'task-001', 'in_progress');
|
||||
}
|
||||
|
||||
expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined);
|
||||
expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined, undefined);
|
||||
|
||||
// Cleanup listeners
|
||||
cleanupProgress();
|
||||
@@ -656,6 +656,7 @@ describe('E2E Smoke Tests', () => {
|
||||
index + 1,
|
||||
'task-001',
|
||||
status,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types';
|
||||
import type { ClaudeProfile, IPCResult, } from '../../shared/types';
|
||||
|
||||
// Test directories - use secure temp directory with random suffix
|
||||
let TEST_DIR: string;
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stdout data (must include newline for buffered output processing)
|
||||
mockStdout.emit('data', Buffer.from('Test log output\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit log events from stderr', async () => {
|
||||
@@ -313,7 +313,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stderr data (must include newline for buffered output processing)
|
||||
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit exit event when process exits', async () => {
|
||||
@@ -329,8 +329,8 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process exit
|
||||
mockProcess.emit('exit', 0);
|
||||
|
||||
// Exit event includes taskId, exit code, and process type
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
|
||||
// Exit event includes taskId, exit code, process type, and optional projectId
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit error event when process errors', async () => {
|
||||
@@ -346,7 +346,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process error
|
||||
mockProcess.emit('error', new Error('Spawn failed'));
|
||||
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should kill task and remove from tracking', async () => {
|
||||
@@ -395,17 +395,16 @@ describe('Subprocess Spawn Integration', () => {
|
||||
expect(manager.getRunningTasks()).toHaveLength(2);
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Wait for spawn to complete (ensures exit handlers are attached)
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
// Emit exit events - both tasks share the same mockProcess, so both handlers fire
|
||||
// We emit twice to ensure both task handlers receive exit events
|
||||
mockProcess.emit('exit', 0);
|
||||
mockProcess.emit('exit', 0);
|
||||
|
||||
// Wait for promises to settle
|
||||
// Wait for both spawn promises to fully resolve — this ensures the exit
|
||||
// handlers are attached to mockProcess. A single setImmediate is NOT enough
|
||||
// on Windows CI because spawnProcess has async operations (getAPIProfileEnv,
|
||||
// getRecoveryCoordinator) between addProcess and the .on('exit') listener.
|
||||
// Waiting for the promises guarantees spawnProcess has completed fully.
|
||||
await Promise.allSettled([promise1, promise2]);
|
||||
|
||||
// Both tasks share the same mockProcess, so one emit fires both exit handlers
|
||||
mockProcess.emit('exit', 0);
|
||||
|
||||
// Wait for tasks to be removed from tracking (cleanup may be async)
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getRunningTasks()).toHaveLength(0);
|
||||
|
||||
@@ -241,12 +241,12 @@ describe('Task Lifecycle Integration', () => {
|
||||
)?.[1];
|
||||
|
||||
if (eventHandler) {
|
||||
eventHandler({}, 'task-001', 'spec_complete');
|
||||
eventHandler({}, 'task-001', 'spec_complete', undefined, undefined);
|
||||
}
|
||||
|
||||
// Verify callback was invoked with correct parameters (taskId, status, projectId)
|
||||
// Note: projectId is optional and undefined when not provided
|
||||
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined);
|
||||
// Verify callback was invoked with correct parameters (taskId, status, projectId, reviewReason)
|
||||
// Note: projectId/reviewReason are optional and undefined when not provided
|
||||
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined, undefined);
|
||||
});
|
||||
|
||||
it('should emit task:progress event with updated plan during spec creation', async () => {
|
||||
@@ -379,4 +379,4 @@ describe('Task Lifecycle Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('child_process', () => {
|
||||
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
|
||||
const sharedSyncMock = vi.fn();
|
||||
|
||||
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
|
||||
const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, callback: unknown) => {
|
||||
// Return a minimal ChildProcess-like object
|
||||
const childProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
@@ -86,7 +86,7 @@ const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callb
|
||||
return childProcess as unknown as import('child_process').ChildProcess;
|
||||
});
|
||||
|
||||
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
|
||||
const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => {
|
||||
// Return a minimal ChildProcess-like object
|
||||
const childProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
|
||||
@@ -61,15 +61,17 @@ import path from 'path';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
describe('isValidConfigDir - Security Validation', () => {
|
||||
let originalHomedir: string;
|
||||
let consoleWarnSpy: any;
|
||||
let _originalHomedir: string;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original homedir for restoration
|
||||
originalHomedir = os.homedir();
|
||||
_originalHomedir = os.homedir();
|
||||
|
||||
// Spy on console.warn to suppress warning output during tests
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -564,7 +564,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
|
||||
expect(result).toHaveProperty("success", true);
|
||||
const data = (result as { data: { theme: string } }).data;
|
||||
expect(data).toHaveProperty("theme", "system");
|
||||
expect(data).toHaveProperty("theme", "dark");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -689,7 +689,8 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
"task:statusChange",
|
||||
"task-1",
|
||||
"human_review",
|
||||
expect.any(String) // projectId for multi-project filtering
|
||||
expect.any(String), // projectId for multi-project filtering
|
||||
"errors"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +292,7 @@ describe('ProjectStore', () => {
|
||||
feature: 'Test Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
status: 'in_progress',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
@@ -338,6 +339,7 @@ describe('ProjectStore', () => {
|
||||
feature: 'Pending Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
status: 'backlog',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
@@ -377,6 +379,7 @@ describe('ProjectStore', () => {
|
||||
feature: 'Complete Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
status: 'ai_review',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
@@ -408,7 +411,7 @@ describe('ProjectStore', () => {
|
||||
expect(tasks[0].status).toBe('ai_review');
|
||||
});
|
||||
|
||||
it('should determine status as human_review when QA report rejected', async () => {
|
||||
it('should determine status as human_review when plan status is human_review', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-rejected');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
@@ -416,6 +419,8 @@ describe('ProjectStore', () => {
|
||||
feature: 'Rejected Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
status: 'human_review',
|
||||
reviewReason: 'qa_rejected',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
@@ -437,11 +442,6 @@ describe('ProjectStore', () => {
|
||||
JSON.stringify(plan)
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
path.join(specsDir, 'qa_report.md'),
|
||||
'# QA Report\n\nStatus: REJECTED\n'
|
||||
);
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
@@ -451,8 +451,7 @@ describe('ProjectStore', () => {
|
||||
expect(tasks[0].status).toBe('human_review');
|
||||
});
|
||||
|
||||
it('should determine status as human_review when QA report approved', async () => {
|
||||
// QA approval moves task to human_review (user needs to review before marking done)
|
||||
it('should determine reviewReason from plan when status is human_review', async () => {
|
||||
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-approved');
|
||||
mkdirSync(specsDir, { recursive: true });
|
||||
|
||||
@@ -460,6 +459,8 @@ describe('ProjectStore', () => {
|
||||
feature: 'Approved Feature',
|
||||
workflow_type: 'feature',
|
||||
services_involved: [],
|
||||
status: 'human_review',
|
||||
reviewReason: 'completed',
|
||||
phases: [
|
||||
{
|
||||
phase: 1,
|
||||
@@ -481,11 +482,6 @@ describe('ProjectStore', () => {
|
||||
JSON.stringify(plan)
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
path.join(specsDir, 'qa_report.md'),
|
||||
'# QA Report\n\nStatus: APPROVED\n'
|
||||
);
|
||||
|
||||
const { ProjectStore } = await import('../project-store');
|
||||
const store = new ProjectStore();
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = '/test/site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
// Save and clear existing PATH, then set lowercase 'Path'
|
||||
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('Rate Limit Edge Cases', () => {
|
||||
];
|
||||
|
||||
for (const msg of falsePositives) {
|
||||
const result = detectRateLimit(msg);
|
||||
const _result = detectRateLimit(msg);
|
||||
// Note: Some may still match secondary indicators - that's intentional
|
||||
// The primary pattern should NOT match these
|
||||
const primaryPattern = /Limit reached\s*[·•]\s*resets/i;
|
||||
|
||||
@@ -609,3 +609,392 @@ Please authenticate and try again.`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Failure Detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - spec appendix messages', () => {
|
||||
it('should detect "Your credit balance is too low to access the Anthropic API"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('Your credit balance is too low to access the Anthropic API');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
expect(result.message).toBeDefined();
|
||||
});
|
||||
|
||||
it('should detect "insufficient credits"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('insufficient credits');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
});
|
||||
|
||||
it('should detect "billing error"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('billing error');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
});
|
||||
|
||||
it('should detect "extra_usage exceeded"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('extra_usage exceeded');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
});
|
||||
|
||||
it('should detect standalone "extra_usage"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('extra_usage');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "payment required"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('payment required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
});
|
||||
|
||||
it('should detect "subscription expired"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('subscription expired');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('subscription_inactive');
|
||||
});
|
||||
|
||||
it('should detect credit balance variations', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const messages = [
|
||||
'credit balance is insufficient',
|
||||
'credit balance is empty',
|
||||
'credit balance is zero',
|
||||
'credit balance is exhausted',
|
||||
'credit balance is too low',
|
||||
];
|
||||
|
||||
for (const msg of messages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - negative cases', () => {
|
||||
it('should return false for normal output', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const normalMessages = [
|
||||
'Task completed successfully',
|
||||
'Processing 100 files',
|
||||
'Build succeeded',
|
||||
'Deploying to production',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const msg of normalMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect rate limit errors as billing failures', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const rateLimitMessages = [
|
||||
'Limit reached · resets Dec 17 at 6am',
|
||||
'rate limit exceeded',
|
||||
'too many requests',
|
||||
];
|
||||
|
||||
for (const msg of rateLimitMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT detect auth errors as billing failures', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const authMessages = [
|
||||
'authentication required',
|
||||
'not authenticated',
|
||||
'unauthorized',
|
||||
'invalid token',
|
||||
'session expired',
|
||||
];
|
||||
|
||||
for (const msg of authMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match "line 402" as billing failure (false positive check)', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const falsePositives = [
|
||||
'line 402',
|
||||
'Processing line 402 of 1000',
|
||||
'Found 4020 records',
|
||||
'Error on line 402',
|
||||
'The user has 402 files',
|
||||
];
|
||||
|
||||
for (const msg of falsePositives) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - 402 with proper context', () => {
|
||||
it('should detect "HTTP 402 Payment Required"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('HTTP 402 Payment Required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "API Error: 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('API Error: 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "status: 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('status: 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "error 402"', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('error 402');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect "402 payment required" (lowercase)', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('402 payment required');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBillingFailureError', () => {
|
||||
it('should return true for billing failure errors', async () => {
|
||||
const { isBillingFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isBillingFailureError('credit balance is too low')).toBe(true);
|
||||
expect(isBillingFailureError('insufficient credits')).toBe(true);
|
||||
expect(isBillingFailureError('billing error')).toBe(true);
|
||||
expect(isBillingFailureError('extra_usage exceeded')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-billing errors', async () => {
|
||||
const { isBillingFailureError } = await import('../rate-limit-detector');
|
||||
|
||||
expect(isBillingFailureError('Task completed')).toBe(false);
|
||||
expect(isBillingFailureError('')).toBe(false);
|
||||
expect(isBillingFailureError('rate limit exceeded')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyBillingFailureType via detectBillingFailure', () => {
|
||||
it('should classify credit-related failures as insufficient_credits', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const creditMessages = [
|
||||
'Your credit balance is too low',
|
||||
'insufficient credits',
|
||||
'out of credits',
|
||||
'extra_usage exceeded',
|
||||
];
|
||||
|
||||
for (const msg of creditMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('insufficient_credits');
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify payment failures as payment_required', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const paymentMessages = [
|
||||
'payment required',
|
||||
'billing error',
|
||||
'billing failure',
|
||||
];
|
||||
|
||||
for (const msg of paymentMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('payment_required');
|
||||
}
|
||||
});
|
||||
|
||||
it('should classify subscription failures as subscription_inactive', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const subscriptionMessages = [
|
||||
'subscription expired',
|
||||
'subscription inactive',
|
||||
'subscription cancelled',
|
||||
'account suspended',
|
||||
];
|
||||
|
||||
for (const msg of subscriptionMessages) {
|
||||
const result = detectBillingFailure(msg);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.failureType).toBe('subscription_inactive');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectBillingFailure - result structure', () => {
|
||||
it('should include profile ID in result', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low', 'custom-profile');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.profileId).toBe('custom-profile');
|
||||
});
|
||||
|
||||
it('should use active profile ID when not specified', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.profileId).toBe('test-profile-id');
|
||||
});
|
||||
|
||||
it('should include original error in result', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = 'Error: credit balance is too low to access the API';
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.originalError).toBe(output);
|
||||
});
|
||||
|
||||
it('should include user-friendly message', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const result = detectBillingFailure('credit balance is too low');
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
expect(result.message).toContain('credit');
|
||||
expect(result.message).toContain('Settings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cross-detection tests', () => {
|
||||
it('should not detect billing errors as auth failures', async () => {
|
||||
const { detectAuthFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const billingMessages = [
|
||||
'credit balance is too low',
|
||||
'insufficient credits',
|
||||
'billing error',
|
||||
'extra_usage exceeded',
|
||||
'payment required',
|
||||
];
|
||||
|
||||
for (const msg of billingMessages) {
|
||||
const result = detectAuthFailure(msg);
|
||||
expect(result.isAuthFailure).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not detect billing errors as rate limits', async () => {
|
||||
const { detectRateLimit } = await import('../rate-limit-detector');
|
||||
|
||||
const billingMessages = [
|
||||
'credit balance is too low',
|
||||
'insufficient credits',
|
||||
'billing error',
|
||||
'extra_usage exceeded',
|
||||
];
|
||||
|
||||
for (const msg of billingMessages) {
|
||||
const result = detectRateLimit(msg);
|
||||
expect(result.isRateLimited).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle case-insensitive billing messages', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const testCases = [
|
||||
'CREDIT BALANCE IS TOO LOW',
|
||||
'Credit Balance Is Too Low',
|
||||
'BILLING ERROR',
|
||||
'Insufficient Credits',
|
||||
'EXTRA_USAGE EXCEEDED',
|
||||
];
|
||||
|
||||
for (const output of testCases) {
|
||||
const result = detectBillingFailure(output);
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiline output with billing failure', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = `Starting API call...
|
||||
Processing request...
|
||||
Error: Your credit balance is too low to access the Anthropic API
|
||||
Please add credits to continue.`;
|
||||
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle JSON error responses with billing failure', async () => {
|
||||
const { detectBillingFailure } = await import('../rate-limit-detector');
|
||||
|
||||
const output = '{"type":"billing_error","message":"Insufficient credits"}';
|
||||
const result = detectBillingFailure(output);
|
||||
|
||||
expect(result.isBillingFailure).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,24 +243,31 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
// Get the mocked functions (vitest stores the implementation)
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
|
||||
// Cast to vi.Mock for accessing mock methods
|
||||
type MockFn = ReturnType<typeof vi.fn>;
|
||||
const existsSyncMock = existsSync as unknown as MockFn;
|
||||
const readFileSyncMock = readFileSync as unknown as MockFn;
|
||||
|
||||
// Save original mock implementations to restore after test
|
||||
const originalExistsSync = (existsSync as any).getMockImplementation();
|
||||
const originalReadFileSync = (readFileSync as any).getMockImplementation();
|
||||
type ExistsSyncFn = (path: string) => boolean;
|
||||
type ReadFileSyncFn = (path: string) => string;
|
||||
const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false);
|
||||
const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => '');
|
||||
|
||||
// Override existsSync to make file appear to exist
|
||||
(existsSync as any).mockImplementation((path: string) => {
|
||||
existsSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
return true; // File appears to exist
|
||||
}
|
||||
return originalExistsSync ? originalExistsSync(path) : false;
|
||||
return originalExistsSync(path);
|
||||
});
|
||||
|
||||
// Override readFileSync to throw error for our specific file
|
||||
(readFileSync as any).mockImplementation((path: string) => {
|
||||
readFileSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
throw new Error('EACCES: permission denied, open \'' + path + '\'');
|
||||
}
|
||||
return originalReadFileSync ? originalReadFileSync(path) : '';
|
||||
return originalReadFileSync(path);
|
||||
});
|
||||
|
||||
const result = await onboardingStatusHandler({}, null) as {
|
||||
@@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
expect(result.data?.hasCompletedOnboarding).toBe(false);
|
||||
|
||||
// Restore original mocks
|
||||
(existsSync as any).mockImplementation(originalExistsSync);
|
||||
(readFileSync as any).mockImplementation(originalReadFileSync);
|
||||
existsSyncMock.mockImplementation(originalExistsSync);
|
||||
readFileSyncMock.mockImplementation(originalReadFileSync);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TaskStateManager } from '../task-state-manager';
|
||||
import type { Task, Project } from '../../shared/types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../ipc-handlers/utils', () => ({
|
||||
safeSendToRenderer: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../ipc-handlers/task/plan-file-utils', () => ({
|
||||
getPlanPath: vi.fn(() => '/mock/path/implementation_plan.json'),
|
||||
persistPlanStatusAndReasonSync: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../worktree-paths', () => ({
|
||||
findTaskWorktree: vi.fn(() => null)
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(() => false)
|
||||
}));
|
||||
|
||||
// Create mock task and project
|
||||
function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: 'test-task-id',
|
||||
specId: '001-test-spec',
|
||||
projectId: 'test-project-id',
|
||||
title: 'Test Task',
|
||||
description: 'Test description',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createMockProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 'test-project-id',
|
||||
name: 'Test Project',
|
||||
path: '/mock/project/path',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastOpenedAt: new Date().toISOString(),
|
||||
...overrides
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe('TaskStateManager', () => {
|
||||
let manager: TaskStateManager;
|
||||
let mockTask: Task;
|
||||
let mockProject: Project;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new TaskStateManager();
|
||||
mockTask = createMockTask();
|
||||
mockProject = createMockProject();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.clearAllTasks();
|
||||
});
|
||||
|
||||
describe('handleTaskEvent', () => {
|
||||
it('should accept events with increasing sequence numbers', () => {
|
||||
const event1 = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const event2 = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 1,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
|
||||
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject events with stale sequence numbers', () => {
|
||||
const event1 = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 5
|
||||
};
|
||||
|
||||
const event2 = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 3, // Older than event1
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 1,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
|
||||
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(false); // Should be rejected
|
||||
});
|
||||
|
||||
it('should accept events with equal sequence numbers (edge case)', () => {
|
||||
// This handles reload scenarios where we might see the same sequence
|
||||
const event1 = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 5
|
||||
};
|
||||
|
||||
const event2 = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 5, // Same as event1
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 1,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
|
||||
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true); // Should be accepted (>= comparison)
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleUiEvent', () => {
|
||||
it('should send PLAN_APPROVED event correctly', () => {
|
||||
// First, set up the task in plan_review state
|
||||
const planningEvent = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planCompleteEvent = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 1,
|
||||
requireReviewBeforeCoding: true // This will cause plan_review state
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningEvent, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planCompleteEvent, mockTask, mockProject);
|
||||
|
||||
// Now send PLAN_APPROVED
|
||||
manager.handleUiEvent(mockTask.id, { type: 'PLAN_APPROVED' }, mockTask, mockProject);
|
||||
|
||||
// The actor should now be in 'coding' state
|
||||
// We can't easily check the state directly, but we can verify no errors occurred
|
||||
});
|
||||
|
||||
it('should send USER_STOPPED event correctly', () => {
|
||||
const event = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, event, mockTask, mockProject);
|
||||
manager.handleUiEvent(mockTask.id, { type: 'USER_STOPPED', hasPlan: false }, mockTask, mockProject);
|
||||
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleManualStatusChange', () => {
|
||||
it('should handle done status', () => {
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'done', mockTask, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle pr_created status', () => {
|
||||
const taskWithPrUrl = createMockTask({ metadata: { prUrl: 'https://github.com/test/pr/1' } });
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'pr_created', taskWithPrUrl, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle in_progress status with plan_review', () => {
|
||||
const taskInPlanReview = createMockTask({
|
||||
status: 'human_review',
|
||||
reviewReason: 'plan_review'
|
||||
});
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'in_progress', taskInPlanReview, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle in_progress status without plan_review', () => {
|
||||
const stoppedTask = createMockTask({
|
||||
status: 'human_review',
|
||||
reviewReason: 'stopped'
|
||||
});
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'in_progress', stoppedTask, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle backlog status', () => {
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'backlog', mockTask, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle human_review status (stage-only merge keeps task in review)', () => {
|
||||
const taskInReview = createMockTask({
|
||||
status: 'human_review',
|
||||
reviewReason: 'completed'
|
||||
});
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'human_review', taskInReview, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle human_review with default reviewReason when task has none', () => {
|
||||
const taskNoReason = createMockTask({
|
||||
status: 'human_review'
|
||||
// no reviewReason set
|
||||
});
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'human_review', taskNoReason, mockProject);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unhandled status', () => {
|
||||
const result = manager.handleManualStatusChange(mockTask.id, 'ai_review', mockTask, mockProject);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence management', () => {
|
||||
it('should set and get last sequence', () => {
|
||||
manager.setLastSequence(mockTask.id, 42);
|
||||
expect(manager.getLastSequence(mockTask.id)).toBe(42);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown task', () => {
|
||||
expect(manager.getLastSequence('unknown-task')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearTask', () => {
|
||||
it('should clear task state', () => {
|
||||
// Set up some state
|
||||
manager.setLastSequence(mockTask.id, 10);
|
||||
|
||||
const event = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
manager.handleTaskEvent(mockTask.id, event, mockTask, mockProject);
|
||||
|
||||
// Clear
|
||||
manager.clearTask(mockTask.id);
|
||||
|
||||
// Verify cleared
|
||||
expect(manager.getLastSequence(mockTask.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllTasks', () => {
|
||||
it('should clear all task state', () => {
|
||||
// Set up state for multiple tasks
|
||||
manager.setLastSequence('task-1', 10);
|
||||
manager.setLastSequence('task-2', 20);
|
||||
|
||||
const event1 = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: 'task-1',
|
||||
specId: '001',
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
const event2 = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: 'task-2',
|
||||
specId: '002',
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
manager.handleTaskEvent('task-1', event1, createMockTask({ id: 'task-1' }), mockProject);
|
||||
manager.handleTaskEvent('task-2', event2, createMockTask({ id: 'task-2' }), mockProject);
|
||||
|
||||
// Clear all
|
||||
manager.clearAllTasks();
|
||||
|
||||
// Verify actors and state are cleared, but sequence tracking is preserved
|
||||
// (to prevent duplicate event processing during refresh window)
|
||||
expect(manager.getLastSequence('task-1')).toBe(10);
|
||||
expect(manager.getLastSequence('task-2')).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleProcessExited', () => {
|
||||
it('should NOT mark as error if terminal event was already seen', () => {
|
||||
// First send a terminal event (like QA_PASSED)
|
||||
const qaPassedEvent = {
|
||||
type: 'QA_PASSED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0,
|
||||
iteration: 1,
|
||||
testsRun: {}
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, qaPassedEvent, mockTask, mockProject);
|
||||
|
||||
// Now process exits - this should NOT trigger error state
|
||||
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
|
||||
|
||||
// Should not throw and should not transition to error
|
||||
// (We can't easily verify the state, but the important thing is no crash)
|
||||
});
|
||||
|
||||
it('should mark as error on unexpected exit when no terminal event seen', () => {
|
||||
// Start a task
|
||||
const planningEvent = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningEvent, mockTask, mockProject);
|
||||
|
||||
// Process exits unexpectedly (no terminal event seen)
|
||||
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
|
||||
|
||||
// Should have sent PROCESS_EXITED event with unexpected=true
|
||||
// This should transition to error state
|
||||
});
|
||||
|
||||
it('should NOT mark exit code 0 as unexpected (plan_review stays intact)', () => {
|
||||
// Simulate: PLANNING_STARTED → PLANNING_COMPLETE (requireReview) → process exits code 0
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: false,
|
||||
subtaskCount: 0,
|
||||
requireReviewBeforeCoding: true
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in plan_review now
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
|
||||
// Process exits with code 0 - should NOT transition to error
|
||||
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
|
||||
|
||||
// PLANNING_COMPLETE is a terminal event, so handleProcessExited should skip entirely
|
||||
// Task should remain in plan_review
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
});
|
||||
|
||||
it('should treat PLANNING_COMPLETE as a terminal event', () => {
|
||||
// PLANNING_COMPLETE should prevent handleProcessExited from running
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 3,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in coding (no review required)
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
|
||||
// Process exits with code 1 - should still skip because PLANNING_COMPLETE is terminal
|
||||
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
|
||||
|
||||
// Task should remain in coding, NOT transition to error
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actor state restoration', () => {
|
||||
it('should restore actor state from task with in_progress status', () => {
|
||||
const taskInProgress = createMockTask({
|
||||
status: 'in_progress',
|
||||
executionProgress: { phase: 'coding', phaseProgress: 50, overallProgress: 50 }
|
||||
});
|
||||
|
||||
const event = {
|
||||
type: 'QA_STARTED',
|
||||
taskId: taskInProgress.id,
|
||||
specId: taskInProgress.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0,
|
||||
iteration: 1,
|
||||
maxIterations: 3
|
||||
};
|
||||
|
||||
// This should create an actor restored to 'coding' state, then transition to 'qa_review'
|
||||
manager.handleTaskEvent(taskInProgress.id, event, taskInProgress, mockProject);
|
||||
|
||||
// No error should occur
|
||||
});
|
||||
|
||||
it('should restore actor state from task with human_review/plan_review', () => {
|
||||
const taskInPlanReview = createMockTask({
|
||||
status: 'human_review',
|
||||
reviewReason: 'plan_review'
|
||||
});
|
||||
|
||||
// Actor should be created in plan_review state
|
||||
manager.handleUiEvent(taskInPlanReview.id, { type: 'PLAN_APPROVED' }, taskInPlanReview, mockProject);
|
||||
|
||||
// Should transition from plan_review to coding without error
|
||||
});
|
||||
|
||||
it('should restore actor state from task with error status', () => {
|
||||
const taskInError = createMockTask({
|
||||
status: 'error',
|
||||
reviewReason: 'errors'
|
||||
});
|
||||
|
||||
// Actor should be created in error state
|
||||
manager.handleUiEvent(taskInError.id, { type: 'USER_RESUMED' }, taskInError, mockProject);
|
||||
|
||||
// Should transition from error to coding without error
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("error handling - non-disposal errors", () => {
|
||||
it("catches other errors and returns false", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockSend.mockImplementation(() => {
|
||||
throw new Error("Some other IPC error");
|
||||
@@ -216,7 +218,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn separately for different channels", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("warning pruning logic - 100-entry hard cap", () => {
|
||||
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("handles many unique channels without throwing errors", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser';
|
||||
import {
|
||||
wouldPhaseRegress,
|
||||
isTerminalPhase,
|
||||
isPausePhase,
|
||||
isValidExecutionPhase,
|
||||
type ExecutionPhase
|
||||
} from '../../shared/constants/phase-protocol';
|
||||
@@ -13,18 +14,48 @@ export class AgentEvents {
|
||||
log: string,
|
||||
currentPhase: ExecutionProgressData['phase'],
|
||||
isSpecRunner: boolean
|
||||
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
|
||||
): {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} | null {
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
phase: structuredEvent.phase as ExecutionProgressData['phase'],
|
||||
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
|
||||
// which is a subset of ExecutionPhase, so this assertion is safe
|
||||
const result: {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Terminal states can't be changed by fallback matching
|
||||
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
|
||||
if (isTerminalPhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -43,6 +74,7 @@ export class AgentEvents {
|
||||
const lowerLog = log.toLowerCase();
|
||||
|
||||
// Spec runner phase detection (all part of "planning")
|
||||
// IMPORTANT: Spec runner should NEVER transition to coding/qa phases via fallback matching
|
||||
if (isSpecRunner) {
|
||||
if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) {
|
||||
return { phase: 'planning', message: 'Discovering project context...' };
|
||||
@@ -59,6 +91,8 @@ export class AgentEvents {
|
||||
if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) {
|
||||
return { phase: 'planning', message: 'Specification complete' };
|
||||
}
|
||||
// Spec runner: don't fall through to run.py patterns (would incorrectly detect coding phase)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run.py phase detection
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||
import type { ClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getOperationRegistry } from '../claude-profile/operation-registry';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
@@ -32,6 +34,9 @@ export class AgentManager extends EventEmitter {
|
||||
metadata?: SpecCreationMetadata;
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
projectId?: string;
|
||||
/** Generation counter to prevent stale cleanup after restart */
|
||||
generation: number;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
@@ -51,26 +56,40 @@ export class AgentManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// Listen for task completion to clean up context (prevent memory leak)
|
||||
this.on('exit', (taskId: string, code: number | null) => {
|
||||
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
|
||||
// Clean up context when:
|
||||
// 1. Task completed successfully (code === 0), or
|
||||
// 2. Task failed and won't be restarted (handled by auto-swap logic)
|
||||
|
||||
// Capture generation at exit time to prevent race conditions with restarts
|
||||
const contextAtExit = this.taskExecutionContext.get(taskId);
|
||||
const generationAtExit = contextAtExit?.generation;
|
||||
|
||||
// Note: Auto-swap restart happens BEFORE this exit event is processed,
|
||||
// so we need a small delay to allow restart to preserve context
|
||||
setTimeout(() => {
|
||||
const context = this.taskExecutionContext.get(taskId);
|
||||
if (!context) return; // Already cleaned up or restarted
|
||||
|
||||
// Check if the context's generation matches - if not, a restart incremented it
|
||||
// and this cleanup is for a stale exit event that shouldn't affect the new task
|
||||
if (generationAtExit !== undefined && context.generation !== generationAtExit) {
|
||||
return; // Stale exit event - task was restarted, don't clean up new context
|
||||
}
|
||||
|
||||
// If task completed successfully, always clean up
|
||||
if (code === 0) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If task failed and hit max retries, clean up
|
||||
if (context.swapCount >= 2) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
}
|
||||
// Otherwise keep context for potential restart
|
||||
}, 1000); // Delay to allow restart logic to run first
|
||||
@@ -84,6 +103,46 @@ export class AgentManager extends EventEmitter {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a task with the unified OperationRegistry for proactive swap support.
|
||||
* Extracted helper to avoid code duplication between spec creation and task execution.
|
||||
* @private
|
||||
*/
|
||||
private registerTaskWithOperationRegistry(
|
||||
taskId: string,
|
||||
operationType: 'spec-creation' | 'task-execution',
|
||||
metadata: Record<string, unknown>
|
||||
): void {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep internal state tracking for backward compatibility
|
||||
this.assignProfileToTask(taskId, activeProfile.id, activeProfile.name, 'proactive');
|
||||
|
||||
// Register with unified registry for proactive swap
|
||||
// Note: We don't provide a stopFn because restartTask() already handles stopping
|
||||
// the task internally via killTask() before restarting. Providing a separate
|
||||
// stopFn would cause a redundant double-kill during profile swaps.
|
||||
const operationRegistry = getOperationRegistry();
|
||||
operationRegistry.registerOperation(
|
||||
taskId,
|
||||
operationType,
|
||||
activeProfile.id,
|
||||
activeProfile.name,
|
||||
(newProfileId: string) => this.restartTask(taskId, newProfileId),
|
||||
{ metadata }
|
||||
);
|
||||
console.log('[AgentManager] Task registered with OperationRegistry:', {
|
||||
taskId,
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name,
|
||||
type: operationType
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start spec creation process
|
||||
*/
|
||||
@@ -93,11 +152,12 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
let profileManager;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -173,10 +233,13 @@ export class AgentManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
|
||||
|
||||
// Register with unified OperationRegistry for proactive swap support
|
||||
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,11 +249,12 @@ export class AgentManager extends EventEmitter {
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
options: TaskExecutionOptions = {},
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
let profileManager;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -251,9 +315,12 @@ export class AgentManager extends EventEmitter {
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
// Register with unified OperationRegistry for proactive swap support
|
||||
this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options });
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +329,8 @@ export class AgentManager extends EventEmitter {
|
||||
async startQAProcess(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string
|
||||
specId: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
|
||||
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
|
||||
@@ -290,7 +358,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,11 +455,14 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription?: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): void {
|
||||
// Preserve swapCount if context already exists (for restarts)
|
||||
const existingContext = this.taskExecutionContext.get(taskId);
|
||||
const swapCount = existingContext?.swapCount ?? 0;
|
||||
// Increment generation on each store (restarts) to invalidate pending cleanup callbacks
|
||||
const generation = (existingContext?.generation ?? 0) + 1;
|
||||
|
||||
this.taskExecutionContext.set(taskId, {
|
||||
projectPath,
|
||||
@@ -402,7 +473,9 @@ export class AgentManager extends EventEmitter {
|
||||
specDir,
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount // Preserve existing count instead of resetting
|
||||
swapCount, // Preserve existing count instead of resetting
|
||||
projectId,
|
||||
generation, // Incremented to prevent stale exit cleanup
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,13 +531,18 @@ export class AgentManager extends EventEmitter {
|
||||
console.log('[AgentManager] Restarting task now:', taskId);
|
||||
if (context.isSpecCreation) {
|
||||
console.log('[AgentManager] Restarting as spec creation');
|
||||
if (!context.taskDescription) {
|
||||
console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing');
|
||||
return;
|
||||
}
|
||||
this.startSpecCreation(
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.taskDescription!,
|
||||
context.taskDescription,
|
||||
context.specDir,
|
||||
context.metadata,
|
||||
context.baseBranch
|
||||
context.baseBranch,
|
||||
context.projectId
|
||||
);
|
||||
} else {
|
||||
console.log('[AgentManager] Restarting as task execution');
|
||||
@@ -472,7 +550,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.specId,
|
||||
context.options
|
||||
context.options,
|
||||
context.projectId
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -13,7 +13,7 @@ function createMockProcess() {
|
||||
return {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event: string, callback: any) => {
|
||||
on: vi.fn((event: string, callback: (code: number) => void) => {
|
||||
if (event === 'exit') {
|
||||
// Simulate immediate exit with code 0
|
||||
setTimeout(() => callback(0), 10);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AgentState } from './agent-state';
|
||||
import { AgentEvents } from './agent-events';
|
||||
import { ProcessType, ExecutionProgressData } from './types';
|
||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
||||
import { parseTaskEvent } from './task-event-parser';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv, detectAuthFailure } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { projectStore } from '../project-store';
|
||||
@@ -29,7 +30,7 @@ import { killProcessGracefully, isWindows } from '../platform';
|
||||
/**
|
||||
* Type for supported CLI tools
|
||||
*/
|
||||
type CliTool = 'claude' | 'gh';
|
||||
type CliTool = 'claude' | 'gh' | 'glab';
|
||||
|
||||
/**
|
||||
* Mapping of CLI tools to their environment variable names
|
||||
@@ -37,7 +38,8 @@ type CliTool = 'claude' | 'gh';
|
||||
*/
|
||||
const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = {
|
||||
claude: 'CLAUDE_CLI_PATH',
|
||||
gh: 'GITHUB_CLI_PATH'
|
||||
gh: 'GITHUB_CLI_PATH',
|
||||
glab: 'GITLAB_CLI_PATH'
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -201,12 +203,14 @@ export class AgentProcessManager {
|
||||
// Detect and pass CLI tool paths to Python backend
|
||||
const claudeCliEnv = this.detectAndSetCliPath('claude');
|
||||
const ghCliEnv = this.detectAndSetCliPath('gh');
|
||||
const glabCliEnv = this.detectAndSetCliPath('glab');
|
||||
|
||||
return {
|
||||
...augmentedEnv,
|
||||
...gitBashEnv,
|
||||
...claudeCliEnv,
|
||||
...ghCliEnv,
|
||||
...glabCliEnv,
|
||||
...extraEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
@@ -277,7 +281,11 @@ export class AgentProcessManager {
|
||||
} : 'NONE');
|
||||
|
||||
if (!bestProfile) {
|
||||
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
|
||||
// Single account case: let backend handle with intelligent pause
|
||||
// Don't show manual modal - backend will pause intelligently and resume when ready
|
||||
console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause');
|
||||
// Return false to let handleProcessFailure emit sdk-rate-limit event
|
||||
// The frontend can then show appropriate UI (e.g., "Paused until X time")
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -302,19 +310,84 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
if (!authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
|
||||
// Try auto-swap if enabled
|
||||
const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection);
|
||||
|
||||
if (!wasHandled) {
|
||||
// Fall back to UI notification
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to auto-swap to another profile on authentication failure.
|
||||
* Only works when autoSwitchOnAuthFailure is enabled and an alternative
|
||||
* authenticated profile is available.
|
||||
*/
|
||||
private handleAuthFailureWithAutoSwap(
|
||||
taskId: string,
|
||||
authFailureDetection: ReturnType<typeof detectAuthFailure>
|
||||
): boolean {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
||||
|
||||
console.log('[AgentProcess] Auth failure auto-switch settings:', {
|
||||
enabled: autoSwitchSettings.enabled,
|
||||
autoSwitchOnAuthFailure: autoSwitchSettings.autoSwitchOnAuthFailure
|
||||
});
|
||||
|
||||
// Check if auto-switch on auth failure is enabled
|
||||
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnAuthFailure) {
|
||||
console.log('[AgentProcess] Auth failure auto-switch disabled - falling back to UI');
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentProfileId = authFailureDetection.profileId;
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
console.log('[AgentProcess] Best available profile for auth failure swap:', bestProfile ? {
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name,
|
||||
isAuthenticated: bestProfile.isAuthenticated
|
||||
} : 'NONE');
|
||||
|
||||
// Verify the best profile is actually authenticated
|
||||
if (!bestProfile || !bestProfile.isAuthenticated) {
|
||||
console.log('[AgentProcess] No authenticated alternative profile - falling back to UI');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] AUTH-FAILURE AUTO-SWAP:', currentProfileId, '->', bestProfile.id);
|
||||
profileManager.setActiveProfile(bestProfile.id);
|
||||
|
||||
// Emit auth-failure event with swap metadata for UI notification
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError,
|
||||
wasAutoSwapped: true,
|
||||
swappedToProfile: { id: bestProfile.id, name: bestProfile.name }
|
||||
});
|
||||
|
||||
// Reuse existing restart event
|
||||
console.log('[AgentProcess] Emitting auto-swap-restart-task event for auth failure:', taskId);
|
||||
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,7 +579,8 @@ export class AgentProcessManager {
|
||||
cwd: string,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
processType: ProcessType = 'task-execution'
|
||||
processType: ProcessType = 'task-execution',
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
const isSpecRunner = processType === 'spec-creation';
|
||||
this.killProcess(taskId);
|
||||
@@ -558,7 +632,7 @@ export class AgentProcessManager {
|
||||
// spawn() failed synchronously (e.g., command not found, permission denied)
|
||||
// Clean up tracking entry and propagate error
|
||||
this.state.deleteProcess(taskId);
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err), projectId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -605,7 +679,7 @@ export class AgentProcessManager {
|
||||
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
|
||||
|
||||
@@ -617,6 +691,17 @@ export class AgentProcessManager {
|
||||
console.log(`[PhaseDebug:${taskId}] Found marker in line: "${line.substring(0, 200)}"`);
|
||||
}
|
||||
|
||||
// Log all task event markers for debugging
|
||||
if (line.includes('__TASK_EVENT__')) {
|
||||
console.log(`[AgentProcess:${taskId}] Found __TASK_EVENT__ marker in line:`, line.substring(0, 300));
|
||||
}
|
||||
|
||||
const taskEvent = parseTaskEvent(line);
|
||||
if (taskEvent) {
|
||||
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
|
||||
this.emitter.emit('task-event', taskId, taskEvent, projectId);
|
||||
}
|
||||
|
||||
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
|
||||
|
||||
if (isDebug && hasMarker) {
|
||||
@@ -674,7 +759,7 @@ export class AgentProcessManager {
|
||||
message: lastMessage,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -694,7 +779,7 @@ export class AgentProcessManager {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.emitter.emit('log', taskId, line + '\n');
|
||||
this.emitter.emit('log', taskId, line + '\n', projectId);
|
||||
processLog(line);
|
||||
if (isDebug) {
|
||||
console.log(`[Agent:${taskId}] ${line}`);
|
||||
@@ -715,11 +800,11 @@ export class AgentProcessManager {
|
||||
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
if (stdoutBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
|
||||
processLog(stdoutBuffer);
|
||||
}
|
||||
if (stderrBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
|
||||
processLog(stderrBuffer);
|
||||
}
|
||||
|
||||
@@ -734,7 +819,7 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
|
||||
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
|
||||
if (wasHandled) {
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -747,10 +832,10 @@ export class AgentProcessManager {
|
||||
message: `Process exited with code ${code}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
@@ -765,9 +850,9 @@ export class AgentProcessManager {
|
||||
message: `Error: ${err.message}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
this.emitter.emit('error', taskId, err.message);
|
||||
this.emitter.emit('error', taskId, err.message, projectId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,12 @@ export class AgentQueueManager {
|
||||
|
||||
// Track completed types for progress calculation
|
||||
const completedTypes = new Set<string>();
|
||||
const totalTypes = 7; // Default all types
|
||||
// Derive totalTypes from --types argument instead of hardcoding
|
||||
const typesArgIndex = args.findIndex((arg) => arg === '--types');
|
||||
const totalTypes =
|
||||
typesArgIndex > -1 && args[typesArgIndex + 1]
|
||||
? args[typesArgIndex + 1].split(',').length
|
||||
: 6; // Default to 6 if not specified
|
||||
|
||||
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
|
||||
@@ -24,9 +24,24 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should group tasks by profile', () => {
|
||||
// Add mock processes
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-2', { pid: 1002 } as any);
|
||||
state.addProcess('task-3', { pid: 1003 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
state.addProcess('task-2', {
|
||||
taskId: 'task-2',
|
||||
process: { pid: 1002 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 2
|
||||
});
|
||||
state.addProcess('task-3', {
|
||||
taskId: 'task-3',
|
||||
process: { pid: 1003 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 3
|
||||
});
|
||||
|
||||
// Assign profiles
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
@@ -42,7 +57,12 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should use default profile for unassigned tasks', () => {
|
||||
// Add process without profile assignment
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface PhaseParseResult<TPhase extends string = string> {
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
progress?: number;
|
||||
// Pause phase metadata
|
||||
resetTimestamp?: number; // Unix timestamp for rate limit reset
|
||||
profileId?: string; // Profile that hit the limit
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from
|
||||
import {
|
||||
EXECUTION_PHASES,
|
||||
TERMINAL_PHASES,
|
||||
isPausePhase,
|
||||
type ExecutionPhase
|
||||
} from '../../../shared/constants/phase-protocol';
|
||||
import { parsePhaseEvent } from '../phase-event-parser';
|
||||
@@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
// 1. Try structured event first (authoritative source)
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
const result: PhaseParseResult<ExecutionPhase> = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Terminal states can't be changed by fallback matching
|
||||
@@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Fall back to text pattern matching
|
||||
// 3. Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(context.currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Fall back to text pattern matching
|
||||
return this.parseFallbackPatterns(log, context);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({
|
||||
phase: BackendPhaseSchema,
|
||||
message: z.string().default(''),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
subtask: z.string().optional()
|
||||
subtask: z.string().optional(),
|
||||
// Pause phase metadata
|
||||
reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset
|
||||
profile_id: z.string().optional() // Profile that hit the limit
|
||||
});
|
||||
|
||||
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Structured task event parser for Python -> TypeScript protocol.
|
||||
* Protocol: __TASK_EVENT__:{...}
|
||||
*/
|
||||
|
||||
import { validateTaskEvent, type TaskEventPayload } from './task-event-schema';
|
||||
|
||||
export const TASK_EVENT_PREFIX = '__TASK_EVENT__:';
|
||||
|
||||
const DEBUG = process.env.DEBUG?.toLowerCase() === 'true' || process.env.DEBUG === '1';
|
||||
|
||||
export type TaskEvent = TaskEventPayload;
|
||||
|
||||
export function parseTaskEvent(line: string): TaskEventPayload | null {
|
||||
const markerIndex = line.indexOf(TASK_EVENT_PREFIX);
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Found marker at index', markerIndex, 'in line:', line.substring(0, 200));
|
||||
}
|
||||
|
||||
const rawJsonStr = line.slice(markerIndex + TASK_EVENT_PREFIX.length).trim();
|
||||
if (!rawJsonStr) {
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Empty JSON string after marker');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonStr = extractJsonObject(rawJsonStr);
|
||||
if (!jsonStr) {
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Could not extract JSON object from:', rawJsonStr.substring(0, 200));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Attempting to parse JSON:', jsonStr.substring(0, 200));
|
||||
}
|
||||
|
||||
try {
|
||||
const rawPayload = JSON.parse(jsonStr) as unknown;
|
||||
const result = validateTaskEvent(rawPayload);
|
||||
|
||||
if (!result.success) {
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Validation failed:', result.error.format());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] Successfully parsed event:', result.data);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
if (DEBUG) {
|
||||
console.log('[task-event-parser] JSON parse FAILED for:', jsonStr);
|
||||
console.log('[task-event-parser] Error:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTaskMarker(line: string): boolean {
|
||||
return line.includes(TASK_EVENT_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a JSON object from a string that may have trailing garbage.
|
||||
* Finds the matching closing brace for the first opening brace.
|
||||
*/
|
||||
function extractJsonObject(str: string): string | null {
|
||||
const firstBrace = str.indexOf('{');
|
||||
if (firstBrace === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let isEscaped = false;
|
||||
|
||||
for (let i = firstBrace; i < str.length; i++) {
|
||||
const char = str[i];
|
||||
|
||||
if (isEscaped) {
|
||||
isEscaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && inString) {
|
||||
isEscaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
depth++;
|
||||
} else if (char === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return str.slice(firstBrace, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TaskEventSchema = z.object({
|
||||
type: z.string(),
|
||||
taskId: z.string(),
|
||||
specId: z.string(),
|
||||
projectId: z.string(),
|
||||
timestamp: z.string(),
|
||||
eventId: z.string(),
|
||||
sequence: z.number().int().min(0)
|
||||
}).passthrough();
|
||||
|
||||
export type TaskEventPayload = z.infer<typeof TaskEventSchema>;
|
||||
|
||||
export interface ValidationResult {
|
||||
success: true;
|
||||
data: TaskEventPayload;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
success: false;
|
||||
error: z.ZodError;
|
||||
}
|
||||
|
||||
export type ParseResult = ValidationResult | ValidationError;
|
||||
|
||||
export function validateTaskEvent(data: unknown): ParseResult {
|
||||
const result = TaskEventSchema.safeParse(data);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data as TaskEventPayload };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
||||
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
|
||||
import type { TaskEventPayload } from './task-event-schema';
|
||||
|
||||
/**
|
||||
* Agent-specific types for process and state management
|
||||
@@ -18,7 +18,7 @@ export interface AgentProcess {
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
|
||||
phase: ExecutionPhase;
|
||||
phaseProgress: number;
|
||||
overallProgress: number;
|
||||
currentSubtask?: string;
|
||||
@@ -30,10 +30,11 @@ export interface ExecutionProgressData {
|
||||
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
|
||||
|
||||
export interface AgentManagerEvents {
|
||||
log: (taskId: string, log: string) => void;
|
||||
error: (taskId: string, error: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
log: (taskId: string, log: string, projectId?: string) => void;
|
||||
error: (taskId: string, error: string, projectId?: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData, projectId?: string) => void;
|
||||
'task-event': (taskId: string, event: TaskEventPayload, projectId?: string) => void;
|
||||
}
|
||||
|
||||
// IdeationConfig now imported from shared types to maintain consistency
|
||||
@@ -48,6 +49,7 @@ export interface TaskExecutionOptions {
|
||||
workers?: number;
|
||||
baseBranch?: string;
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
@@ -71,6 +73,7 @@ export interface SpecCreationMetadata {
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
// Workspace mode - whether to use worktree isolation
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* App language tracking module for main process.
|
||||
*
|
||||
* Tracks the user's in-app language setting (not OS locale) for use in
|
||||
* main process code that needs localized strings (e.g., context menus).
|
||||
*
|
||||
* Updated via IPC when user changes language in settings.
|
||||
*/
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
// Current app language, defaults to 'en'
|
||||
// Updated via setAppLanguage() when renderer notifies of language change
|
||||
let currentAppLanguage = 'en';
|
||||
|
||||
/**
|
||||
* Get the current app language.
|
||||
* Falls back to 'en' if not set.
|
||||
*/
|
||||
export function getAppLanguage(): string {
|
||||
return currentAppLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current app language.
|
||||
* Called by IPC handler when renderer changes language.
|
||||
*/
|
||||
export function setAppLanguage(language: string): void {
|
||||
currentAppLanguage = language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize app language from OS locale as a starting point.
|
||||
* The renderer will update this once i18n initializes.
|
||||
*/
|
||||
export function initAppLanguage(): void {
|
||||
try {
|
||||
// app.getLocale() may not be available in test environments
|
||||
const osLocale = app?.getLocale?.() || 'en';
|
||||
// Extract base language (e.g., 'en-US' -> 'en')
|
||||
currentAppLanguage = osLocale.split('-')[0] || 'en';
|
||||
} catch {
|
||||
currentAppLanguage = 'en';
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
* - APP_UPDATE_ERROR: Error during update process
|
||||
*/
|
||||
|
||||
import { accessSync, constants as fsConstants } from 'fs';
|
||||
import path from 'path';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
import { app, net } from 'electron';
|
||||
@@ -24,6 +26,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { AppUpdateInfo } from '../shared/types';
|
||||
import { compareVersions } from './updater/version-manager';
|
||||
import { isMacOS } from './platform';
|
||||
|
||||
// GitHub repo info for API calls
|
||||
const GITHUB_OWNER = 'AndyMik90';
|
||||
@@ -91,10 +94,13 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
|
||||
*/
|
||||
export function setUpdateChannel(channel: UpdateChannel): void {
|
||||
autoUpdater.channel = channel;
|
||||
// Enable pre-release scanning when beta channel is selected
|
||||
// This allows electron-updater to find beta releases on GitHub
|
||||
autoUpdater.allowPrerelease = channel === 'beta';
|
||||
// Clear any downloaded update info when channel changes to prevent showing
|
||||
// an Install button for an update from a different channel
|
||||
downloadedUpdateInfo = null;
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
|
||||
}
|
||||
|
||||
// Enable more verbose logging in debug mode
|
||||
@@ -187,8 +193,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
|
||||
console.error('[app-updater] Update error:', error);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -293,13 +298,57 @@ export async function downloadUpdate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app is running from a read-only volume (e.g., DMG on macOS)
|
||||
* Returns true if the app cannot be updated in place
|
||||
*/
|
||||
function isRunningFromReadOnlyVolume(): boolean {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
// Check if the filesystem is read-only by testing write access.
|
||||
// We don't use a /Volumes/ prefix check because writable external drives
|
||||
// (USB, external SSDs) are also mounted under /Volumes/ on macOS.
|
||||
try {
|
||||
// Navigate from app.asar to the Contents/ directory (app.asar -> Resources -> Contents)
|
||||
const contentsPath = path.resolve(appPath, '..', '..');
|
||||
|
||||
// Try to check if we can write to the app bundle's parent directory
|
||||
accessSync(path.dirname(contentsPath), fsConstants.W_OK);
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
// Only treat as read-only if the filesystem itself is read-only (EROFS).
|
||||
// Permission errors (EACCES) in managed/enterprise environments should not
|
||||
// block updates — the updater may still have elevated access.
|
||||
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
|
||||
return code === 'EROFS';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit and install update
|
||||
* Called from IPC handler when user confirms installation
|
||||
* Returns false if running from a read-only volume (update cannot proceed)
|
||||
*/
|
||||
export function quitAndInstall(): void {
|
||||
export function quitAndInstall(): boolean {
|
||||
// Check if running from read-only volume before attempting install
|
||||
if (isRunningFromReadOnlyVolume()) {
|
||||
console.warn('[app-updater] Cannot install: running from read-only volume');
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, {
|
||||
appPath: app.getAppPath()
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn('[app-updater] Quitting and installing update');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +451,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
|
||||
|
||||
const version = latestStable.tag_name.replace(/^v/, '');
|
||||
// Sanitize version string for logging (remove control characters and limit length)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
|
||||
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
|
||||
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
||||
|
||||
@@ -483,11 +532,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
||||
channel: UpdateChannel,
|
||||
triggerDowngradeCheck = false
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
autoUpdater.channel = channel;
|
||||
// Clear any downloaded update info when channel changes to prevent showing
|
||||
// an Install button for an update from a different channel
|
||||
downloadedUpdateInfo = null;
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
||||
// Use the shared channel-setting function to avoid code duplication
|
||||
setUpdateChannel(channel);
|
||||
|
||||
// If switching to stable and downgrade check requested, look for stable version
|
||||
if (channel === 'latest' && triggerDowngradeCheck) {
|
||||
@@ -509,8 +555,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
||||
* Uses electron-updater with allowDowngrade enabled to download older stable versions
|
||||
*/
|
||||
export async function downloadStableVersion(): Promise<void> {
|
||||
// Switch to stable channel
|
||||
autoUpdater.channel = 'latest';
|
||||
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
|
||||
setUpdateChannel('latest');
|
||||
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
|
||||
autoUpdater.allowDowngrade = true;
|
||||
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
|
||||
|
||||
@@ -44,7 +44,6 @@ export class ChangelogService extends EventEmitter {
|
||||
private _pythonPath: string | null = null;
|
||||
private claudePath: string;
|
||||
private autoBuildSourcePath: string = '';
|
||||
private cachedEnv: Record<string, string> | null = null;
|
||||
private debugEnabled: boolean | null = null;
|
||||
private generator: ChangelogGenerator | null = null;
|
||||
private versionSuggester: VersionSuggester | null = null;
|
||||
@@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
@@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter {
|
||||
* Suggest version using AI analysis of git commits
|
||||
*/
|
||||
async suggestVersionFromCommits(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
commits: import('../../shared/types').GitCommit[],
|
||||
currentVersion?: string
|
||||
): Promise<{ version: string; reason: string }> {
|
||||
@@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,6 @@ except Exception as e:
|
||||
case 'minor':
|
||||
newVersion = `${major}.${minor + 1}.0`;
|
||||
break;
|
||||
case 'patch':
|
||||
default:
|
||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||
break;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
clearRateLimitEvents as clearRateLimitEventsImpl
|
||||
} from './claude-profile/rate-limit-manager';
|
||||
import {
|
||||
loadProfileStore,
|
||||
loadProfileStoreAsync,
|
||||
saveProfileStore,
|
||||
ProfileStoreData,
|
||||
@@ -43,7 +42,7 @@ import {
|
||||
shouldProactivelySwitch as shouldProactivelySwitchImpl,
|
||||
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
||||
} from './claude-profile/profile-scorer';
|
||||
import { getCredentialsFromKeychain } from './claude-profile/credential-utils';
|
||||
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
|
||||
import {
|
||||
CLAUDE_PROFILES_DIR,
|
||||
generateProfileId as generateProfileIdImpl,
|
||||
@@ -96,6 +95,10 @@ export class ClaudeProfileManager {
|
||||
// This repairs emails that were truncated due to ANSI escape codes in terminal output
|
||||
this.migrateCorruptedEmails();
|
||||
|
||||
// Populate missing subscription metadata for existing profiles
|
||||
// This reads subscriptionType and rateLimitTier from Keychain credentials
|
||||
this.populateSubscriptionMetadata();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -133,6 +136,58 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
|
||||
*
|
||||
* This reads from Keychain credentials and updates profiles that don't have this metadata.
|
||||
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
|
||||
*/
|
||||
private populateSubscriptionMetadata(): void {
|
||||
let needsSave = false;
|
||||
|
||||
for (const profile of this.data.profiles) {
|
||||
if (!profile.configDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if profile already has subscription metadata
|
||||
if (profile.subscriptionType && profile.rateLimitTier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedConfigDir = normalizeWindowsPath(
|
||||
profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir
|
||||
);
|
||||
|
||||
// Use helper with onlyIfMissing option to preserve existing values
|
||||
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
|
||||
|
||||
if (result.subscriptionTypeUpdated) {
|
||||
needsSave = true;
|
||||
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
|
||||
profileId: profile.id,
|
||||
subscriptionType: result.subscriptionType
|
||||
});
|
||||
}
|
||||
|
||||
if (result.rateLimitTierUpdated) {
|
||||
needsSave = true;
|
||||
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
|
||||
profileId: profile.id,
|
||||
rateLimitTier: result.rateLimitTier
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the profile manager has been initialized
|
||||
*/
|
||||
@@ -140,31 +195,6 @@ export class ClaudeProfileManager {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load profiles from disk
|
||||
*/
|
||||
private load(): ProfileStoreData {
|
||||
const loadedData = loadProfileStore(this.storePath);
|
||||
if (loadedData) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Loaded profiles:', {
|
||||
count: loadedData.profiles.length,
|
||||
activeProfileId: loadedData.activeProfileId,
|
||||
profiles: loadedData.profiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
email: p.email,
|
||||
isDefault: p.isDefault
|
||||
}))
|
||||
});
|
||||
}
|
||||
return loadedData;
|
||||
}
|
||||
|
||||
// Return default with a single "Default" profile
|
||||
return this.createDefaultData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default profile data
|
||||
*
|
||||
@@ -498,9 +528,12 @@ export class ClaudeProfileManager {
|
||||
// This prevents interference with external Claude Code CLI usage
|
||||
if (profile?.configDir) {
|
||||
// Expand ~ to home directory for the environment variable
|
||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir;
|
||||
const expandedConfigDir = normalizeWindowsPath(
|
||||
profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir
|
||||
);
|
||||
|
||||
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
@@ -719,9 +752,11 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
// Expand ~ to home directory for the environment variable
|
||||
const expandedConfigDir = profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, require('os').homedir())
|
||||
: profile.configDir;
|
||||
const expandedConfigDir = normalizeWindowsPath(
|
||||
profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, require('os').homedir())
|
||||
: profile.configDir
|
||||
);
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Unit tests for OperationRegistry
|
||||
*
|
||||
* Tests cover:
|
||||
* - Singleton pattern
|
||||
* - Operation registration/unregistration
|
||||
* - Profile-based querying
|
||||
* - Summary generation
|
||||
* - Operation restart functionality
|
||||
* - Event emissions
|
||||
* - Edge cases
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getOperationRegistry,
|
||||
resetOperationRegistry,
|
||||
type OperationType,
|
||||
} from '../operation-registry';
|
||||
|
||||
describe('OperationRegistry', () => {
|
||||
beforeEach(() => {
|
||||
// Reset registry before each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
describe('Singleton Pattern', () => {
|
||||
it('should return the same instance on multiple calls', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should create new instance after reset', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
resetOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should clear all operations on reset', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
resetOperationRegistry();
|
||||
const newRegistry = getOperationRegistry();
|
||||
|
||||
expect(newRegistry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerOperation', () => {
|
||||
it('should register a basic operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
expect(operation?.type).toBe('spec-creation');
|
||||
expect(operation?.profileId).toBe('profile1');
|
||||
expect(operation?.profileName).toBe('Profile 1');
|
||||
expect(operation?.restartFn).toBe(mockRestart);
|
||||
expect(operation?.startedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should register operation with optional stopFn', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const mockStop = vi.fn();
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.stopFn).toBe(mockStop);
|
||||
});
|
||||
|
||||
it('should register operation with metadata', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should emit operation-registered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-registered', eventListener);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'task-execution',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
const emittedOperation = eventListener.mock.calls[0][0];
|
||||
expect(emittedOperation.id).toBe('op1');
|
||||
expect(emittedOperation.type).toBe('task-execution');
|
||||
});
|
||||
|
||||
it('should increment operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow registering multiple operation types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
const types: OperationType[] = [
|
||||
'spec-creation',
|
||||
'task-execution',
|
||||
'pr-review',
|
||||
'mr-review',
|
||||
'insights',
|
||||
'roadmap',
|
||||
'changelog',
|
||||
'ideation',
|
||||
'triage',
|
||||
'other',
|
||||
];
|
||||
|
||||
types.forEach((type, index) => {
|
||||
registry.registerOperation(
|
||||
`op${index}`,
|
||||
type,
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
});
|
||||
|
||||
expect(registry.getOperationCount()).toBe(types.length);
|
||||
|
||||
types.forEach((type, index) => {
|
||||
const op = registry.getOperation(`op${index}`);
|
||||
expect(op?.type).toBe(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterOperation', () => {
|
||||
it('should unregister an existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperation('op1')).toBeDefined();
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperation('op1')).toBeUndefined();
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit operation-unregistered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.unregisterOperation('op1');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution');
|
||||
});
|
||||
|
||||
it('should handle unregistering non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
// Should not throw
|
||||
expect(() => registry.unregisterOperation('non-existent')).not.toThrow();
|
||||
|
||||
// Should not emit event for non-existent operation
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should decrement operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.unregisterOperation('op2');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperation', () => {
|
||||
it('should retrieve operation by id', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const operation = registry.getOperation('non-existent');
|
||||
expect(operation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationsByProfile', () => {
|
||||
it('should return operations for a specific profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const profile1Ops = registry.getOperationsByProfile('profile1');
|
||||
expect(profile1Ops).toHaveLength(2);
|
||||
expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toHaveLength(1);
|
||||
expect(profile2Ops[0].id).toBe('op3');
|
||||
});
|
||||
|
||||
it('should return empty array for profile with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllOperationsByProfile', () => {
|
||||
it('should return all operations grouped by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
|
||||
expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']);
|
||||
expect(allOps['profile1']).toHaveLength(2);
|
||||
expect(allOps['profile2']).toHaveLength(1);
|
||||
expect(allOps['profile3']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty object when no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
expect(allOps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
it('should return correct summary with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const summary = registry.getSummary();
|
||||
expect(summary.totalRunning).toBe(0);
|
||||
expect(summary.byProfile).toEqual({});
|
||||
expect(summary.byType).toEqual({});
|
||||
});
|
||||
|
||||
it('should count operations by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(3);
|
||||
expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']);
|
||||
expect(summary.byProfile['profile2']).toEqual(['op3']);
|
||||
});
|
||||
|
||||
it('should count operations by type', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.byType['spec-creation']).toBe(2);
|
||||
expect(summary.byType['pr-review']).toBe(1);
|
||||
expect(summary.byType['insights']).toBe(1);
|
||||
});
|
||||
|
||||
it('should return complete summary with multiple profiles and types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(5);
|
||||
expect(Object.keys(summary.byProfile)).toHaveLength(3);
|
||||
expect(Object.keys(summary.byType)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartOperationsOnProfile', () => {
|
||||
it('should restart all operations on a profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(mockRestart1).toHaveBeenCalledWith('profile2');
|
||||
expect(mockRestart2).toHaveBeenCalledWith('profile2');
|
||||
|
||||
// Verify profile was updated
|
||||
const op1 = registry.getOperation('op1');
|
||||
const op2 = registry.getOperation('op2');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
expect(op1?.profileName).toBe('Profile 2');
|
||||
expect(op2?.profileId).toBe('profile2');
|
||||
expect(op2?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should call stopFn before restart if provided', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockStop = vi.fn().mockResolvedValue(undefined);
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
// Ensure stopFn was called before restartFn
|
||||
expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRestart.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 when no operations on profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile2',
|
||||
'profile3',
|
||||
'Profile 3'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle restart failure gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails
|
||||
const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
// Only op1 succeeded
|
||||
expect(count).toBe(1);
|
||||
|
||||
// op1 should have updated profile
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
|
||||
// op2 and op3 should still have old profile
|
||||
const op2 = registry.getOperation('op2');
|
||||
const op3 = registry.getOperation('op3');
|
||||
expect(op2?.profileId).toBe('profile1');
|
||||
expect(op3?.profileId).toBe('profile1');
|
||||
});
|
||||
|
||||
it('should emit operation-restarted event for each successful restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(2);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2');
|
||||
expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should emit operations-restarted event after restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should not emit operations-restarted event if no restarts succeeded', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(false);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle synchronous restart functions', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOperationProfile', () => {
|
||||
it('should update profile for existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.updateOperationProfile('op1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(operation?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should handle updating non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(3);
|
||||
|
||||
registry.clear();
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
expect(registry.getSummary().totalRunning).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle registering operation with same id (overwrites)', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockReturnValue(true);
|
||||
const mockRestart2 = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.type).toBe('task-execution');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle multiple unregisters of same operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
// Second unregister should not throw or cause issues
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle restart with no operations gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve operation metadata through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should preserve startedAt timestamp through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const originalOp = registry.getOperation('op1');
|
||||
const originalStartTime = originalOp?.startedAt;
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const updatedOp = registry.getOperation('op1');
|
||||
expect(updatedOp?.startedAt).toBe(originalStartTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
getKeychainServiceName,
|
||||
getWindowsCredentialTarget,
|
||||
getCredentialsFromKeychain,
|
||||
getFullCredentialsFromKeychain,
|
||||
getCredentials,
|
||||
clearKeychainCache,
|
||||
clearCredentialCache,
|
||||
@@ -358,19 +359,24 @@ describe('credential-utils', () => {
|
||||
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
|
||||
});
|
||||
|
||||
it('should return null when PowerShell not found', () => {
|
||||
it('should return null when PowerShell not found and no credentials file exists', () => {
|
||||
// Neither PowerShell nor credentials file exists
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBeNull();
|
||||
expect(result.email).toBeNull();
|
||||
expect(result.error).toBe('PowerShell not found');
|
||||
// No error because file fallback returns null gracefully when file doesn't exist
|
||||
});
|
||||
|
||||
it('should return credentials from Windows Credential Manager', () => {
|
||||
// Mock PowerShell path found
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
it('should return credentials from Windows Credential Manager when file is empty', () => {
|
||||
// Mock PowerShell path found, but credentials file doesn't exist
|
||||
vi.mocked(existsSync).mockImplementation((path: unknown) => {
|
||||
const pathStr = String(path);
|
||||
// PowerShell exists, but credentials file doesn't
|
||||
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-windows-token-789',
|
||||
@@ -384,9 +390,33 @@ describe('credential-utils', () => {
|
||||
expect(result.email).toBe('windows@example.com');
|
||||
});
|
||||
|
||||
it('should return null when credential not found', () => {
|
||||
it('should fall back to file when Credential Manager returns empty', () => {
|
||||
// Mock PowerShell exists but returns empty (no credential in Credential Manager)
|
||||
// Mock file exists with valid credentials
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(execFileSync).mockReturnValue('');
|
||||
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-file-fallback-token',
|
||||
email: 'file@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBe('sk-ant-file-fallback-token');
|
||||
expect(result.email).toBe('file@example.com');
|
||||
});
|
||||
|
||||
it('should return null when both Credential Manager and file have no credentials', () => {
|
||||
// Mock PowerShell exists but returns empty
|
||||
// Mock credentials file doesn't exist
|
||||
vi.mocked(existsSync).mockImplementation((path: unknown) => {
|
||||
const pathStr = String(path);
|
||||
// PowerShell exists, but credentials file doesn't
|
||||
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
@@ -394,14 +424,137 @@ describe('credential-utils', () => {
|
||||
expect(result.email).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle invalid JSON from Credential Manager', () => {
|
||||
it('should handle invalid JSON from Credential Manager by falling back to file', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(execFileSync).mockReturnValue('invalid json');
|
||||
vi.mocked(execFileSync).mockReturnValue('invalid json'); // Invalid JSON from Credential Manager
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-file-token-after-cm-failure',
|
||||
email: 'fallback@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
// Should fall back to file and get valid credentials
|
||||
expect(result.token).toBe('sk-ant-file-token-after-cm-failure');
|
||||
expect(result.email).toBe('fallback@example.com');
|
||||
});
|
||||
|
||||
it('should prefer file credentials when both sources have tokens', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-windows-file-token',
|
||||
email: 'windowsfile@example.com',
|
||||
},
|
||||
}));
|
||||
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-credman-token',
|
||||
email: 'credman@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getCredentialsFromKeychain();
|
||||
|
||||
// Should prefer file since Claude CLI writes there after login
|
||||
expect(result.token).toBe('sk-ant-windows-file-token');
|
||||
expect(result.email).toBe('windowsfile@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullCredentialsFromKeychain (Windows)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isMacOS).mockReturnValue(false);
|
||||
vi.mocked(isWindows).mockReturnValue(true);
|
||||
vi.mocked(isLinux).mockReturnValue(false);
|
||||
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
|
||||
clearCredentialCache();
|
||||
});
|
||||
|
||||
it('should return full credentials from file when available', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-full-creds-token',
|
||||
refreshToken: 'refresh-token-123',
|
||||
expiresAt: 1700000000000,
|
||||
email: 'full@example.com',
|
||||
scopes: ['user:read', 'user:write'],
|
||||
},
|
||||
}));
|
||||
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
|
||||
|
||||
const result = getFullCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBe('sk-ant-full-creds-token');
|
||||
expect(result.refreshToken).toBe('refresh-token-123');
|
||||
expect(result.expiresAt).toBe(1700000000000);
|
||||
expect(result.email).toBe('full@example.com');
|
||||
expect(result.scopes).toEqual(['user:read', 'user:write']);
|
||||
});
|
||||
|
||||
it('should return credentials from Credential Manager when file is empty', () => {
|
||||
vi.mocked(existsSync).mockImplementation((path: unknown) => {
|
||||
const pathStr = String(path);
|
||||
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-credman-full-token',
|
||||
refreshToken: 'credman-refresh',
|
||||
expiresAt: 1700000000000,
|
||||
email: 'credman@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getFullCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBe('sk-ant-credman-full-token');
|
||||
expect(result.refreshToken).toBe('credman-refresh');
|
||||
expect(result.email).toBe('credman@example.com');
|
||||
});
|
||||
|
||||
it('should prefer file credentials when both sources have tokens (consistent with basic API)', () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-file-full-token',
|
||||
refreshToken: 'file-refresh',
|
||||
expiresAt: 1700000000000,
|
||||
email: 'file@example.com',
|
||||
},
|
||||
}));
|
||||
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'sk-ant-credman-full-token',
|
||||
refreshToken: 'credman-refresh',
|
||||
expiresAt: 1800000000000, // Later expiry
|
||||
email: 'credman@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = getFullCredentialsFromKeychain();
|
||||
|
||||
// Should prefer file since Claude CLI writes there after login
|
||||
// This is consistent with getCredentialsFromKeychain behavior
|
||||
expect(result.token).toBe('sk-ant-file-full-token');
|
||||
expect(result.refreshToken).toBe('file-refresh');
|
||||
expect(result.email).toBe('file@example.com');
|
||||
});
|
||||
|
||||
it('should return null when both sources have no credentials', () => {
|
||||
vi.mocked(existsSync).mockImplementation((path: unknown) => {
|
||||
const pathStr = String(path);
|
||||
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
|
||||
});
|
||||
vi.mocked(execFileSync).mockReturnValue('');
|
||||
|
||||
const result = getFullCredentialsFromKeychain();
|
||||
|
||||
expect(result.token).toBeNull();
|
||||
expect(result.email).toBeNull();
|
||||
expect(result.refreshToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Unified registry for ALL Claude Agent SDK operations.
|
||||
*
|
||||
* This is the single source of truth for tracking running operations that use
|
||||
* Claude profiles. It enables:
|
||||
* 1. Proactive account swapping - restart operations on a different profile
|
||||
* 2. Rate limit recovery - know which operations to restart after auth refresh
|
||||
* 3. Usage attribution - track which profile is being used by which operation
|
||||
*
|
||||
* Operations include:
|
||||
* - Autonomous tasks (spec creation, task execution)
|
||||
* - GitHub PR reviews
|
||||
* - GitLab MR reviews
|
||||
* - Insights analysis
|
||||
* - Roadmap generation
|
||||
* - Changelog generation
|
||||
* - Any other Claude SDK subprocess
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Types of operations that use Claude SDK
|
||||
*/
|
||||
export type OperationType =
|
||||
| 'spec-creation'
|
||||
| 'task-execution'
|
||||
| 'pr-review'
|
||||
| 'mr-review'
|
||||
| 'insights'
|
||||
| 'roadmap'
|
||||
| 'changelog'
|
||||
| 'ideation'
|
||||
| 'triage'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Registered operation entry
|
||||
*
|
||||
* IMPORTANT: Object reference stability during restarts
|
||||
* =====================================================
|
||||
* When an operation is restarted via restartFn, the restartFn implementation may
|
||||
* choose to re-register the operation (creating a new RegisteredOperation object)
|
||||
* OR update the existing one. Either approach is valid:
|
||||
*
|
||||
* 1. RE-REGISTRATION (AgentManager pattern):
|
||||
* - restartFn calls registerOperation() which replaces the Map entry
|
||||
* - Creates a new RegisteredOperation object with fresh closures
|
||||
* - Previous object references become stale and should not be used
|
||||
* - Callers MUST call getOperation(id) again to get the fresh reference
|
||||
*
|
||||
* 2. IN-PLACE UPDATE (alternative pattern):
|
||||
* - restartFn updates internal state but doesn't re-register
|
||||
* - Object reference remains valid
|
||||
* - Registry calls updateOperationProfile() to sync profileId
|
||||
*
|
||||
* BEST PRACTICE for consumers:
|
||||
* - Don't hold long-lived references to RegisteredOperation objects
|
||||
* - Always use getOperation(id) to get current state
|
||||
* - Subscribe to 'operation-restarted' events to know when to refresh
|
||||
* - If you must hold a reference, listen for 'operation-restarted' and refresh it
|
||||
*/
|
||||
export interface RegisteredOperation {
|
||||
/** Unique operation ID */
|
||||
id: string;
|
||||
/** Type of operation */
|
||||
type: OperationType;
|
||||
/** Profile ID currently being used */
|
||||
profileId: string;
|
||||
/** Profile name for logging */
|
||||
profileName: string;
|
||||
/** When the operation started */
|
||||
startedAt: Date;
|
||||
/** Optional metadata (project ID, PR number, etc.) */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Function to restart this operation with a new profile.
|
||||
* Returns true if restart was initiated successfully.
|
||||
* The registry will update the profileId after successful restart.
|
||||
*
|
||||
* IMPORTANT: This function may re-register the operation (creating a new object)
|
||||
* or update in-place. Callers should use getOperation(id) after restart to get
|
||||
* the current reference.
|
||||
*/
|
||||
restartFn: (newProfileId: string) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Optional function to stop the operation.
|
||||
* Called before restart if provided.
|
||||
*/
|
||||
stopFn?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the operation registry
|
||||
*
|
||||
* NOTE: This interface is defined for documentation purposes only. It describes the event types
|
||||
* that ClaudeOperationRegistry can emit, but is not currently enforced at the type system level.
|
||||
* EventEmitter uses runtime event names, so type-safe event binding would require additional
|
||||
* type assertion infrastructure. This interface serves as documentation for consumers of the
|
||||
* operation registry to know which events are available and their callback signatures.
|
||||
*/
|
||||
export interface OperationRegistryEvents {
|
||||
'operation-registered': (operation: RegisteredOperation) => void;
|
||||
'operation-unregistered': (operationId: string, type: OperationType) => void;
|
||||
'operation-restarted': (operationId: string, oldProfileId: string, newProfileId: string) => void;
|
||||
'operations-restarted': (count: number, oldProfileId: string, newProfileId: string) => void;
|
||||
'operation-profile-updated': (operationId: string, oldProfileId: string, newProfileId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry for Claude SDK operations
|
||||
*
|
||||
* CONSUMER GUIDELINES: Object Reference Stability
|
||||
* ================================================
|
||||
* Operations may be restarted during profile swaps. When this happens:
|
||||
*
|
||||
* 1. The operation's restartFn is called with a new profileId
|
||||
* 2. The restartFn may choose to:
|
||||
* a) Re-register the operation (creates new RegisteredOperation object), OR
|
||||
* b) Update internal state without re-registering (keeps same object)
|
||||
*
|
||||
* 3. Either pattern is valid, but has implications for consumers:
|
||||
* - Pattern (a): Previous object references become stale
|
||||
* - Pattern (b): Object references remain valid
|
||||
*
|
||||
* BEST PRACTICES for consumers:
|
||||
* - Don't hold long-lived references to RegisteredOperation objects
|
||||
* - Always use getOperation(id) to get current state when needed
|
||||
* - Subscribe to 'operation-restarted' events to know when state may have changed
|
||||
* - Use hasOperation(id) to verify an operation is still registered
|
||||
*
|
||||
* EXAMPLE: Safely working with operation references
|
||||
* ```typescript
|
||||
* const registry = getOperationRegistry();
|
||||
*
|
||||
* // Initial fetch
|
||||
* let operation = registry.getOperation('task-123');
|
||||
*
|
||||
* // Listen for restarts
|
||||
* registry.onOperationRestarted((operationId, oldProfileId, newProfileId) => {
|
||||
* if (operationId === 'task-123') {
|
||||
* // Refresh reference after restart
|
||||
* operation = registry.getOperation('task-123');
|
||||
* console.log('Operation restarted with new profile:', newProfileId);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // When accessing operation state later, prefer fresh fetch:
|
||||
* const currentOp = registry.getOperation('task-123');
|
||||
* if (currentOp) {
|
||||
* console.log('Current profile:', currentOp.profileId);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class ClaudeOperationRegistry extends EventEmitter {
|
||||
private operations: Map<string, RegisteredOperation> = new Map();
|
||||
private debugMode: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.debugMode = process.env.DEBUG === 'true';
|
||||
}
|
||||
|
||||
private debugLog(...args: unknown[]): void {
|
||||
if (this.debugMode) {
|
||||
console.log('[OperationRegistry]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new operation
|
||||
*/
|
||||
registerOperation(
|
||||
id: string,
|
||||
type: OperationType,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
restartFn: RegisteredOperation['restartFn'],
|
||||
options?: {
|
||||
stopFn?: RegisteredOperation['stopFn'];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): void {
|
||||
const operation: RegisteredOperation = {
|
||||
id,
|
||||
type,
|
||||
profileId,
|
||||
profileName,
|
||||
startedAt: new Date(),
|
||||
restartFn,
|
||||
stopFn: options?.stopFn,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
this.operations.set(id, operation);
|
||||
this.debugLog('Operation registered:', {
|
||||
id,
|
||||
type,
|
||||
profileId,
|
||||
profileName,
|
||||
metadata: options?.metadata,
|
||||
});
|
||||
|
||||
this.emit('operation-registered', operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an operation (when it completes or is cancelled)
|
||||
*/
|
||||
unregisterOperation(id: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
this.operations.delete(id);
|
||||
this.debugLog('Operation unregistered:', { id, type: operation.type });
|
||||
this.emit('operation-unregistered', id, operation.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations running on a specific profile
|
||||
*/
|
||||
getOperationsByProfile(profileId: string): RegisteredOperation[] {
|
||||
const result: RegisteredOperation[] = [];
|
||||
for (const op of this.operations.values()) {
|
||||
if (op.profileId === profileId) {
|
||||
result.push(op);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running operations grouped by profile
|
||||
*/
|
||||
getAllOperationsByProfile(): Record<string, RegisteredOperation[]> {
|
||||
const result: Record<string, RegisteredOperation[]> = {};
|
||||
for (const op of this.operations.values()) {
|
||||
if (!result[op.profileId]) {
|
||||
result[op.profileId] = [];
|
||||
}
|
||||
result[op.profileId].push(op);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation by ID
|
||||
*
|
||||
* IMPORTANT: Always call this method to get the current operation state.
|
||||
* Don't hold long-lived references to RegisteredOperation objects, as they
|
||||
* may become stale after a restart. Instead, call getOperation(id) whenever
|
||||
* you need current state, or subscribe to 'operation-restarted' events.
|
||||
*/
|
||||
getOperation(id: string): RegisteredOperation | undefined {
|
||||
return this.operations.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation exists and is currently registered.
|
||||
* Use this to verify an operation reference is still valid.
|
||||
*
|
||||
* @param id - Operation ID to check
|
||||
* @returns true if operation exists in registry, false otherwise
|
||||
*/
|
||||
hasOperation(id: string): boolean {
|
||||
return this.operations.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running operations
|
||||
*/
|
||||
getOperationCount(): number {
|
||||
return this.operations.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of running operations for logging
|
||||
*/
|
||||
getSummary(): {
|
||||
totalRunning: number;
|
||||
byProfile: Record<string, string[]>;
|
||||
byType: Record<OperationType, number>;
|
||||
} {
|
||||
const byProfile: Record<string, string[]> = {};
|
||||
const byType: Record<string, number> = {};
|
||||
|
||||
for (const op of this.operations.values()) {
|
||||
// By profile
|
||||
if (!byProfile[op.profileId]) {
|
||||
byProfile[op.profileId] = [];
|
||||
}
|
||||
byProfile[op.profileId].push(op.id);
|
||||
|
||||
// By type
|
||||
byType[op.type] = (byType[op.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalRunning: this.operations.size,
|
||||
byProfile,
|
||||
byType: byType as Record<OperationType, number>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart all operations running on a specific profile with a new profile.
|
||||
* This is called by UsageMonitor during proactive swaps.
|
||||
*
|
||||
* IMPORTANT: Object reference stability after restart
|
||||
* ====================================================
|
||||
* When operations are restarted, their restartFn implementations may:
|
||||
* 1. Re-register the operation (AgentManager pattern) - creates new object
|
||||
* 2. Update in-place (alternative pattern) - keeps same object
|
||||
*
|
||||
* For consumers holding operation references:
|
||||
* - Your reference may become stale if the operation re-registers
|
||||
* - Always call getOperation(id) after this method to get fresh reference
|
||||
* - Or subscribe to 'operation-restarted' events and refresh on each event
|
||||
*
|
||||
* This method emits:
|
||||
* - 'operation-restarted' for each successful restart (use this to refresh refs)
|
||||
* - 'operations-restarted' once with total count
|
||||
* - 'operation-profile-updated' for each profile update
|
||||
*
|
||||
* @param oldProfileId - Profile ID to migrate away from
|
||||
* @param newProfileId - Profile ID to migrate to
|
||||
* @param newProfileName - Profile name for logging
|
||||
* @returns Number of operations that were restarted
|
||||
*/
|
||||
async restartOperationsOnProfile(
|
||||
oldProfileId: string,
|
||||
newProfileId: string,
|
||||
newProfileName: string
|
||||
): Promise<number> {
|
||||
const operations = this.getOperationsByProfile(oldProfileId);
|
||||
|
||||
if (operations.length === 0) {
|
||||
this.debugLog('No operations to restart on profile:', oldProfileId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log('[OperationRegistry] Restarting', operations.length, 'operations:', {
|
||||
from: oldProfileId,
|
||||
to: newProfileId,
|
||||
operations: operations.map(op => ({ id: op.id, type: op.type })),
|
||||
});
|
||||
|
||||
let restartedCount = 0;
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
// Stop the operation first if a stop function is provided
|
||||
if (op.stopFn) {
|
||||
this.debugLog('Stopping operation before restart:', op.id);
|
||||
await op.stopFn();
|
||||
}
|
||||
|
||||
// Call the restart function
|
||||
this.debugLog('Restarting operation:', op.id, 'with profile:', newProfileId);
|
||||
const success = await op.restartFn(newProfileId);
|
||||
|
||||
if (success) {
|
||||
restartedCount++;
|
||||
|
||||
// Update the profile for operations that weren't re-registered during restart.
|
||||
// For AgentManager tasks, restartFn may create a NEW object in the Map,
|
||||
// in which case this update is harmless (updates the new reference).
|
||||
// For other operations, this ensures the profile is properly updated.
|
||||
this.updateOperationProfile(op.id, newProfileId, newProfileName);
|
||||
|
||||
// Re-fetch from Map to get the current object (restartFn may have
|
||||
// re-registered the operation with a new object)
|
||||
const currentOp = this.operations.get(op.id);
|
||||
|
||||
console.log('[OperationRegistry] Operation restarted successfully:', {
|
||||
id: op.id,
|
||||
type: currentOp?.type ?? op.type,
|
||||
newProfile: newProfileName,
|
||||
});
|
||||
|
||||
this.emit('operation-restarted', op.id, oldProfileId, newProfileId);
|
||||
} else {
|
||||
console.warn('[OperationRegistry] Operation restart returned false:', op.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OperationRegistry] Failed to restart operation:', op.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (restartedCount > 0) {
|
||||
this.emit('operations-restarted', restartedCount, oldProfileId, newProfileId);
|
||||
}
|
||||
|
||||
console.log('[OperationRegistry] Restart complete:', {
|
||||
total: operations.length,
|
||||
succeeded: restartedCount,
|
||||
failed: operations.length - restartedCount,
|
||||
});
|
||||
|
||||
return restartedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile assignment for an operation (e.g., after restart)
|
||||
*/
|
||||
updateOperationProfile(id: string, newProfileId: string, newProfileName: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
const oldProfileId = operation.profileId;
|
||||
operation.profileId = newProfileId;
|
||||
operation.profileName = newProfileName;
|
||||
this.debugLog('Operation profile updated:', {
|
||||
id,
|
||||
from: oldProfileId,
|
||||
to: newProfileId,
|
||||
});
|
||||
this.emit('operation-profile-updated', id, oldProfileId, newProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered operations (for testing or cleanup)
|
||||
*/
|
||||
clear(): void {
|
||||
this.operations.clear();
|
||||
this.debugLog('All operations cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-registered
|
||||
* Subscribe to operation registration events
|
||||
*/
|
||||
onOperationRegistered(callback: (operation: RegisteredOperation) => void): () => void {
|
||||
this.on('operation-registered', callback);
|
||||
return () => this.off('operation-registered', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-unregistered
|
||||
* Subscribe to operation unregistration events
|
||||
*/
|
||||
onOperationUnregistered(callback: (operationId: string, type: OperationType) => void): () => void {
|
||||
this.on('operation-unregistered', callback);
|
||||
return () => this.off('operation-unregistered', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-restarted
|
||||
* Subscribe to individual operation restart events
|
||||
*/
|
||||
onOperationRestarted(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operation-restarted', callback);
|
||||
return () => this.off('operation-restarted', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operations-restarted
|
||||
* Subscribe to batch operation restart events
|
||||
*/
|
||||
onOperationsRestarted(callback: (count: number, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operations-restarted', callback);
|
||||
return () => this.off('operations-restarted', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-profile-updated
|
||||
* Subscribe to operation profile update events
|
||||
*/
|
||||
onOperationProfileUpdated(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operation-profile-updated', callback);
|
||||
return () => this.off('operation-profile-updated', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let registryInstance: ClaudeOperationRegistry | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton ClaudeOperationRegistry instance
|
||||
*/
|
||||
export function getOperationRegistry(): ClaudeOperationRegistry {
|
||||
if (!registryInstance) {
|
||||
registryInstance = new ClaudeOperationRegistry();
|
||||
}
|
||||
return registryInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the registry (for testing)
|
||||
*/
|
||||
export function resetOperationRegistry(): void {
|
||||
if (registryInstance) {
|
||||
registryInstance.clear();
|
||||
registryInstance.removeAllListeners();
|
||||
}
|
||||
registryInstance = null;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = {
|
||||
sessionThreshold: 95, // Consider switching at 95% session usage
|
||||
weeklyThreshold: 99, // Consider switching at 99% weekly usage
|
||||
autoSwitchOnRateLimit: false, // Prompt user by default
|
||||
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
|
||||
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
|
||||
};
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
|
||||
* Expand ~ in path to home directory
|
||||
*/
|
||||
export function expandHomePath(path: string): string {
|
||||
if (path && path.startsWith('~')) {
|
||||
if (path?.startsWith('~')) {
|
||||
const home = homedir();
|
||||
return path.replace(/^~/, home);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
refreshOAuthToken,
|
||||
ensureValidToken,
|
||||
reactiveTokenRefresh,
|
||||
type TokenRefreshResult
|
||||
} from './token-refresh';
|
||||
|
||||
// Mock credential-utils
|
||||
|
||||
@@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
|
||||
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
|
||||
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
|
||||
import { isProfileRateLimited } from './rate-limit-manager';
|
||||
import { getOperationRegistry } from './operation-registry';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ApiProvider };
|
||||
@@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const activeProfile = profilesFile.profiles.find(
|
||||
(p) => p.id === profilesFile.activeProfileId
|
||||
);
|
||||
if (activeProfile && activeProfile.apiKey) {
|
||||
if (activeProfile?.apiKey) {
|
||||
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
|
||||
return activeProfile.apiKey;
|
||||
}
|
||||
@@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
let baseUrl: string;
|
||||
let provider: ApiProvider;
|
||||
|
||||
if (activeProfile && activeProfile.isAPIProfile) {
|
||||
if (activeProfile?.isAPIProfile) {
|
||||
// Use the pre-determined profile to avoid race conditions
|
||||
// Trust the activeProfile data and use baseUrl directly
|
||||
baseUrl = activeProfile.baseUrl;
|
||||
@@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const profilesFile = await loadProfilesFile();
|
||||
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
|
||||
|
||||
if (apiProfile && apiProfile.apiKey) {
|
||||
if (apiProfile?.apiKey) {
|
||||
// API profile found
|
||||
baseUrl = apiProfile.baseUrl;
|
||||
provider = detectProvider(baseUrl);
|
||||
@@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter {
|
||||
limitType
|
||||
});
|
||||
|
||||
// PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials
|
||||
// This includes autonomous tasks, PR reviews, insights, roadmap, etc.
|
||||
// Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost
|
||||
const operationRegistry = getOperationRegistry();
|
||||
const operationSummary = operationRegistry.getSummary();
|
||||
const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || [];
|
||||
|
||||
// Always log running operations info for debugging
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', {
|
||||
oldProfileId: currentProfileId,
|
||||
newProfileId: bestAccount.id,
|
||||
totalRunning: operationSummary.totalRunning,
|
||||
byProfile: operationSummary.byProfile,
|
||||
byType: operationSummary.byType,
|
||||
operationIdsOnOldProfile: operationIdsOnOldProfile
|
||||
});
|
||||
|
||||
if (operationIdsOnOldProfile.length > 0) {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile);
|
||||
|
||||
// Restart all operations on the old profile with the new profile
|
||||
const restartedCount = await operationRegistry.restartOperationsOnProfile(
|
||||
currentProfileId,
|
||||
bestAccount.id,
|
||||
bestAccount.name
|
||||
);
|
||||
|
||||
// Emit event for tracking/logging
|
||||
this.emit('proactive-operations-restarted', {
|
||||
fromProfile: { id: currentProfileId, name: fromProfileName },
|
||||
toProfile: { id: bestAccount.id, name: bestAccount.name },
|
||||
operationIds: operationIdsOnOldProfile,
|
||||
restartedCount,
|
||||
limitType,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} else {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart');
|
||||
}
|
||||
|
||||
// Note: Don't immediately check new profile - let normal interval handle it
|
||||
// This prevents cascading swaps if multiple profiles are near limits
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
getWindowsExecutablePaths,
|
||||
getWindowsExecutablePathsAsync,
|
||||
WINDOWS_GIT_PATHS,
|
||||
WINDOWS_GLAB_PATHS,
|
||||
findWindowsExecutableViaWhere,
|
||||
findWindowsExecutableViaWhereAsync,
|
||||
isSecurePath,
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
/**
|
||||
* Supported CLI tools managed by this system
|
||||
*/
|
||||
export type CLITool = 'python' | 'git' | 'gh' | 'claude';
|
||||
export type CLITool = 'python' | 'git' | 'gh' | 'glab' | 'claude';
|
||||
|
||||
/**
|
||||
* User configuration for CLI tool paths
|
||||
@@ -64,6 +65,7 @@ export interface ToolConfig {
|
||||
pythonPath?: string;
|
||||
gitPath?: string;
|
||||
githubCLIPath?: string;
|
||||
gitlabCLIPath?: string;
|
||||
claudePath?: string;
|
||||
}
|
||||
|
||||
@@ -370,6 +372,8 @@ class CLIToolManager {
|
||||
return this.detectGit();
|
||||
case 'gh':
|
||||
return this.detectGitHubCLI();
|
||||
case 'glab':
|
||||
return this.detectGitLabCLI();
|
||||
case 'claude':
|
||||
return this.detectClaude();
|
||||
default:
|
||||
@@ -719,6 +723,105 @@ class CLIToolManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GitLab CLI with multi-level priority
|
||||
*
|
||||
* Priority order:
|
||||
* 1. User configuration (if valid for current platform)
|
||||
* 2. Homebrew glab (macOS)
|
||||
* 3. System PATH
|
||||
* 4. Windows Program Files
|
||||
*
|
||||
* @returns Detection result for GitLab CLI
|
||||
*/
|
||||
private detectGitLabCLI(): ToolDetectionResult {
|
||||
// 1. User configuration
|
||||
if (this.userConfig.gitlabCLIPath) {
|
||||
// Check if path is from wrong platform (e.g., Windows path on macOS)
|
||||
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
|
||||
console.warn(
|
||||
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
|
||||
);
|
||||
} else {
|
||||
const validation = this.validateGitLabCLI(this.userConfig.gitlabCLIPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: this.userConfig.gitlabCLIPath,
|
||||
version: validation.version,
|
||||
source: 'user-config',
|
||||
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
|
||||
};
|
||||
}
|
||||
console.warn(
|
||||
`[GitLab CLI] User-configured path invalid: ${validation.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Homebrew (macOS)
|
||||
if (isMacOS()) {
|
||||
const homebrewPaths = [
|
||||
'/opt/homebrew/bin/glab', // Apple Silicon
|
||||
'/usr/local/bin/glab', // Intel Mac
|
||||
];
|
||||
|
||||
for (const glabPath of homebrewPaths) {
|
||||
if (existsSync(glabPath)) {
|
||||
const validation = this.validateGitLabCLI(glabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: glabPath,
|
||||
version: validation.version,
|
||||
source: 'homebrew',
|
||||
message: `Using Homebrew GitLab CLI: ${glabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. System PATH (augmented)
|
||||
const glabPath = findExecutable('glab');
|
||||
if (glabPath) {
|
||||
const validation = this.validateGitLabCLI(glabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: glabPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using system GitLab CLI: ${glabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Windows Program Files
|
||||
if (isWindows()) {
|
||||
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
|
||||
for (const glabPath of windowsPaths) {
|
||||
const validation = this.validateGitLabCLI(glabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: glabPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using Windows GitLab CLI: ${glabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Not found
|
||||
return {
|
||||
found: false,
|
||||
source: 'fallback',
|
||||
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude CLI with multi-level priority
|
||||
*
|
||||
@@ -846,6 +949,7 @@ class CLIToolManager {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: getAugmentedEnv(),
|
||||
}).trim();
|
||||
|
||||
const match = version.match(/Python (\d+\.\d+\.\d+)/);
|
||||
@@ -896,6 +1000,7 @@ class CLIToolManager {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: getAugmentedEnv(),
|
||||
}).trim();
|
||||
|
||||
const match = version.match(/git version (\d+\.\d+\.\d+)/);
|
||||
@@ -926,6 +1031,7 @@ class CLIToolManager {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: getAugmentedEnv(),
|
||||
}).trim();
|
||||
|
||||
const match = version.match(/gh version (\d+\.\d+\.\d+)/);
|
||||
@@ -944,6 +1050,38 @@ class CLIToolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GitLab CLI availability and version
|
||||
*
|
||||
* @param glabCmd - The GitLab CLI command to validate
|
||||
* @returns Validation result with version information
|
||||
*/
|
||||
private validateGitLabCLI(glabCmd: string): ToolValidation {
|
||||
try {
|
||||
const version = execFileSync(glabCmd, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: getAugmentedEnv(),
|
||||
}).trim();
|
||||
|
||||
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
|
||||
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
|
||||
const versionStr = match ? match[1] : version.split('\n')[0];
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version: versionStr,
|
||||
message: `GitLab CLI ${versionStr} is available`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Claude CLI availability and version
|
||||
*
|
||||
@@ -1097,6 +1235,8 @@ class CLIToolManager {
|
||||
return this.detectGitAsync();
|
||||
case 'gh':
|
||||
return this.detectGitHubCLIAsync();
|
||||
case 'glab':
|
||||
return this.detectGitLabCLIAsync();
|
||||
default:
|
||||
return {
|
||||
found: false,
|
||||
@@ -1298,6 +1438,39 @@ class CLIToolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate GitLab CLI availability and version asynchronously (non-blocking)
|
||||
*
|
||||
* @param glabCmd - The GitLab CLI command to validate
|
||||
* @returns Promise resolving to validation result
|
||||
*/
|
||||
private async validateGitLabCLIAsync(glabCmd: string): Promise<ToolValidation> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(glabCmd, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: await getAugmentedEnvAsync(),
|
||||
});
|
||||
|
||||
const version = stdout.trim();
|
||||
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
|
||||
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
|
||||
const versionStr = match ? match[1] : version.split('\n')[0];
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
version: versionStr,
|
||||
message: `GitLab CLI ${versionStr} is available`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Claude CLI asynchronously (non-blocking)
|
||||
*
|
||||
@@ -1724,6 +1897,98 @@ class CLIToolManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect GitLab CLI asynchronously (non-blocking)
|
||||
*
|
||||
* Same detection logic as detectGitLabCLI but uses async validation.
|
||||
*
|
||||
* @returns Promise resolving to detection result
|
||||
*/
|
||||
private async detectGitLabCLIAsync(): Promise<ToolDetectionResult> {
|
||||
// 1. User configuration
|
||||
if (this.userConfig.gitlabCLIPath) {
|
||||
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
|
||||
console.warn(
|
||||
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
|
||||
);
|
||||
} else {
|
||||
const validation = await this.validateGitLabCLIAsync(this.userConfig.gitlabCLIPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: this.userConfig.gitlabCLIPath,
|
||||
version: validation.version,
|
||||
source: 'user-config',
|
||||
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
|
||||
};
|
||||
}
|
||||
console.warn(`[GitLab CLI] User-configured path invalid: ${validation.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Homebrew (macOS)
|
||||
if (isMacOS()) {
|
||||
const homebrewPaths = [
|
||||
'/opt/homebrew/bin/glab',
|
||||
'/usr/local/bin/glab',
|
||||
];
|
||||
|
||||
for (const glabPath of homebrewPaths) {
|
||||
if (await existsAsync(glabPath)) {
|
||||
const validation = await this.validateGitLabCLIAsync(glabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: glabPath,
|
||||
version: validation.version,
|
||||
source: 'homebrew',
|
||||
message: `Using Homebrew GitLab CLI: ${glabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. System PATH (augmented)
|
||||
const glabPath = await findExecutableAsync('glab');
|
||||
if (glabPath) {
|
||||
const validation = await this.validateGitLabCLIAsync(glabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: glabPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using system GitLab CLI: ${glabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Windows Program Files
|
||||
if (isWindows()) {
|
||||
const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
|
||||
for (const winGlabPath of windowsPaths) {
|
||||
const validation = await this.validateGitLabCLIAsync(winGlabPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: winGlabPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using Windows GitLab CLI: ${winGlabPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Not found
|
||||
return {
|
||||
found: false,
|
||||
source: 'fallback',
|
||||
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bundled Python path for packaged apps
|
||||
*
|
||||
|
||||
@@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null {
|
||||
const normalizedPath = path.normalize(binPath);
|
||||
|
||||
return fs.existsSync(normalizedPath) ? normalizedPath : null;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Fallback for Windows: try default npm global location when npm.cmd is not in PATH
|
||||
// This happens when the packaged app launches from GUI without full shell environment
|
||||
if (isWindows()) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user