Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d6c8cb14 | |||
| 01295eff99 | |||
| a41e5a0eba | |||
| 5ed85e86ab | |||
| 071379a109 | |||
| 0ddd740bd1 | |||
| 2e2b82365f | |||
| acb131b721 | |||
| df528f0650 | |||
| ff91a1af0f | |||
| 6d0222fa9c | |||
| fe08c644c4 |
@@ -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.6-beta.1)
|
||||
[](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.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-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.
|
||||
|
||||
@@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,7 +15,82 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Concurrency retry constants
|
||||
MAX_CONCURRENCY_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY_SECONDS = 2
|
||||
MAX_RETRY_DELAY_SECONDS = 32
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
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
|
||||
|
||||
@@ -6,8 +6,11 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -57,11 +60,19 @@ from ui import (
|
||||
)
|
||||
|
||||
from .base import (
|
||||
AUTH_FAILURE_PAUSE_FILE,
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS,
|
||||
AUTO_CONTINUE_DELAY_SECONDS,
|
||||
HUMAN_INTERVENTION_FILE,
|
||||
INITIAL_RETRY_DELAY_SECONDS,
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
|
||||
RATE_LIMIT_PAUSE_FILE,
|
||||
RESUME_FILE,
|
||||
sanitize_error_message,
|
||||
)
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
@@ -76,6 +87,219 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_and_clear_resume_file(
|
||||
resume_file: Path,
|
||||
pause_file: Path,
|
||||
fallback_resume_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if resume file exists and clean up both resume and pause files.
|
||||
|
||||
Also checks a fallback location (main project spec dir) in case the frontend
|
||||
couldn't find the worktree and only wrote the RESUME file there.
|
||||
|
||||
Args:
|
||||
resume_file: Path to RESUME file
|
||||
pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE)
|
||||
fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir)
|
||||
|
||||
Returns:
|
||||
True if resume file existed (early resume), False otherwise
|
||||
"""
|
||||
found = resume_file.exists()
|
||||
|
||||
# Check fallback location if primary not found
|
||||
if not found and fallback_resume_file and fallback_resume_file.exists():
|
||||
found = True
|
||||
try:
|
||||
fallback_resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up fallback resume file: {e}")
|
||||
|
||||
if found:
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_rate_limit_reset(
|
||||
spec_dir: Path,
|
||||
wait_seconds: float,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for rate limit reset with periodic checks for resume/cancel.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to check for RESUME file
|
||||
wait_seconds: Maximum time to wait in seconds
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
|
||||
Returns:
|
||||
True if resumed early, False if waited full duration
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= wait_seconds:
|
||||
break
|
||||
|
||||
# Check if user requested resume
|
||||
if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume):
|
||||
return True
|
||||
|
||||
# Wait for next check interval or remaining time
|
||||
sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
# Clean up pause file after wait completes
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_auth_resume(
|
||||
spec_dir: Path,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Wait for user re-authentication signal.
|
||||
|
||||
Blocks until:
|
||||
- RESUME file is created (user completed re-auth in UI)
|
||||
- AUTH_PAUSE file is deleted (alternative resume signal)
|
||||
- Maximum wait timeout is reached (24 hours)
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to monitor for signal files
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS:
|
||||
break
|
||||
|
||||
# Check for resume signals
|
||||
if (
|
||||
_check_and_clear_resume_file(resume_file, pause_file, fallback_resume)
|
||||
or not pause_file.exists()
|
||||
):
|
||||
# If pause file was deleted externally, still clean up resume file if it exists
|
||||
if not pause_file.exists():
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up resume file {resume_file}: {e}")
|
||||
return
|
||||
|
||||
await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Timeout reached - clean up and return
|
||||
print_status(
|
||||
"Authentication wait timeout reached (24 hours) - resuming with original credentials",
|
||||
"warning",
|
||||
)
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}")
|
||||
|
||||
|
||||
def parse_rate_limit_reset_time(error_info: dict | None) -> int | None:
|
||||
"""
|
||||
Parse rate limit reset time from error info.
|
||||
|
||||
Attempts to extract reset time from various formats in error messages.
|
||||
|
||||
TIMEZONE ASSUMPTIONS:
|
||||
- "in X minutes/hours" patterns are timezone-safe (relative time)
|
||||
- "at HH:MM" patterns assume LOCAL timezone, which is reasonable since:
|
||||
1. The user sees timestamps in their local timezone
|
||||
2. The wait calculation happens locally using datetime.now()
|
||||
3. If the API returns UTC "at" times, this would need adjustment
|
||||
(but Claude API typically returns relative times like "in X minutes")
|
||||
|
||||
Args:
|
||||
error_info: Error info dict with 'message' key
|
||||
|
||||
Returns:
|
||||
Unix timestamp of reset time, or None if not parseable
|
||||
"""
|
||||
if not error_info:
|
||||
return None
|
||||
|
||||
message = error_info.get("message", "")
|
||||
|
||||
# Try to find patterns like "resets at 3:00 PM" or "in 5 minutes"
|
||||
# Pattern: "in X minutes/hours" (timezone-safe - relative time)
|
||||
in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I)
|
||||
if in_time_match:
|
||||
amount = int(in_time_match.group(1))
|
||||
unit = in_time_match.group(2).lower()
|
||||
if unit.startswith("hour") or unit.startswith("hr"):
|
||||
delta = timedelta(hours=amount)
|
||||
else:
|
||||
delta = timedelta(minutes=amount)
|
||||
return int((datetime.now() + delta).timestamp())
|
||||
|
||||
# Pattern: "at HH:MM" (12 or 24 hour)
|
||||
at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I)
|
||||
if at_time_match:
|
||||
try:
|
||||
hour = int(at_time_match.group(1))
|
||||
minute = int(at_time_match.group(2))
|
||||
meridiem = at_time_match.group(3)
|
||||
|
||||
# Validate hour range when meridiem is present
|
||||
# Hours should be 1-12 for AM/PM format
|
||||
if meridiem and not (1 <= hour <= 12):
|
||||
return None
|
||||
|
||||
if meridiem:
|
||||
if meridiem.lower() == "pm" and hour < 12:
|
||||
hour += 12
|
||||
elif meridiem.lower() == "am" and hour == 12:
|
||||
hour = 0
|
||||
|
||||
# Validate hour and minute ranges
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if reset_time <= now:
|
||||
reset_time += timedelta(days=1)
|
||||
return int(reset_time.timestamp())
|
||||
except ValueError:
|
||||
# Invalid time values - return None to fall back to standard retry
|
||||
return None
|
||||
|
||||
# No pattern matched - return None to let caller decide retry behavior
|
||||
return None
|
||||
|
||||
|
||||
async def run_autonomous_agent(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
@@ -682,6 +906,135 @@ async def run_autonomous_agent(
|
||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
||||
)
|
||||
|
||||
elif error_info and error_info.get("type") == "rate_limit":
|
||||
# Rate limit error - intelligent wait for reset
|
||||
_reset_concurrency_state()
|
||||
|
||||
reset_timestamp = parse_rate_limit_reset_time(error_info)
|
||||
if reset_timestamp:
|
||||
wait_seconds = reset_timestamp - datetime.now().timestamp()
|
||||
|
||||
# Handle negative wait_seconds (reset time in the past)
|
||||
if wait_seconds <= 0:
|
||||
print_status(
|
||||
"Rate limit reset time already passed - retrying immediately",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
await asyncio.sleep(2) # Brief delay before retry
|
||||
continue
|
||||
|
||||
if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS:
|
||||
# Wait time too long - fail the task
|
||||
print_status("Rate limit wait time too long", "error")
|
||||
print(
|
||||
f"Reset time would require waiting {wait_seconds / 3600:.1f} hours"
|
||||
)
|
||||
print(
|
||||
f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours"
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
"Rate limit wait time exceeds maximum allowed",
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
break
|
||||
|
||||
# Emit pause phase with reset time for frontend
|
||||
wait_minutes = wait_seconds / 60
|
||||
emit_phase(
|
||||
ExecutionPhase.RATE_LIMIT_PAUSED,
|
||||
f"Rate limit - resuming in {wait_minutes:.0f} minutes",
|
||||
reset_timestamp=reset_timestamp,
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Rate limit reached")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Rate limit reached"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"reset_timestamp": reset_timestamp,
|
||||
"error": sanitized_error,
|
||||
}
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print_status(
|
||||
f"Rate limited - waiting {wait_minutes:.0f} minutes for reset",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait with periodic checks for resume signal
|
||||
resumed_early = await wait_for_rate_limit_reset(
|
||||
spec_dir, wait_seconds, source_spec_dir
|
||||
)
|
||||
if resumed_early:
|
||||
print_status("Resumed early by user", "success")
|
||||
|
||||
# Resume execution
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after rate limit")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
else:
|
||||
# Couldn't parse reset time - fall back to standard retry
|
||||
print_status("Rate limit hit (unknown reset time)", "warning")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
_reset_concurrency_state()
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue
|
||||
|
||||
elif error_info and error_info.get("type") == "authentication":
|
||||
# Authentication error - pause for user re-authentication
|
||||
_reset_concurrency_state()
|
||||
|
||||
emit_phase(
|
||||
ExecutionPhase.AUTH_FAILURE_PAUSED,
|
||||
"Re-authentication required",
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Authentication failed")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Authentication failed"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"error": sanitized_error,
|
||||
"requires_action": "re-authenticate",
|
||||
}
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" AUTHENTICATION REQUIRED")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("OAuth token is invalid or expired.")
|
||||
print("Please re-authenticate in the Auto Claude settings.")
|
||||
print()
|
||||
print("The task will automatically resume once you re-authenticate.")
|
||||
print()
|
||||
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait for user to complete re-authentication
|
||||
await wait_for_auth_resume(spec_dir, source_spec_dir)
|
||||
|
||||
print_status("Authentication restored - resuming", "success")
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
|
||||
else:
|
||||
# Other errors - use standard retry logic
|
||||
print_status("Session encountered an error", "error")
|
||||
|
||||
@@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
@@ -34,6 +35,7 @@ from ui import (
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import sanitize_error_message
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
@@ -68,6 +70,93 @@ def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
@@ -568,7 +657,18 @@ async def run_agent_session(
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
error_type = "tool_concurrency" if is_concurrency else "other"
|
||||
is_rate_limit = is_rate_limit_error(e)
|
||||
is_auth = is_authentication_error(e)
|
||||
|
||||
# Classify error type for appropriate handling
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limit:
|
||||
error_type = "rate_limit"
|
||||
elif is_auth:
|
||||
error_type = "authentication"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
@@ -579,20 +679,32 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
)
|
||||
|
||||
# Log concurrency errors prominently
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
|
||||
sanitized_error = sanitize_error_message(str(e))
|
||||
|
||||
# Log errors prominently based on type
|
||||
if is_concurrency:
|
||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||
print(" Claude API limits concurrent tool use in a single request")
|
||||
print(f" Error: {str(e)[:200]}\n")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_rate_limit:
|
||||
print("\n⚠️ Rate limit reached")
|
||||
print(" API usage quota exceeded - waiting for reset")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_auth:
|
||||
print("\n⚠️ Authentication error")
|
||||
print(" OAuth token may be invalid or expired")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {e}")
|
||||
print(f"Error during agent session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": str(e),
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", str(e), error_info
|
||||
return "error", sanitized_error, error_info
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,41 +297,6 @@ When multiple agents report on the same area:
|
||||
- **Track consensus**: Note which findings have cross-agent validation
|
||||
- **Evidence-based, not confidence-based**: Multiple agents agreeing doesn't skip validation - all findings still verified
|
||||
|
||||
## STRUCTURED OUTPUT REQUIREMENTS
|
||||
|
||||
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
|
||||
|
||||
### Valid category values (use EXACTLY one of these, lowercase):
|
||||
- `security` - Authentication, authorization, injection, XSS, secrets
|
||||
- `quality` - Code quality, error handling, maintainability
|
||||
- `logic` - Logic errors, edge cases, race conditions
|
||||
- `test` - Test coverage, test quality
|
||||
- `docs` - Documentation issues
|
||||
- `regression` - Regression from previous fix
|
||||
- `incomplete_fix` - Partial fix that needs more work
|
||||
|
||||
### Valid severity values (use EXACTLY one of these, lowercase):
|
||||
- `critical` - Security vulnerabilities, data loss risk
|
||||
- `high` - Significant bugs, breaking changes
|
||||
- `medium` - Quality issues, should fix
|
||||
- `low` - Suggestions, minor improvements
|
||||
|
||||
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
|
||||
- `READY_TO_MERGE` - All issues resolved, can merge
|
||||
- `MERGE_WITH_CHANGES` - Only LOW severity issues
|
||||
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
|
||||
- `BLOCKED` - CRITICAL issues or CI failing
|
||||
|
||||
### Common mistakes to AVOID:
|
||||
| Wrong | Correct |
|
||||
|-------|---------|
|
||||
| `"duplication"` | Use `"quality"` or `"redundancy"` if in scope |
|
||||
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
|
||||
| `"needs revision"` | `"NEEDS_REVISION"` |
|
||||
| `"Ready to Merge"` | `"READY_TO_MERGE"` |
|
||||
| `"HIGH"` | `"high"` (lowercase for severity) |
|
||||
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
|
||||
|
||||
@@ -626,44 +626,6 @@ The `agent_agreement` field in structured output tracks:
|
||||
**Note:** Agent agreement data is logged for monitoring. The cross-validation results
|
||||
are reflected in each finding's source_agents, cross_validated, and confidence fields.
|
||||
|
||||
## STRUCTURED OUTPUT REQUIREMENTS
|
||||
|
||||
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
|
||||
|
||||
### Valid category values (use EXACTLY one of these, lowercase):
|
||||
- `security` - Authentication, authorization, injection, XSS, secrets
|
||||
- `quality` - Code quality, error handling, maintainability
|
||||
- `logic` - Logic errors, edge cases, race conditions
|
||||
- `codebase_fit` - Architectural consistency, pattern adherence
|
||||
- `test` - Test coverage, test quality
|
||||
- `docs` - Documentation issues
|
||||
- `redundancy` - Code duplication (NOT "duplication"!)
|
||||
- `pattern` - Anti-patterns, design issues
|
||||
- `performance` - Performance problems
|
||||
|
||||
### Valid severity values (use EXACTLY one of these, lowercase):
|
||||
- `critical` - Security vulnerabilities, data loss risk
|
||||
- `high` - Significant bugs, breaking changes
|
||||
- `medium` - Quality issues, should fix
|
||||
- `low` - Suggestions, minor improvements
|
||||
|
||||
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
|
||||
- `APPROVE` - No issues found
|
||||
- `COMMENT` - Only LOW severity issues
|
||||
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
|
||||
- `BLOCKED` - CRITICAL issues
|
||||
|
||||
### Common mistakes to AVOID:
|
||||
| Wrong | Correct |
|
||||
|-------|---------|
|
||||
| `"duplication"` | `"redundancy"` |
|
||||
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
|
||||
| `"needs revision"` | `"NEEDS_REVISION"` |
|
||||
| `"Ready to Merge"` | `"APPROVE"` |
|
||||
| `"READY_TO_MERGE"` | `"APPROVE"` (this schema uses APPROVE) |
|
||||
| `"HIGH"` | `"high"` (lowercase for severity) |
|
||||
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
|
||||
|
||||
## Output Format
|
||||
|
||||
After synthesis and validation, output your final review in this JSON format:
|
||||
|
||||
@@ -818,9 +818,7 @@ class GHClient:
|
||||
return commits[-1].get("oid")
|
||||
return None
|
||||
|
||||
async def get_pr_checks(
|
||||
self, pr_number: int, excluded_checks: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get CI check runs status for a PR.
|
||||
|
||||
@@ -828,7 +826,6 @@ class GHClient:
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
@@ -837,9 +834,7 @@ class GHClient:
|
||||
- failing: Number of failing checks
|
||||
- pending: Number of pending checks
|
||||
- failed_checks: List of failed check names
|
||||
- excluded_checks: List of checks that were excluded from counting
|
||||
"""
|
||||
excluded_set = set(excluded_checks or [])
|
||||
try:
|
||||
# Note: gh pr checks --json only supports: bucket, completedAt, description,
|
||||
# event, link, name, startedAt, state, workflow
|
||||
@@ -854,20 +849,11 @@ class GHClient:
|
||||
failing = 0
|
||||
pending = 0
|
||||
failed_checks = []
|
||||
excluded_from_count = []
|
||||
|
||||
for check in checks:
|
||||
state = check.get("state", "").upper()
|
||||
name = check.get("name", "Unknown")
|
||||
|
||||
# Skip excluded checks (for stuck/broken CI checks)
|
||||
if name in excluded_set:
|
||||
excluded_from_count.append(name)
|
||||
logger.info(
|
||||
f"Excluding CI check '{name}' from counts (user-configured)"
|
||||
)
|
||||
continue
|
||||
|
||||
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
|
||||
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
||||
passing += 1
|
||||
@@ -884,7 +870,6 @@ class GHClient:
|
||||
"failing": failing,
|
||||
"pending": pending,
|
||||
"failed_checks": failed_checks,
|
||||
"excluded_checks": excluded_from_count,
|
||||
}
|
||||
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
|
||||
@@ -894,7 +879,6 @@ class GHClient:
|
||||
"failing": 0,
|
||||
"pending": 0,
|
||||
"failed_checks": [],
|
||||
"excluded_checks": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -989,9 +973,7 @@ class GHClient:
|
||||
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_pr_checks_comprehensive(
|
||||
self, pr_number: int, excluded_checks: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive CI status including workflows awaiting approval.
|
||||
|
||||
@@ -1001,13 +983,12 @@ class GHClient:
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
|
||||
|
||||
Returns:
|
||||
Dict with all check information including awaiting_approval count
|
||||
"""
|
||||
# Get standard checks (with exclusions applied)
|
||||
checks = await self.get_pr_checks(pr_number, excluded_checks=excluded_checks)
|
||||
# Get standard checks
|
||||
checks = await self.get_pr_checks(pr_number)
|
||||
|
||||
# Get workflows awaiting approval
|
||||
awaiting = await self.get_workflows_awaiting_approval(pr_number)
|
||||
|
||||
@@ -14,7 +14,6 @@ REFACTORED: Service layer architecture - orchestrator delegates to specialized s
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -157,17 +156,6 @@ class GitHubOrchestrator:
|
||||
# Initialize rate limiter singleton
|
||||
self.rate_limiter = RateLimiter.get_instance()
|
||||
|
||||
# Load excluded CI checks from environment (for stuck/broken checks)
|
||||
excluded_ci_env = os.environ.get("GITHUB_EXCLUDED_CI_CHECKS", "")
|
||||
self.excluded_ci_checks: list[str] = [
|
||||
check.strip() for check in excluded_ci_env.split(",") if check.strip()
|
||||
]
|
||||
if self.excluded_ci_checks:
|
||||
safe_print(
|
||||
f"[CI] Excluding checks from review: {', '.join(self.excluded_ci_checks)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Initialize service layer
|
||||
self.pr_review_engine = PRReviewEngine(
|
||||
project_dir=self.project_dir,
|
||||
@@ -443,10 +431,7 @@ class GitHubOrchestrator:
|
||||
)
|
||||
|
||||
# Check CI status (comprehensive - includes workflows awaiting approval)
|
||||
# Excluded checks are filtered out based on user configuration
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(
|
||||
pr_number, excluded_checks=self.excluded_ci_checks
|
||||
)
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
|
||||
# Log CI status with awaiting approval info
|
||||
awaiting = ci_status.get("awaiting_approval", 0)
|
||||
@@ -719,10 +704,7 @@ class GitHubOrchestrator:
|
||||
|
||||
# ALWAYS fetch current CI status to detect CI recovery
|
||||
# This must happen BEFORE the early return check to avoid stale CI verdicts
|
||||
# Excluded checks are filtered out based on user configuration
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(
|
||||
pr_number, excluded_checks=self.excluded_ci_checks
|
||||
)
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
followup_context.ci_status = ci_status
|
||||
|
||||
if not has_commits and not has_file_changes:
|
||||
@@ -732,14 +714,11 @@ class GitHubOrchestrator:
|
||||
# If CI was failing before but now passes, we need to update the verdict
|
||||
current_failing = ci_status.get("failing", 0)
|
||||
current_awaiting = ci_status.get("awaiting_approval", 0)
|
||||
current_pending = ci_status.get("pending", 0)
|
||||
|
||||
# Helper to detect CI-related blockers (includes workflows pending and CI pending)
|
||||
# Helper to detect CI-related blockers (includes workflows pending)
|
||||
def is_ci_blocker(b: str) -> bool:
|
||||
return (
|
||||
b.startswith("CI Failed:")
|
||||
or b.startswith("Workflows Pending:")
|
||||
or b.startswith("CI Pending:")
|
||||
return b.startswith("CI Failed:") or b.startswith(
|
||||
"Workflows Pending:"
|
||||
)
|
||||
|
||||
previous_blockers = getattr(previous_review, "blockers", [])
|
||||
@@ -749,13 +728,11 @@ class GitHubOrchestrator:
|
||||
)
|
||||
|
||||
# Determine the appropriate verdict based on current CI status
|
||||
# CI/Workflow/Pending status check (all block merging)
|
||||
ci_or_workflow_blocking = (
|
||||
current_failing > 0 or current_awaiting > 0 or current_pending > 0
|
||||
)
|
||||
# CI/Workflow status check (both block merging)
|
||||
ci_or_workflow_blocking = current_failing > 0 or current_awaiting > 0
|
||||
|
||||
if ci_or_workflow_blocking:
|
||||
# CI is still failing, pending, or workflows pending - keep blocked verdict
|
||||
# CI is still failing or workflows pending - keep blocked verdict
|
||||
updated_verdict = MergeVerdict.BLOCKED
|
||||
if current_failing > 0:
|
||||
updated_reasoning = (
|
||||
@@ -772,15 +749,6 @@ class GitHubOrchestrator:
|
||||
f"No new commits since last review. "
|
||||
f"CI status: {current_failing} check(s) failing.{ci_note}"
|
||||
)
|
||||
elif current_pending > 0:
|
||||
updated_reasoning = (
|
||||
f"No code changes since last review. "
|
||||
f"{current_pending} CI check(s) still pending."
|
||||
)
|
||||
no_change_summary = (
|
||||
f"No new commits since last review. "
|
||||
f"CI status: {current_pending} check(s) still running."
|
||||
)
|
||||
else:
|
||||
updated_reasoning = (
|
||||
f"No code changes since last review. "
|
||||
@@ -869,14 +837,6 @@ class GitHubOrchestrator:
|
||||
if blocker_msg not in blockers:
|
||||
blockers.append(blocker_msg)
|
||||
|
||||
# Add pending CI checks to blockers
|
||||
if current_pending > 0:
|
||||
blocker_msg = (
|
||||
f"CI Pending: {current_pending} check(s) still running"
|
||||
)
|
||||
if blocker_msg not in blockers:
|
||||
blockers.append(blocker_msg)
|
||||
|
||||
# Map verdict to overall_status (consistent with rest of codebase)
|
||||
if updated_verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
@@ -999,36 +959,6 @@ class GitHubOrchestrator:
|
||||
if ci_warning not in result.summary:
|
||||
result.summary += ci_warning
|
||||
|
||||
# Also check for pending CI checks
|
||||
pending_checks = followup_context.ci_status.get("pending", 0)
|
||||
if pending_checks > 0:
|
||||
safe_print(
|
||||
f"[Followup] CI checks still pending: {pending_checks}",
|
||||
flush=True,
|
||||
)
|
||||
# Override verdict if CI is pending
|
||||
if result.verdict in (
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
MergeVerdict.MERGE_WITH_CHANGES,
|
||||
):
|
||||
result.verdict = MergeVerdict.BLOCKED
|
||||
result.verdict_reasoning = (
|
||||
f"Blocked: {pending_checks} CI check(s) still running. "
|
||||
"Wait for CI to complete before merge."
|
||||
)
|
||||
result.overall_status = "request_changes"
|
||||
# Add pending CI to blockers
|
||||
blocker_msg = f"CI Pending: {pending_checks} check(s) still running"
|
||||
if blocker_msg not in result.blockers:
|
||||
result.blockers.append(blocker_msg)
|
||||
# Update summary to reflect CI status
|
||||
ci_pending_warning = (
|
||||
f"\n\n**⏳ CI Status:** {pending_checks} check(s) still running. "
|
||||
"Wait for CI to complete."
|
||||
)
|
||||
if ci_pending_warning not in result.summary:
|
||||
result.summary += ci_pending_warning
|
||||
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""
|
||||
Markdown Parsing Utilities for PR Reviews
|
||||
==========================================
|
||||
|
||||
Shared utilities for parsing markdown output from AI reviewers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
|
||||
try:
|
||||
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
from .category_utils import map_category
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
from services.category_utils import map_category
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_markdown_findings(
|
||||
output: str, context_name: str = "Review"
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Parse findings from markdown output when JSON extraction fails.
|
||||
|
||||
This handles cases where the AI outputs findings in markdown format
|
||||
after structured output validation fails. Extracts findings from patterns like:
|
||||
- **CRITICAL - Code Duplication**
|
||||
- ### Critical Issues (Must Fix)
|
||||
- 1. **HIGH - Race condition in auth.ts:45**
|
||||
|
||||
Args:
|
||||
output: Markdown text output to parse
|
||||
context_name: Name of the context (e.g., "ParallelOrchestrator", "ParallelFollowup")
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding instances extracted from markdown
|
||||
"""
|
||||
findings: list[PRReviewFinding] = []
|
||||
|
||||
# Normalize line endings
|
||||
output = output.replace("\r\n", "\n")
|
||||
|
||||
# Pattern: **SEVERITY - Title** or **SEVERITY: Title** or numbered lists
|
||||
numbered_pattern = r"(?:\d+\.\s*)?\*\*(\w+)\s*[-:–]\s*([^*]+)\*\*"
|
||||
|
||||
# Track positions to avoid duplicates
|
||||
found_titles: set[str] = set()
|
||||
|
||||
# Find all severity-title matches
|
||||
for match in re.finditer(numbered_pattern, output, re.IGNORECASE):
|
||||
severity_str = match.group(1).strip().upper()
|
||||
title = match.group(2).strip()
|
||||
|
||||
# Skip if already found this title (avoid duplicates)
|
||||
title_key = title.lower()[:50]
|
||||
if title_key in found_titles:
|
||||
continue
|
||||
found_titles.add(title_key)
|
||||
|
||||
# Map severity string to enum
|
||||
severity_map = {
|
||||
"CRITICAL": ReviewSeverity.CRITICAL,
|
||||
"HIGH": ReviewSeverity.HIGH,
|
||||
"MEDIUM": ReviewSeverity.MEDIUM,
|
||||
"MED": ReviewSeverity.MEDIUM,
|
||||
"LOW": ReviewSeverity.LOW,
|
||||
"SUGGESTION": ReviewSeverity.LOW,
|
||||
}
|
||||
severity = severity_map.get(severity_str, ReviewSeverity.MEDIUM)
|
||||
|
||||
# Try to extract file and line from title or nearby text
|
||||
file_line_match = re.search(
|
||||
r"[`(]?([a-zA-Z0-9_./\-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|c|cpp|h|hpp|swift|kt|scala|php|vue|svelte)):(\d+)[`)]?",
|
||||
title + output[match.end() : match.end() + 200],
|
||||
)
|
||||
|
||||
file_path = "unknown"
|
||||
line_num = 0
|
||||
if file_line_match:
|
||||
file_path = file_line_match.group(1)
|
||||
line_num = int(file_line_match.group(2))
|
||||
|
||||
# Extract description - text following the title until next heading or finding
|
||||
desc_start = match.end()
|
||||
desc_end_patterns = [
|
||||
r"\n\s*\*\*\w+\s*[-:–]", # Next finding
|
||||
r"\n\s*#{1,3}\s", # Next heading
|
||||
r"\n\s*\d+\.\s*\*\*", # Next numbered item
|
||||
r"\n\s*---", # Horizontal rule
|
||||
]
|
||||
desc_end = len(output)
|
||||
for pattern in desc_end_patterns:
|
||||
end_match = re.search(pattern, output[desc_start:])
|
||||
if end_match:
|
||||
desc_end = min(desc_end, desc_start + end_match.start())
|
||||
|
||||
description = output[desc_start:desc_end].strip()
|
||||
description = re.sub(r"^\s*[-*]\s*", "", description, flags=re.MULTILINE)
|
||||
description = description[:500] # Limit length
|
||||
|
||||
# Infer category from title/description
|
||||
category_keywords = {
|
||||
"security": [
|
||||
"security",
|
||||
"injection",
|
||||
"xss",
|
||||
"auth",
|
||||
"credential",
|
||||
"secret",
|
||||
],
|
||||
"redundancy": [
|
||||
"duplication",
|
||||
"redundant",
|
||||
"duplicate",
|
||||
"similar",
|
||||
"copy",
|
||||
],
|
||||
"performance": ["performance", "slow", "optimize", "memory", "leak"],
|
||||
"logic": ["logic", "race", "condition", "edge case", "bug", "error"],
|
||||
"quality": ["quality", "maintainability", "readability", "complexity"],
|
||||
"test": ["test", "coverage", "assertion"],
|
||||
"docs": ["doc", "comment", "readme"],
|
||||
"regression": ["regression", "broke", "revert"],
|
||||
"incomplete_fix": ["incomplete", "partial", "still"],
|
||||
}
|
||||
|
||||
category = ReviewCategory.QUALITY # Default
|
||||
title_desc_lower = (title + " " + description).lower()
|
||||
for cat, keywords in category_keywords.items():
|
||||
if any(kw in title_desc_lower for kw in keywords):
|
||||
category = map_category(cat)
|
||||
break
|
||||
|
||||
# Generate finding ID
|
||||
finding_id = hashlib.md5(
|
||||
f"{file_path}:{line_num}:{title[:50]}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
title=title[:80],
|
||||
description=description if description else title,
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix="",
|
||||
evidence=None,
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
if findings:
|
||||
logger.info(
|
||||
f"[{context_name}] Extracted {len(findings)} findings from markdown output"
|
||||
)
|
||||
|
||||
return findings
|
||||
@@ -46,7 +46,6 @@ try:
|
||||
from .agent_utils import create_working_dir_injector
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .markdown_utils import parse_markdown_findings
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
@@ -67,7 +66,6 @@ except (ImportError, ValueError, SystemError):
|
||||
from services.agent_utils import create_working_dir_injector
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.markdown_utils import parse_markdown_findings
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
@@ -567,27 +565,13 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
stream_error = stream_result.get("error")
|
||||
if stream_error:
|
||||
# Don't raise for structured_output_validation_failed - use text fallback instead
|
||||
if stream_error == "structured_output_validation_failed":
|
||||
logger.warning(
|
||||
"[ParallelFollowup] Structured output validation failed after retries. "
|
||||
"Will use text fallback to extract findings."
|
||||
)
|
||||
safe_print(
|
||||
"[ParallelFollowup] Structured output failed - using text fallback",
|
||||
flush=True,
|
||||
)
|
||||
# Clear structured_output to ensure fallback is used
|
||||
stream_result["structured_output"] = None
|
||||
else:
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_error}"
|
||||
)
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] 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"]
|
||||
@@ -1063,35 +1047,12 @@ The SDK will run invoked agents in parallel automatically.
|
||||
|
||||
return self._create_empty_result()
|
||||
|
||||
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from markdown output when JSON extraction fails.
|
||||
|
||||
Delegates to shared markdown parsing utility.
|
||||
|
||||
Args:
|
||||
output: Markdown text output to parse
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding instances extracted from markdown
|
||||
"""
|
||||
return parse_markdown_findings(output, context_name="ParallelFollowup")
|
||||
|
||||
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
|
||||
"""Parse text output when structured output fails.
|
||||
|
||||
Attempts markdown parsing to extract findings when the AI outputs
|
||||
findings in markdown format after structured output validation fails.
|
||||
"""
|
||||
"""Parse text output when structured output fails."""
|
||||
logger.warning("[ParallelFollowup] Falling back to text parsing")
|
||||
|
||||
# Try markdown parsing first to extract findings
|
||||
findings = self._parse_markdown_output(text)
|
||||
new_finding_ids = [f.id for f in findings]
|
||||
|
||||
if findings:
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Extracted {len(findings)} findings via markdown fallback"
|
||||
)
|
||||
# Simple heuristic parsing
|
||||
findings = []
|
||||
|
||||
# Look for verdict keywords
|
||||
text_lower = text.lower()
|
||||
@@ -1102,18 +1063,13 @@ The SDK will run invoked agents in parallel automatically.
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
# If we found findings, default to NEEDS_REVISION
|
||||
verdict = (
|
||||
MergeVerdict.NEEDS_REVISION
|
||||
if findings
|
||||
else MergeVerdict.MERGE_WITH_CHANGES
|
||||
)
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"new_finding_ids": [],
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": text[:500] if text else "Unable to parse response",
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ try:
|
||||
from .agent_utils import create_working_dir_injector
|
||||
from .category_utils import map_category
|
||||
from .io_utils import safe_print
|
||||
from .markdown_utils import parse_markdown_findings
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import (
|
||||
AgentAgreement,
|
||||
@@ -74,7 +73,6 @@ except (ImportError, ValueError, SystemError):
|
||||
from services.agent_utils import create_working_dir_injector
|
||||
from services.category_utils import map_category
|
||||
from services.io_utils import safe_print
|
||||
from services.markdown_utils import parse_markdown_findings
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import (
|
||||
AgentAgreement,
|
||||
@@ -526,27 +524,14 @@ Report findings with specific file paths, line numbers, and code evidence.
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# Don't bail out for structured_output_validation_failed - use text fallback
|
||||
if error == "structured_output_validation_failed":
|
||||
logger.warning(
|
||||
f"[Specialist:{config.name}] Structured output validation failed. "
|
||||
"Using text fallback."
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Structured output failed - using text fallback",
|
||||
flush=True,
|
||||
)
|
||||
# Clear structured_output to ensure fallback is used
|
||||
stream_result["structured_output"] = None
|
||||
else:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] SDK stream failed: {error}"
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Analysis failed: {error}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
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")
|
||||
@@ -1400,19 +1385,6 @@ The SDK will run invoked agents in parallel automatically.
|
||||
|
||||
return None
|
||||
|
||||
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from markdown output when JSON extraction fails.
|
||||
|
||||
Delegates to shared markdown parsing utility.
|
||||
|
||||
Args:
|
||||
output: Markdown text output to parse
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding instances extracted from markdown
|
||||
"""
|
||||
return parse_markdown_findings(output, context_name="ParallelOrchestrator")
|
||||
|
||||
def _create_finding_from_dict(self, f_data: dict[str, Any]) -> PRReviewFinding:
|
||||
"""Create a PRReviewFinding from dictionary data.
|
||||
|
||||
@@ -1447,39 +1419,23 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from text output (fallback).
|
||||
|
||||
Attempts JSON extraction first, then falls back to markdown parsing
|
||||
when structured output validation fails and AI outputs findings in
|
||||
markdown format.
|
||||
"""
|
||||
findings: list[PRReviewFinding] = []
|
||||
"""Parse findings from text output (fallback)."""
|
||||
findings = []
|
||||
|
||||
try:
|
||||
# First, try to extract JSON from text
|
||||
# Extract JSON from text
|
||||
data = self._extract_json_from_text(output)
|
||||
if data:
|
||||
# Get findings array from JSON
|
||||
findings_data = data.get("findings", [])
|
||||
|
||||
# Convert each finding dict to PRReviewFinding
|
||||
for f_data in findings_data:
|
||||
finding = self._create_finding_from_dict(f_data)
|
||||
findings.append(finding)
|
||||
|
||||
if findings:
|
||||
return findings
|
||||
|
||||
# Fallback: Try markdown parsing when JSON extraction fails
|
||||
# This handles the case where structured output validation failed
|
||||
# and Claude output findings in markdown format instead
|
||||
findings = self._parse_markdown_output(output)
|
||||
if findings:
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Extracted {len(findings)} findings via markdown fallback"
|
||||
)
|
||||
if not data:
|
||||
return findings
|
||||
|
||||
# Get findings array from JSON
|
||||
findings_data = data.get("findings", [])
|
||||
|
||||
# Convert each finding dict to PRReviewFinding
|
||||
for f_data in findings_data:
|
||||
finding = self._create_finding_from_dict(f_data)
|
||||
findings.append(finding)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ParallelOrchestrator] Text parsing failed: {e}")
|
||||
|
||||
@@ -1793,34 +1749,25 @@ For EACH finding above:
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# Handle structured output validation failure - use text fallback
|
||||
if error == "structured_output_validation_failed":
|
||||
# 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
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(
|
||||
"[PRReview:FindingValidator] Structured output validation failed. "
|
||||
"Will use text fallback to parse validation results."
|
||||
)
|
||||
# Clear structured_output to trigger text parsing below
|
||||
stream_result["structured_output"] = None
|
||||
else:
|
||||
# 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
|
||||
f"[PRReview] Retryable validation error: {error}"
|
||||
)
|
||||
last_error = Exception(error)
|
||||
continue # Retry
|
||||
|
||||
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
|
||||
logger.error(f"[PRReview] Validation failed: {error}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
|
||||
|
||||
@@ -26,87 +26,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# =============================================================================
|
||||
# Enum Normalization Helpers
|
||||
# =============================================================================
|
||||
# These help the AI produce valid output by mapping common synonyms/typos to
|
||||
# the exact values required by the schema. This reduces structured output
|
||||
# validation failures where the AI uses natural language like "duplication"
|
||||
# when the schema requires "redundancy".
|
||||
|
||||
|
||||
def normalize_category(value: str) -> str:
|
||||
"""Normalize category value to match schema requirements.
|
||||
|
||||
Maps common synonyms and typos to valid category values.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
# Map synonyms to valid category values
|
||||
synonyms = {
|
||||
# Redundancy synonyms
|
||||
"duplication": "redundancy",
|
||||
"code duplication": "redundancy",
|
||||
"duplicate": "redundancy",
|
||||
"duplicated": "redundancy",
|
||||
"copy": "redundancy",
|
||||
"copied": "redundancy",
|
||||
# Performance synonyms
|
||||
"perf": "performance",
|
||||
"slow": "performance",
|
||||
"optimization": "performance",
|
||||
# Quality synonyms
|
||||
"code quality": "quality",
|
||||
"maintainability": "quality",
|
||||
# Logic synonyms
|
||||
"correctness": "logic",
|
||||
"bug": "logic",
|
||||
# Test synonyms
|
||||
"testing": "test",
|
||||
"tests": "test",
|
||||
# Docs synonyms
|
||||
"documentation": "docs",
|
||||
"doc": "docs",
|
||||
# Security synonyms
|
||||
"sec": "security",
|
||||
"vulnerability": "security",
|
||||
# Style (map to quality or pattern)
|
||||
"style": "quality",
|
||||
"formatting": "quality",
|
||||
}
|
||||
|
||||
normalized = value.lower().strip()
|
||||
return synonyms.get(normalized, normalized)
|
||||
|
||||
|
||||
def normalize_verdict(value: str) -> str:
|
||||
"""Normalize verdict value to match schema requirements.
|
||||
|
||||
Handles common format variations like spaces, hyphens, and case sensitivity.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
# Normalize: uppercase, replace spaces/hyphens with underscores
|
||||
normalized = value.upper().strip().replace(" ", "_").replace("-", "_")
|
||||
|
||||
# Map common variations
|
||||
synonyms = {
|
||||
"READY": "READY_TO_MERGE",
|
||||
"MERGE": "READY_TO_MERGE",
|
||||
"APPROVED": "APPROVE",
|
||||
"NEEDS_CHANGES": "NEEDS_REVISION",
|
||||
"REQUEST_CHANGES": "NEEDS_REVISION",
|
||||
"CHANGES_REQUESTED": "NEEDS_REVISION",
|
||||
"BLOCK": "BLOCKED",
|
||||
"REJECT": "BLOCKED",
|
||||
}
|
||||
|
||||
return synonyms.get(normalized, normalized)
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# =============================================================================
|
||||
# Verification Evidence (Required for All Findings)
|
||||
@@ -192,22 +112,11 @@ class QualityFinding(BaseFinding):
|
||||
|
||||
category: Literal[
|
||||
"redundancy", "quality", "test", "performance", "pattern", "docs"
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: redundancy, quality, test, "
|
||||
"performance, pattern, docs. Use 'redundancy' for code duplication "
|
||||
"(NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
] = Field(description="Issue category")
|
||||
redundant_with: str | None = Field(
|
||||
None, description="Reference to duplicate code (file:line) if redundant"
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class DeepAnalysisFinding(BaseFinding):
|
||||
"""A finding from deep analysis with verification info."""
|
||||
@@ -219,20 +128,11 @@ class DeepAnalysisFinding(BaseFinding):
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
] = Field(description="Issue category")
|
||||
verification_note: str | None = Field(
|
||||
None, description="What evidence is missing or couldn't be verified"
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class StructuralIssue(BaseModel):
|
||||
"""A structural issue with the PR."""
|
||||
@@ -294,10 +194,7 @@ class FollowupFinding(BaseModel):
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"test, docs."
|
||||
)
|
||||
description="Issue category"
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
@@ -309,11 +206,6 @@ class FollowupFinding(BaseModel):
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class FollowupReviewResponse(BaseModel):
|
||||
"""Complete response schema for follow-up PR review."""
|
||||
@@ -330,19 +222,9 @@ class FollowupReviewResponse(BaseModel):
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
)
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
@field_validator("verdict", mode="before")
|
||||
@classmethod
|
||||
def _normalize_verdict(cls, v: str) -> str:
|
||||
return normalize_verdict(v)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Review Responses (Multi-Pass)
|
||||
@@ -407,19 +289,9 @@ class StructuralPassResult(BaseModel):
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(
|
||||
description=(
|
||||
"Structural verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
|
||||
)
|
||||
)
|
||||
] = Field(description="Structural verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
@field_validator("verdict", mode="before")
|
||||
@classmethod
|
||||
def _normalize_verdict(cls, v: str) -> str:
|
||||
return normalize_verdict(v)
|
||||
|
||||
|
||||
class AICommentTriageResult(BaseModel):
|
||||
"""Result from AI comment triage pass."""
|
||||
@@ -494,11 +366,7 @@ class OrchestratorFinding(BaseModel):
|
||||
"performance",
|
||||
"logic",
|
||||
"test",
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -511,34 +379,19 @@ class OrchestratorFinding(BaseModel):
|
||||
description="Evidence that this finding was verified against actual code"
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class OrchestratorReviewResponse(BaseModel):
|
||||
"""Complete response schema for orchestrator PR review."""
|
||||
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
|
||||
)
|
||||
)
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
findings: list[OrchestratorFinding] = Field(
|
||||
default_factory=list, description="Issues found during review"
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the review")
|
||||
|
||||
@field_validator("verdict", mode="before")
|
||||
@classmethod
|
||||
def _normalize_verdict(cls, v: str) -> str:
|
||||
return normalize_verdict(v)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Orchestrator Review Response (SDK Subagents)
|
||||
@@ -593,13 +446,7 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"codebase_fit, test, docs, redundancy, pattern, performance. "
|
||||
"Use 'redundancy' for code duplication (NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -635,11 +482,6 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
False, description="Whether multiple agents agreed on this finding"
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class AgentAgreement(BaseModel):
|
||||
"""Tracks agreement between agents on findings."""
|
||||
@@ -703,12 +545,7 @@ class SpecialistFinding(BaseModel):
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: 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")
|
||||
@@ -724,11 +561,6 @@ class SpecialistFinding(BaseModel):
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class SpecialistResponse(BaseModel):
|
||||
"""Response schema for individual specialist agent (parallel SDK sessions).
|
||||
@@ -780,18 +612,10 @@ class ParallelOrchestratorResponse(BaseModel):
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
|
||||
description=(
|
||||
"Overall PR verdict. MUST be exactly one of: APPROVE, COMMENT, "
|
||||
"NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
description="Overall PR verdict"
|
||||
)
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
@field_validator("verdict", mode="before")
|
||||
@classmethod
|
||||
def _normalize_verdict(cls, v: str) -> str:
|
||||
return normalize_verdict(v)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
|
||||
@@ -831,12 +655,7 @@ class ParallelFollowupFinding(BaseModel):
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"test, docs, regression, incomplete_fix."
|
||||
)
|
||||
)
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -864,11 +683,6 @@ class ParallelFollowupFinding(BaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("category", mode="before")
|
||||
@classmethod
|
||||
def _normalize_category(cls, v: str) -> str:
|
||||
return normalize_category(v)
|
||||
|
||||
|
||||
class CommentAnalysis(BaseModel):
|
||||
"""Analysis of a contributor or AI comment."""
|
||||
@@ -940,19 +754,9 @@ class ParallelFollowupResponse(BaseModel):
|
||||
# Verdict
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
)
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
@field_validator("verdict", mode="before")
|
||||
@classmethod
|
||||
def _normalize_verdict(cls, v: str) -> str:
|
||||
return normalize_verdict(v)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Finding Validation Response (Re-investigation of unresolved findings)
|
||||
|
||||
@@ -460,17 +460,12 @@ async def process_sdk_stream(
|
||||
)
|
||||
if subtype == "error_max_structured_output_retries":
|
||||
# SDK failed to produce valid structured output after retries
|
||||
# Log ALL available fields for debugging - helps diagnose enum mismatches
|
||||
result_detail = getattr(msg, "result", None)
|
||||
is_error = getattr(msg, "is_error", False)
|
||||
logger.warning(
|
||||
f"[{context_name}] Claude could not produce valid structured output "
|
||||
f"after maximum retries - schema validation failed. "
|
||||
f"is_error={is_error}, result_preview={str(result_detail)[:500] if result_detail else 'None'}"
|
||||
f"after maximum retries - schema validation failed"
|
||||
)
|
||||
safe_print(
|
||||
f"[{context_name}] WARNING: Structured output validation failed after retries. "
|
||||
f"Result preview: {str(result_detail)[:300] if result_detail else 'None'}"
|
||||
f"[{context_name}] WARNING: Structured output validation failed after retries"
|
||||
)
|
||||
if not stream_error:
|
||||
stream_error = "structured_output_validation_failed"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -33,6 +35,8 @@ export class AgentManager extends EventEmitter {
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
projectId?: string;
|
||||
/** Generation counter to prevent stale cleanup after restart */
|
||||
generation: number;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
@@ -57,21 +61,35 @@ export class AgentManager extends EventEmitter {
|
||||
// 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
|
||||
@@ -85,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
|
||||
*/
|
||||
@@ -99,7 +157,7 @@ export class AgentManager extends EventEmitter {
|
||||
): 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) {
|
||||
@@ -177,6 +235,9 @@ export class AgentManager extends EventEmitter {
|
||||
// Store context for potential restart
|
||||
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', projectId);
|
||||
}
|
||||
@@ -193,7 +254,7 @@ export class AgentManager extends EventEmitter {
|
||||
): 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) {
|
||||
@@ -256,6 +317,9 @@ export class AgentManager extends EventEmitter {
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -397,6 +461,8 @@ export class AgentManager extends EventEmitter {
|
||||
// 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,
|
||||
@@ -408,7 +474,8 @@ export class AgentManager extends EventEmitter {
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount, // Preserve existing count instead of resetting
|
||||
projectId
|
||||
projectId,
|
||||
generation, // Incremented to prevent stale exit cleanup
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,10 +531,14 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -281,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;
|
||||
}
|
||||
|
||||
@@ -306,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -19,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;
|
||||
|
||||
@@ -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,
|
||||
@@ -54,24 +53,6 @@ import {
|
||||
getEmailFromConfigDir
|
||||
} from './claude-profile/profile-utils';
|
||||
|
||||
/**
|
||||
* Debug flag - only log verbose profile operations when DEBUG=true
|
||||
*/
|
||||
const IS_DEBUG = process.env.DEBUG === 'true';
|
||||
|
||||
/**
|
||||
* Debug log helper - only logs when DEBUG=true
|
||||
*/
|
||||
function debugLog(message: string, data?: unknown): void {
|
||||
if (IS_DEBUG) {
|
||||
if (data !== undefined) {
|
||||
console.warn(message, data);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code profiles for multi-account support.
|
||||
* Profiles are stored in the app's userData directory.
|
||||
@@ -139,7 +120,7 @@ export class ClaudeProfileManager {
|
||||
const configEmail = getEmailFromConfigDir(profile.configDir);
|
||||
|
||||
if (configEmail && profile.email !== configEmail) {
|
||||
debugLog('[ClaudeProfileManager] Migrating corrupted email for profile:', {
|
||||
console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', {
|
||||
profileId: profile.id,
|
||||
oldEmail: profile.email,
|
||||
newEmail: configEmail
|
||||
@@ -151,7 +132,7 @@ export class ClaudeProfileManager {
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
debugLog('[ClaudeProfileManager] Email migration complete');
|
||||
console.warn('[ClaudeProfileManager] Email migration complete');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +167,7 @@ export class ClaudeProfileManager {
|
||||
|
||||
if (result.subscriptionTypeUpdated) {
|
||||
needsSave = true;
|
||||
debugLog('[ClaudeProfileManager] Populated subscriptionType for profile:', {
|
||||
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
|
||||
profileId: profile.id,
|
||||
subscriptionType: result.subscriptionType
|
||||
});
|
||||
@@ -194,7 +175,7 @@ export class ClaudeProfileManager {
|
||||
|
||||
if (result.rateLimitTierUpdated) {
|
||||
needsSave = true;
|
||||
debugLog('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
|
||||
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
|
||||
profileId: profile.id,
|
||||
rateLimitTier: result.rateLimitTier
|
||||
});
|
||||
@@ -203,7 +184,7 @@ export class ClaudeProfileManager {
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
debugLog('[ClaudeProfileManager] Subscription metadata population complete');
|
||||
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,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') {
|
||||
debugLog('[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
|
||||
*
|
||||
@@ -347,12 +303,19 @@ export class ClaudeProfileManager {
|
||||
// Fallback to default
|
||||
const defaultProfile = this.data.profiles.find(p => p.isDefault);
|
||||
if (defaultProfile) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] getActiveProfile - using default:', {
|
||||
id: defaultProfile.id,
|
||||
name: defaultProfile.name,
|
||||
email: defaultProfile.email
|
||||
});
|
||||
}
|
||||
return defaultProfile;
|
||||
}
|
||||
// If somehow no default exists, return first profile
|
||||
const fallback = this.data.profiles[0];
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] getActiveProfile - using fallback:', {
|
||||
console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', {
|
||||
id: fallback.id,
|
||||
name: fallback.name,
|
||||
email: fallback.email
|
||||
@@ -362,7 +325,7 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] getActiveProfile:', {
|
||||
console.warn('[ClaudeProfileManager] getActiveProfile:', {
|
||||
id: active.id,
|
||||
name: active.name,
|
||||
email: active.email
|
||||
@@ -453,12 +416,12 @@ export class ClaudeProfileManager {
|
||||
const previousProfileId = this.data.activeProfileId;
|
||||
const profile = this.getProfile(profileId);
|
||||
if (!profile) {
|
||||
debugLog('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
|
||||
console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] setActiveProfile:', {
|
||||
console.warn('[ClaudeProfileManager] setActiveProfile:', {
|
||||
from: previousProfileId,
|
||||
to: profileId,
|
||||
profileName: profile.name
|
||||
@@ -573,10 +536,10 @@ export class ClaudeProfileManager {
|
||||
|
||||
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', { profileName: profile.name, configDir: expandedConfigDir });
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
}
|
||||
} else {
|
||||
debugLog('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -796,7 +759,7 @@ export class ClaudeProfileManager {
|
||||
);
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] getProfileEnv:', {
|
||||
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
isDefault: profile.isDefault,
|
||||
@@ -817,7 +780,7 @@ export class ClaudeProfileManager {
|
||||
if (credentials.token) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
debugLog('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
|
||||
console.warn('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -873,7 +836,7 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
this.save();
|
||||
debugLog('[ClaudeProfileManager] Cleared migrated profile:', profileId);
|
||||
console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,25 +36,6 @@ function getTokenFingerprint(token: string | null | undefined): string {
|
||||
return token.slice(0, 8) + '...' + token.slice(-4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug flag - only log verbose credential operations when DEBUG=true
|
||||
*/
|
||||
const IS_DEBUG = process.env.DEBUG === 'true';
|
||||
|
||||
/**
|
||||
* Debug log helper - only logs when DEBUG=true
|
||||
* Uses console.warn to ensure visibility in Electron's main process
|
||||
*/
|
||||
function debugLog(message: string, ...args: unknown[]): void {
|
||||
if (IS_DEBUG) {
|
||||
if (args.length > 0) {
|
||||
console.warn(message, ...args);
|
||||
} else {
|
||||
console.warn(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe interpolation into PowerShell double-quoted strings.
|
||||
* Escapes all PowerShell special characters to prevent injection attacks.
|
||||
@@ -212,7 +193,7 @@ export function getKeychainServiceName(configDir?: string): string {
|
||||
// No configDir provided - this should not happen with isolated profiles
|
||||
// Fall back to unhashed name for backwards compatibility during migration
|
||||
if (!configDir) {
|
||||
debugLog('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
|
||||
console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
|
||||
return 'Claude Code-credentials';
|
||||
}
|
||||
|
||||
@@ -364,7 +345,7 @@ function executeCredentialRead(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
timeout: number,
|
||||
identifier: string
|
||||
_identifier: string
|
||||
): string | null {
|
||||
try {
|
||||
const result = execFileSync(executablePath, args, {
|
||||
@@ -415,13 +396,13 @@ function parseCredentialJson<T extends PlatformCredentials>(
|
||||
try {
|
||||
data = JSON.parse(credentialsJson);
|
||||
} catch {
|
||||
debugLog(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
|
||||
console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
|
||||
return extractFn({}) as T;
|
||||
}
|
||||
|
||||
// Validate JSON structure
|
||||
if (!validateCredentialData(data)) {
|
||||
debugLog(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
|
||||
console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
|
||||
return extractFn({}) as T;
|
||||
}
|
||||
|
||||
@@ -458,7 +439,7 @@ function getCredentialsFromFile(
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
debugLog(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
|
||||
console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
|
||||
credentialsPath,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -472,7 +453,7 @@ function getCredentialsFromFile(
|
||||
// Defense-in-depth: Validate credentials path is within expected boundaries
|
||||
if (!isValidCredentialsPath(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
}
|
||||
const invalidResult = { token: null, email: null, error: 'Invalid credentials path' };
|
||||
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
|
||||
@@ -482,7 +463,7 @@ function getCredentialsFromFile(
|
||||
// Check if credentials file exists
|
||||
if (!existsSync(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
}
|
||||
const notFoundResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
|
||||
@@ -497,7 +478,7 @@ function getCredentialsFromFile(
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
} catch {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
const errorResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
|
||||
return errorResult;
|
||||
@@ -505,7 +486,7 @@ function getCredentialsFromFile(
|
||||
|
||||
// Validate JSON structure
|
||||
if (!validateCredentialData(data)) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
const invalidResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
|
||||
return invalidResult;
|
||||
@@ -515,7 +496,7 @@ function getCredentialsFromFile(
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -525,7 +506,7 @@ function getCredentialsFromFile(
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -535,7 +516,7 @@ function getCredentialsFromFile(
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` };
|
||||
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
|
||||
return errorResult;
|
||||
@@ -559,7 +540,7 @@ function getFullCredentialsFromFile(
|
||||
// Defense-in-depth: Validate credentials path is within expected boundaries
|
||||
if (!isValidCredentialsPath(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
}
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' };
|
||||
}
|
||||
@@ -567,7 +548,7 @@ function getFullCredentialsFromFile(
|
||||
// Check if credentials file exists
|
||||
if (!existsSync(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
}
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
|
||||
}
|
||||
@@ -580,13 +561,13 @@ function getFullCredentialsFromFile(
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
} catch {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
|
||||
}
|
||||
|
||||
// Validate JSON structure
|
||||
if (!validateCredentialData(data)) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
|
||||
}
|
||||
|
||||
@@ -594,12 +575,12 @@ function getFullCredentialsFromFile(
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -612,7 +593,7 @@ function getFullCredentialsFromFile(
|
||||
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
@@ -635,6 +616,15 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
if (!forceRefresh && cached) {
|
||||
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
console.warn('[CredentialUtils:macOS:CACHE] Returning cached credentials:', {
|
||||
serviceName,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
cacheAge: Math.round(cacheAge / 1000) + 's'
|
||||
});
|
||||
}
|
||||
return cached.credentials;
|
||||
}
|
||||
}
|
||||
@@ -674,7 +664,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
|
||||
console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -684,7 +674,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
|
||||
console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -695,7 +685,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
} catch (error) {
|
||||
// Unexpected error (executeCredentialRead already handles "not found" cases)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
|
||||
console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
|
||||
const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` };
|
||||
// Use shorter TTL for errors
|
||||
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
|
||||
@@ -766,7 +756,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
debugLog('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
|
||||
attribute,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -781,7 +771,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
|
||||
console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
|
||||
}
|
||||
// Return a special result indicating Secret Service is unavailable
|
||||
return { token: null, email: null, error: 'secret-tool not found' };
|
||||
@@ -805,7 +795,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -815,7 +805,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
|
||||
attribute,
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
@@ -827,7 +817,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
} catch (error) {
|
||||
// Unexpected error (executeCredentialRead already handles "not found" cases)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
|
||||
// Return error to trigger fallback to file storage
|
||||
return { token: null, email: null, error: `Secret Service access failed: ${errorMessage}` };
|
||||
}
|
||||
@@ -850,7 +840,7 @@ function getCredentialsFromLinux(configDir?: string, forceRefresh = false): Plat
|
||||
// If Secret Service had an error (not just "not found"), log it and try file fallback
|
||||
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,7 +894,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
debugLog('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
|
||||
console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
|
||||
targetName,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -920,7 +910,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
|
||||
const invalidResult = { token: null, email: null, error: 'Invalid credential target name format' };
|
||||
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
|
||||
console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
|
||||
}
|
||||
return invalidResult;
|
||||
}
|
||||
@@ -1027,7 +1017,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:Windows] Invalid token format for target:', targetName);
|
||||
console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -1037,7 +1027,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
|
||||
console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -1047,7 +1037,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
const errorResult = { token: null, email: null, error: `Credential Manager access failed: ${errorMessage}` };
|
||||
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
|
||||
return errorResult;
|
||||
@@ -1112,13 +1102,13 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
|
||||
// If only one has a token, use that one
|
||||
if (fileResult.token && !credManagerResult.token) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
|
||||
console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
if (credManagerResult.token && !fileResult.token) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
|
||||
console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
|
||||
}
|
||||
return credManagerResult;
|
||||
}
|
||||
@@ -1130,7 +1120,7 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
|
||||
|
||||
// Both have tokens - prefer file since Claude CLI writes there after login
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
@@ -1252,12 +1242,12 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
|
||||
console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
|
||||
console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -1271,7 +1261,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
|
||||
} catch (error) {
|
||||
// Unexpected error (executeCredentialRead already handles "not found" cases)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
|
||||
console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Keychain access failed: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
@@ -1287,7 +1277,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
|
||||
}
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'secret-tool not found' };
|
||||
}
|
||||
@@ -1309,12 +1299,12 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
|
||||
);
|
||||
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
|
||||
attribute,
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
@@ -1329,7 +1319,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
|
||||
} catch (error) {
|
||||
// Unexpected error (executeCredentialRead already handles "not found" cases)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Secret Service access failed: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
@@ -1349,7 +1339,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials {
|
||||
|
||||
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1376,7 +1366,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful
|
||||
if (!isValidTargetName(targetName)) {
|
||||
const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credential target name format' };
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
|
||||
console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
|
||||
}
|
||||
return invalidResult;
|
||||
}
|
||||
@@ -1471,12 +1461,12 @@ public static extern bool CredFree(IntPtr cred);
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
|
||||
console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
|
||||
console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -1489,7 +1479,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Credential Manager access failed: ${errorMessage}` };
|
||||
}
|
||||
}
|
||||
@@ -1518,13 +1508,13 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
|
||||
// If only one has a token, use that one
|
||||
if (fileResult.token && !credManagerResult.token) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
|
||||
console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
if (credManagerResult.token && !fileResult.token) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
|
||||
console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
|
||||
}
|
||||
return credManagerResult;
|
||||
}
|
||||
@@ -1539,7 +1529,7 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
|
||||
// Using file as primary ensures consistency: the same token is returned whether
|
||||
// calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain().
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
@@ -1643,12 +1633,12 @@ function updateMacOSKeychainCredentials(
|
||||
}
|
||||
);
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
|
||||
console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
|
||||
}
|
||||
} catch {
|
||||
// Entry didn't exist - that's fine, we'll create it
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
|
||||
console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1666,7 +1656,7 @@ function updateMacOSKeychainCredentials(
|
||||
);
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
|
||||
console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1699,7 +1689,7 @@ function updateLinuxSecretServiceCredentials(
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
|
||||
}
|
||||
return { success: false, error: 'secret-tool not found' };
|
||||
}
|
||||
@@ -1740,7 +1730,7 @@ function updateLinuxSecretServiceCredentials(
|
||||
);
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1776,7 +1766,7 @@ function updateLinuxCredentials(
|
||||
return secretServiceResult;
|
||||
}
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
|
||||
console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,7 +1827,7 @@ function updateLinuxFileCredentials(
|
||||
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
|
||||
console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1976,7 +1966,7 @@ function updateWindowsCredentialManagerCredentials(
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
|
||||
console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -2019,13 +2009,13 @@ function restrictWindowsFilePermissions(filePath: string): void {
|
||||
});
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
|
||||
console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal: log warning but don't fail the operation
|
||||
// The file is still protected by the user's home directory permissions
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
debugLog('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
|
||||
console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2115,7 +2105,7 @@ function updateWindowsFileCredentials(
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
|
||||
console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -2172,7 +2162,7 @@ function updateWindowsCredentials(
|
||||
// Credential Manager failed but file succeeded - this is acceptable
|
||||
// Claude CLI will use the file, which has the latest tokens
|
||||
if (isDebug) {
|
||||
debugLog('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
|
||||
console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -319,6 +319,12 @@ export async function ensureValidToken(
|
||||
? configDir.replace(/^~/, homedir())
|
||||
: configDir;
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[TokenRefresh:ensureValidToken] Checking token validity', {
|
||||
configDir: expandedConfigDir || 'default'
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Read full credentials from keychain
|
||||
const creds = getFullCredentialsFromKeychain(expandedConfigDir);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -90,22 +91,60 @@ const PROVIDER_USAGE_ENDPOINTS: readonly ProviderUsageEndpoint[] = [
|
||||
export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string | null {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Constructing usage endpoint:', {
|
||||
provider,
|
||||
baseUrl
|
||||
});
|
||||
}
|
||||
|
||||
const endpointConfig = PROVIDER_USAGE_ENDPOINTS.find(e => e.provider === provider);
|
||||
if (!endpointConfig) {
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Unknown provider - no endpoint configured:', {
|
||||
provider,
|
||||
availableProviders: PROVIDER_USAGE_ENDPOINTS.map(e => e.provider)
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Found endpoint config for provider:', {
|
||||
provider,
|
||||
usagePath: endpointConfig.usagePath
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const originalPath = url.pathname;
|
||||
// Replace the path with the usage endpoint path
|
||||
url.pathname = endpointConfig.usagePath;
|
||||
|
||||
// Note: quota/limit endpoint doesn't require query parameters
|
||||
// The model-usage and tool-usage endpoints would need time windows, but we're using quota/limit
|
||||
|
||||
return url.toString();
|
||||
const finalUrl = url.toString();
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Successfully constructed endpoint:', {
|
||||
provider,
|
||||
originalPath,
|
||||
newPath: endpointConfig.usagePath,
|
||||
finalUrl
|
||||
});
|
||||
}
|
||||
|
||||
return finalUrl;
|
||||
} catch (error) {
|
||||
console.error('[UsageMonitor] Invalid baseUrl for usage endpoint:', baseUrl);
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] URL construction failed:', {
|
||||
baseUrl,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +164,18 @@ export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string
|
||||
*/
|
||||
export function detectProvider(baseUrl: string): ApiProvider {
|
||||
// Wrapper around shared detectProvider with debug logging for main process
|
||||
return sharedDetectProvider(baseUrl);
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
const provider = sharedDetectProvider(baseUrl);
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:PROVIDER_DETECTION] Detected provider:', {
|
||||
baseUrl,
|
||||
provider
|
||||
});
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -726,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;
|
||||
}
|
||||
@@ -795,12 +845,18 @@ export class UsageMonitor extends EventEmitter {
|
||||
// Fallback: Try direct keychain read (e.g., if refresh token unavailable)
|
||||
const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir);
|
||||
if (keychainCreds.token) {
|
||||
this.debugLog('[UsageMonitor:TRACE] Using fallback OAuth token from Keychain for profile: ' + activeProfile.name, {
|
||||
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
|
||||
});
|
||||
return keychainCreds.token;
|
||||
}
|
||||
|
||||
// Keychain read also failed
|
||||
if (keychainCreds.error) {
|
||||
this.debugLog('[UsageMonitor] Keychain access failed:', keychainCreds.error);
|
||||
} else {
|
||||
this.debugLog('[UsageMonitor:TRACE] No token in Keychain for profile: ' + activeProfile.name +
|
||||
' - user may need to re-authenticate with claude /login');
|
||||
}
|
||||
|
||||
// Mark profile as needing re-authentication since credentials are missing
|
||||
@@ -808,6 +864,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
}
|
||||
|
||||
// No credential available
|
||||
this.debugLog('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1277,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;
|
||||
@@ -1291,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);
|
||||
@@ -1951,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
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -157,8 +157,7 @@ function createWindow(): void {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
// Validate the returned object has expected structure with valid dimensions
|
||||
if (
|
||||
display &&
|
||||
display.workAreaSize &&
|
||||
display?.workAreaSize &&
|
||||
typeof display.workAreaSize.width === 'number' &&
|
||||
typeof display.workAreaSize.height === 'number' &&
|
||||
display.workAreaSize.width > 0 &&
|
||||
@@ -495,7 +494,7 @@ app.whenReady().then(() => {
|
||||
// Setup event forwarding from usage monitor to renderer
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
|
||||
// Start the usage monitor
|
||||
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||
|
||||
@@ -9,11 +9,10 @@ import { InsightsPaths } from './paths';
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, InsightsSession> = new Map();
|
||||
private storage: SessionStorage;
|
||||
private paths: InsightsPaths;
|
||||
|
||||
constructor(storage: SessionStorage, paths: InsightsPaths) {
|
||||
constructor(storage: SessionStorage, _paths: InsightsPaths) {
|
||||
this.storage = storage;
|
||||
this.paths = paths;
|
||||
// Note: paths parameter kept for API compatibility but not currently used
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,10 @@ export function registerAppUpdateHandlers(): void {
|
||||
IPC_CHANNELS.APP_UPDATE_INSTALL,
|
||||
async (): Promise<IPCResult> => {
|
||||
try {
|
||||
// quitAndInstall() returns false if blocked by read-only volume,
|
||||
// but the user is notified via APP_UPDATE_READONLY_VOLUME event instead.
|
||||
// The preload fires this as fire-and-forget, so the return value is
|
||||
// only consumed by the .catch() handler for unexpected errors.
|
||||
quitAndInstall();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export function registerChangelogHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
|
||||
changelogService.on('rate-limit', (_projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
|
||||
@@ -216,10 +216,12 @@ async function scanClaudeInstallations(activePath: string | null): Promise<Claud
|
||||
* @param currentInstalled - Optional currently installed version. If provided and newer than
|
||||
* cached latest, cache will be invalidated and fresh data fetched.
|
||||
* This handles the case where CLI was updated while app was running.
|
||||
* @param forceRefresh - If true, bypasses the cache and fetches fresh data from npm.
|
||||
* Use this when the user explicitly clicks a "Refresh" button.
|
||||
*/
|
||||
async function fetchLatestVersion(currentInstalled?: string | null): Promise<string> {
|
||||
// Check cache first
|
||||
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
async function fetchLatestVersion(currentInstalled?: string | null, forceRefresh?: boolean): Promise<string> {
|
||||
// Check cache first (unless force refresh is requested)
|
||||
if (!forceRefresh && cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
const cachedVersion = cachedLatestVersion.version;
|
||||
|
||||
// Invalidate cache if installed version is newer than cached latest
|
||||
@@ -881,7 +883,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Check for oauthAccount with emailAddress
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -907,7 +909,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
};
|
||||
}
|
||||
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -958,9 +960,9 @@ export function registerClaudeCodeHandlers(): void {
|
||||
// Check Claude Code version
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
||||
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
try {
|
||||
console.warn('[Claude Code] Checking version...');
|
||||
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
|
||||
|
||||
// Get installed version via cli-tool-manager
|
||||
let detectionResult;
|
||||
@@ -977,10 +979,11 @@ export function registerClaudeCodeHandlers(): void {
|
||||
|
||||
// Fetch latest version from npm
|
||||
// Pass installed version to invalidate cache if installed > cached (handles CLI update while app running)
|
||||
// Pass forceRefresh to bypass cache when user explicitly clicks Refresh
|
||||
let latest: string;
|
||||
try {
|
||||
console.warn('[Claude Code] Fetching latest version from npm...');
|
||||
latest = await fetchLatestVersion(installed);
|
||||
latest = await fetchLatestVersion(installed, forceRefresh);
|
||||
console.warn('[Claude Code] Latest version:', latest);
|
||||
} catch (error) {
|
||||
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
||||
|
||||
@@ -12,9 +12,6 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
|
||||
import {
|
||||
getGraphitiDatabaseDetails
|
||||
} from './utils';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
import {
|
||||
loadGraphitiStateFromSpecs,
|
||||
|
||||
@@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation =
|
||||
| { command: string; env: Record<string, string> }
|
||||
| { error: string };
|
||||
|
||||
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
try {
|
||||
const invocation = getClaudeCliInvocation();
|
||||
if (!invocation?.command) {
|
||||
@@ -118,14 +118,6 @@ export function registerEnvHandlers(
|
||||
if (config.githubAutoSync !== undefined) {
|
||||
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
|
||||
}
|
||||
// GitHub CI check exclusion (comma-separated list of check names to ignore)
|
||||
if (config.githubExcludedCIChecks !== undefined) {
|
||||
if (config.githubExcludedCIChecks.length > 0) {
|
||||
existingVars['GITHUB_EXCLUDED_CI_CHECKS'] = config.githubExcludedCIChecks.join(',');
|
||||
} else {
|
||||
delete existingVars['GITHUB_EXCLUDED_CI_CHECKS'];
|
||||
}
|
||||
}
|
||||
// GitLab Integration
|
||||
if (config.gitlabEnabled !== undefined) {
|
||||
existingVars[GITLAB_ENV_KEYS.ENABLED] = config.gitlabEnabled ? 'true' : 'false';
|
||||
@@ -259,9 +251,6 @@ ${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${e
|
||||
${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='}
|
||||
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
|
||||
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
|
||||
# CI check names to exclude from blocking during PR reviews (comma-separated)
|
||||
# Useful for stuck/broken CI checks that never complete
|
||||
${existingVars['GITHUB_EXCLUDED_CI_CHECKS'] ? `GITHUB_EXCLUDED_CI_CHECKS=${existingVars['GITHUB_EXCLUDED_CI_CHECKS']}` : '# GITHUB_EXCLUDED_CI_CHECKS='}
|
||||
|
||||
# =============================================================================
|
||||
# GITLAB INTEGRATION (OPTIONAL)
|
||||
@@ -441,13 +430,6 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') {
|
||||
config.githubAutoSync = true;
|
||||
}
|
||||
// Parse excluded CI checks (comma-separated list)
|
||||
if (vars['GITHUB_EXCLUDED_CI_CHECKS']) {
|
||||
config.githubExcludedCIChecks = vars['GITHUB_EXCLUDED_CI_CHECKS']
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// GitLab config
|
||||
if (vars[GITLAB_ENV_KEYS.TOKEN]) {
|
||||
|
||||
@@ -518,7 +518,7 @@ describe('GitHub OAuth Handlers', () => {
|
||||
mockFindExecutable.mockReturnValue('/usr/local/bin/gh');
|
||||
|
||||
// Mock execFileSync for version check
|
||||
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
|
||||
if (args && args[0] === '--version') {
|
||||
return 'gh version 2.65.0 (2024-01-15)\n';
|
||||
}
|
||||
@@ -553,7 +553,7 @@ describe('GitHub OAuth Handlers', () => {
|
||||
|
||||
describe('gh Auth Check Handler', () => {
|
||||
it('should return authenticated: true with username when logged in', async () => {
|
||||
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
|
||||
if (args && args[0] === 'auth' && args[1] === 'status') {
|
||||
return 'Logged in to github.com as testuser\n';
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function sendComplete(
|
||||
* Investigate a GitHub issue and create a task
|
||||
*/
|
||||
export function registerInvestigateIssue(
|
||||
agentManager: AgentManager,
|
||||
_agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
ipcMain.on(
|
||||
|
||||
@@ -117,7 +117,7 @@ const GITHUB_DEVICE_URL = 'https://github.com/login/device';
|
||||
*/
|
||||
function parseDeviceCode(output: string): string | null {
|
||||
const match = output.match(DEVICE_CODE_PATTERN);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||
const normalizedCode = match[1].replace(' ', '-');
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
|
||||
@@ -245,29 +245,6 @@ function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
|
||||
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: "true" } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds extra environment variables for PR review subprocess.
|
||||
* Includes Claude.md setting and GitHub-specific config like excluded CI checks.
|
||||
*/
|
||||
function getPRReviewExtraEnv(
|
||||
project: Project,
|
||||
config: { excludedCIChecks?: string[] } | null
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Add Claude.md setting
|
||||
if (project.settings?.useClaudeMd !== false) {
|
||||
env.USE_CLAUDE_MD = "true";
|
||||
}
|
||||
|
||||
// Add excluded CI checks (for stuck/broken CI like license/cla)
|
||||
if (config?.excludedCIChecks && config.excludedCIChecks.length > 0) {
|
||||
env.GITHUB_EXCLUDED_CI_CHECKS = config.excludedCIChecks.join(",");
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR review finding from AI analysis
|
||||
*/
|
||||
@@ -1327,8 +1304,11 @@ async function runPRReview(
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, false);
|
||||
|
||||
// Build environment with project settings (including excluded CI checks)
|
||||
const subprocessEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
|
||||
// Build environment with project settings
|
||||
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
|
||||
// Create operation ID for this review
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
@@ -1364,10 +1344,17 @@ async function runPRReview(
|
||||
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo },
|
||||
// PR reviews don't support restart (would need to refetch PR data)
|
||||
// The review will complete or fail, and user can retry manually
|
||||
},
|
||||
});
|
||||
|
||||
// Register the running process
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
// Register the running process (keep legacy registry for cancel support)
|
||||
runningReviews.set(reviewKey, childProcess);
|
||||
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
|
||||
|
||||
@@ -2732,8 +2719,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, true);
|
||||
|
||||
// Build environment with project settings (including excluded CI checks)
|
||||
const followupEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
|
||||
// Build environment with project settings
|
||||
const followupEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
@@ -2771,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
});
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-followup-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo, isFollowup: true },
|
||||
},
|
||||
});
|
||||
|
||||
// Update registry with actual process (replacing placeholder)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
export interface GitHubConfig {
|
||||
token: string;
|
||||
repo: string;
|
||||
excludedCIChecks?: string[];
|
||||
}
|
||||
|
||||
export interface GitHubAPIIssue {
|
||||
|
||||
@@ -81,14 +81,7 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
|
||||
}
|
||||
|
||||
if (!token || !repo) return null;
|
||||
|
||||
// Parse excluded CI checks (comma-separated list)
|
||||
const excludedCIChecksRaw = vars['GITHUB_EXCLUDED_CI_CHECKS'];
|
||||
const excludedCIChecks = excludedCIChecksRaw
|
||||
? excludedCIChecksRaw.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return { token, repo, excludedCIChecks };
|
||||
return { token, repo };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ
|
||||
import { parsePythonCommand } from '../../../python-detector';
|
||||
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
||||
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
||||
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
|
||||
import { isWindows, isMacOS } from '../../../platform';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -73,6 +74,23 @@ export interface SubprocessOptions {
|
||||
progressPattern?: RegExp;
|
||||
/** Additional environment variables to pass to the subprocess */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Operation registration for proactive swap support.
|
||||
* If provided, the operation will be registered with the unified OperationRegistry.
|
||||
*/
|
||||
operationRegistration?: {
|
||||
/** Unique operation ID */
|
||||
operationId: string;
|
||||
/** Operation type for categorization */
|
||||
operationType: OperationType;
|
||||
/** Optional metadata for the operation */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Function to restart the operation with a new profile.
|
||||
* Should call the original function with refreshed environment.
|
||||
*/
|
||||
restartFn?: (newProfileId: string) => boolean | Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +144,67 @@ export function runPythonSubprocess<T = unknown>(
|
||||
detached: !isWindows(),
|
||||
});
|
||||
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
if (options.operationRegistration) {
|
||||
const { operationId, operationType, metadata, restartFn } = options.operationRegistration;
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
if (activeProfile) {
|
||||
const operationRegistry = getOperationRegistry();
|
||||
|
||||
// Create a stop function that kills the subprocess.
|
||||
// Note: This sends SIGTERM and returns immediately without waiting for process exit.
|
||||
//
|
||||
// Timing dependency for restarts:
|
||||
// - For subprocess-runner operations, restartFn returns false so no race condition
|
||||
// (operations are non-resumable and won't be restarted, just stopped gracefully)
|
||||
// - For AgentManager operations, there's a 500ms setTimeout delay in restartTask
|
||||
// (see agent-manager.ts line 528) that mitigates the race between kill and restart
|
||||
//
|
||||
// RestartFn implementations should handle potential overlap between process termination
|
||||
// and restart initialization if not using the setTimeout pattern.
|
||||
const stopFn = async () => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
if (!isWindows()) {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} else {
|
||||
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register with OperationRegistry for tracking and proactive swap support.
|
||||
// For operations that provide a restartFn, UsageMonitor can restart them with a new profile.
|
||||
// For operations without restartFn (e.g., PR reviews which are non-resumable due to one-shot workflow),
|
||||
// we register with a no-op restartFn that returns false. This allows the swap to stop the operation
|
||||
// gracefully without attempting restart. The operation will be killed when the profile swaps,
|
||||
// which is the correct behavior for non-resumable operations.
|
||||
operationRegistry.registerOperation(
|
||||
operationId,
|
||||
operationType,
|
||||
activeProfile.id,
|
||||
activeProfile.name,
|
||||
restartFn || (() => false), // Use provided restartFn or a no-op for non-resumable operations
|
||||
{
|
||||
stopFn,
|
||||
metadata: { ...metadata, pythonPath: options.pythonPath, cwd: options.cwd }
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[SubprocessRunner] Operation registered with OperationRegistry:', {
|
||||
operationId,
|
||||
operationType,
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise<SubprocessResult<T>>((resolve) => {
|
||||
|
||||
let stdout = '';
|
||||
@@ -305,6 +384,11 @@ export function runPythonSubprocess<T = unknown>(
|
||||
// Treat null exit code (killed with SIGKILL) as failure, not success
|
||||
const exitCode = code ?? -1;
|
||||
|
||||
// Unregister from OperationRegistry when process exits
|
||||
if (options.operationRegistration) {
|
||||
getOperationRegistry().unregisterOperation(options.operationRegistration.operationId);
|
||||
}
|
||||
|
||||
// Debug logging only in development mode
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')');
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
GitLabAutoFixQueueItem,
|
||||
GitLabAutoFixProgress,
|
||||
GitLabIssueBatch,
|
||||
GitLabBatchProgress,
|
||||
GitLabAnalyzePreviewResult,
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function sendError(
|
||||
* Register investigation handler
|
||||
*/
|
||||
export function registerInvestigateIssue(
|
||||
agentManager: AgentManager,
|
||||
_agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
ipcMain.on(
|
||||
@@ -129,16 +129,11 @@ export function registerInvestigateIssue(
|
||||
message: 'Analyzing issue with AI...'
|
||||
});
|
||||
|
||||
// Build context for investigation
|
||||
let context = buildIssueContext(issue, config.project, config.instanceUrl);
|
||||
|
||||
if (selectedNotes.length > 0) {
|
||||
context += '\n\n## Selected Comments\n';
|
||||
for (const note of selectedNotes) {
|
||||
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
|
||||
context += note.body + '\n';
|
||||
}
|
||||
}
|
||||
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||
// context building into a reusable utility function.
|
||||
|
||||
// Use agent manager to investigate
|
||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import { readSettingsFile } from '../../settings-utils';
|
||||
import type { Project, AppSettings } from '../../../shared/types';
|
||||
import type {
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
MRReviewProgress,
|
||||
NewCommitsCheck,
|
||||
|
||||
@@ -248,13 +248,13 @@ export function registerStartGlabAuth(): void {
|
||||
env: getAugmentedEnv()
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let _output = '';
|
||||
let errorOutput = '';
|
||||
let browserOpened = false;
|
||||
|
||||
glabProcess.stdout?.on('data', (data) => {
|
||||
const chunk = data.toString('utf-8');
|
||||
output += chunk;
|
||||
_output += chunk;
|
||||
debugLog('glab stdout:', chunk);
|
||||
|
||||
// Try to open browser if URL detected
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
|
||||
/**
|
||||
* Simplified task info returned when creating a spec from a GitLab issue.
|
||||
|
||||
@@ -201,6 +201,10 @@ function getOllamaInstallCommand(): string {
|
||||
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
|
||||
* Used to check Ollama status, list models, and manage downloads.
|
||||
*
|
||||
* Includes deduplication: identical command+baseUrl requests within 2s return the cached
|
||||
* result/promise instead of spawning a new subprocess. This prevents runaway subprocess
|
||||
* spawning from React re-render loops.
|
||||
*
|
||||
* Supported commands:
|
||||
* - 'check-status': Verify Ollama service is running
|
||||
* - 'list-models': Get all available models
|
||||
@@ -212,9 +216,43 @@ function getOllamaInstallCommand(): string {
|
||||
* @param {string} [baseUrl] - Optional Ollama API base URL (defaults to http://localhost:11434)
|
||||
* @returns {Promise<{success, data?, error?}>} Result object with success flag and data/error
|
||||
*/
|
||||
// Deduplication cache to prevent rapid-fire subprocess spawning (e.g., from React re-render loops)
|
||||
const ollamaDetectorCache = new Map<string, { promise: Promise<{ success: boolean; data?: unknown; error?: string }>; timestamp: number }>();
|
||||
const OLLAMA_CACHE_TTL_MS = 2000; // Cache results for 2 seconds
|
||||
|
||||
async function executeOllamaDetector(
|
||||
command: string,
|
||||
baseUrl?: string
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
||||
// Deduplication: return cached promise for identical requests within TTL
|
||||
const cacheKey = `${command}:${baseUrl || 'default'}`;
|
||||
const cached = ollamaDetectorCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < OLLAMA_CACHE_TTL_MS) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log('[OllamaDetector] Returning cached result for:', command);
|
||||
}
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const promise = executeOllamaDetectorImpl(command, baseUrl);
|
||||
ollamaDetectorCache.set(cacheKey, { promise, timestamp: Date.now() });
|
||||
|
||||
// Clean up cache entry after TTL
|
||||
promise.finally(() => {
|
||||
setTimeout(() => {
|
||||
const entry = ollamaDetectorCache.get(cacheKey);
|
||||
if (entry && entry.promise === promise) {
|
||||
ollamaDetectorCache.delete(cacheKey);
|
||||
}
|
||||
}, OLLAMA_CACHE_TTL_MS);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function executeOllamaDetectorImpl(
|
||||
command: string,
|
||||
baseUrl?: string
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
||||
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
|
||||
@@ -483,7 +521,7 @@ export function registerMemoryHandlers(): void {
|
||||
};
|
||||
} else {
|
||||
// Basic validation for other providers
|
||||
llmResult = config.apiKey && config.apiKey.trim()
|
||||
llmResult = config.apiKey?.trim()
|
||||
? {
|
||||
success: true,
|
||||
message: `${config.llmProvider} API key format appears valid`,
|
||||
@@ -703,7 +741,7 @@ export function registerMemoryHandlers(): void {
|
||||
async (
|
||||
event,
|
||||
modelName: string,
|
||||
baseUrl?: string
|
||||
_baseUrl?: string
|
||||
): Promise<IPCResult<OllamaPullResult>> => {
|
||||
try {
|
||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { IPCResult } from '../../shared/types';
|
||||
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
|
||||
import {
|
||||
loadProfilesFile,
|
||||
saveProfilesFile,
|
||||
validateFilePermissions,
|
||||
getProfilesFilePath,
|
||||
atomicModifyProfiles,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { existsSync, } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
@@ -233,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null {
|
||||
return branches[0] || null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Configure all Python-dependent services with the managed Python path
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import type {
|
||||
IPCResult,
|
||||
Roadmap,
|
||||
RoadmapFeature,
|
||||
RoadmapFeatureStatus,
|
||||
RoadmapGenerationStatus,
|
||||
PersistedRoadmapProgress,
|
||||
|
||||
@@ -511,7 +511,7 @@ export function registerSettingsHandlers(
|
||||
error: `Path is not a directory: ${resolvedPath}`
|
||||
};
|
||||
}
|
||||
} catch (statError) {
|
||||
} catch (_statError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot access path: ${resolvedPath}`
|
||||
|
||||
@@ -800,6 +800,73 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resume a paused task (rate limited or auth failure paused)
|
||||
* This writes a RESUME file to the spec directory to signal the backend to continue
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_RESUME_PAUSED,
|
||||
async (_, taskId: string): Promise<IPCResult> => {
|
||||
// Find task and project
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
|
||||
if (!task || !project) {
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
// Get the spec directory - use task.specsPath if available (handles worktree vs main)
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specDir = task.specsPath || path.join(
|
||||
project.path,
|
||||
specsBaseDir,
|
||||
task.specId
|
||||
);
|
||||
|
||||
// Write RESUME file to signal backend to continue
|
||||
const resumeFilePath = path.join(specDir, 'RESUME');
|
||||
|
||||
try {
|
||||
const resumeContent = JSON.stringify({
|
||||
resumed_at: new Date().toISOString(),
|
||||
resumed_by: 'user'
|
||||
});
|
||||
atomicWriteFileSync(resumeFilePath, resumeContent);
|
||||
console.log(`[TASK_RESUME_PAUSED] Wrote RESUME file to: ${resumeFilePath}`);
|
||||
|
||||
// Also write to worktree if it exists (backend may be running inside the worktree)
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
if (worktreePath) {
|
||||
const worktreeResumeFilePath = path.join(worktreePath, specsBaseDir, task.specId, 'RESUME');
|
||||
try {
|
||||
atomicWriteFileSync(worktreeResumeFilePath, resumeContent);
|
||||
console.log(`[TASK_RESUME_PAUSED] Also wrote RESUME file to worktree: ${worktreeResumeFilePath}`);
|
||||
} catch (worktreeError) {
|
||||
// Non-fatal - main spec dir RESUME is sufficient
|
||||
console.warn(`[TASK_RESUME_PAUSED] Could not write to worktree (non-fatal):`, worktreeError);
|
||||
}
|
||||
} else if (
|
||||
task.executionProgress?.phase === 'rate_limit_paused' ||
|
||||
task.executionProgress?.phase === 'auth_failure_paused'
|
||||
) {
|
||||
// Warn if worktree not found for a paused task - the backend is likely
|
||||
// running inside the worktree and may not see the RESUME file in the main spec dir
|
||||
console.warn(
|
||||
`[TASK_RESUME_PAUSED] Worktree not found for paused task ${task.specId}. ` +
|
||||
`Backend may not detect the RESUME file if running inside a worktree.`
|
||||
);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[TASK_RESUME_PAUSED] Failed to write RESUME file:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to signal resume'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Recover a stuck task (status says in_progress but no process running)
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre
|
||||
import path from 'path';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
findTaskWorktree,
|
||||
} from '../../worktree-paths';
|
||||
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
|
||||
import { getIsolatedGitEnv, detectWorktreeBranch, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { getIsolatedGitEnv, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { cleanupWorktree } from '../../utils/worktree-cleanup';
|
||||
import { killProcessGracefully } from '../../platform';
|
||||
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
|
||||
@@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise<Set<string>> {
|
||||
function isAppInstalled(
|
||||
appNames: string[],
|
||||
specificPaths: string[],
|
||||
platform: string
|
||||
_platform: string
|
||||
): { installed: boolean; foundPath: string } {
|
||||
// First, check the cached app list (fast)
|
||||
for (const name of appNames) {
|
||||
@@ -2568,7 +2568,7 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
if (gitStatus?.trim()) {
|
||||
// Parse the status output to get file names
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { readSettingsFileAsync } from '../settings-utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { debugLog, } from '../../shared/utils/debug-logger';
|
||||
import { migrateSession } from '../claude-profile/session-utils';
|
||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
@@ -29,8 +29,6 @@ export class LogService {
|
||||
|
||||
// Flush logs to disk every 2 seconds to balance performance vs data safety
|
||||
private readonly FLUSH_INTERVAL_MS = 2000;
|
||||
// Maximum log file size before rotation (10MB)
|
||||
private readonly MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
// Keep last N sessions
|
||||
private readonly MAX_SESSIONS_TO_KEEP = 10;
|
||||
|
||||
@@ -205,7 +203,7 @@ export class LogService {
|
||||
const sessionId = file.replace('session-', '').replace('.log', '');
|
||||
|
||||
// Parse session ID back to date
|
||||
const dateStr = sessionId.replace(/-/g, (match, offset) => {
|
||||
const dateStr = sessionId.replace(/-/g, (_match, offset) => {
|
||||
// Replace first 2 dashes with actual dashes, rest with colons
|
||||
if (offset < 10) return '-';
|
||||
if (offset === 10) return 'T';
|
||||
|
||||
@@ -243,7 +243,7 @@ function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig {
|
||||
/**
|
||||
* Get Unix shell configuration
|
||||
*/
|
||||
function getUnixShellConfig(preferredShell?: ShellType): ShellConfig {
|
||||
function getUnixShellConfig(_preferredShell?: ShellType): ShellConfig {
|
||||
const shellPath = process.env.SHELL || '/bin/zsh';
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,10 +3,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences, ExecutionPhase } from '../shared/types';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX, TASK_STATUS_PRIORITY } from '../shared/constants';
|
||||
import { XSTATE_SETTLED_STATES } from '../shared/state-machines';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
import { findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -324,12 +325,37 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
|
||||
// 3. Deduplicate tasks by ID
|
||||
// CRITICAL FIX: Don't blindly prefer worktree - it may be stale!
|
||||
// If main project task is "done", it should win over worktree's "in_progress".
|
||||
// Worktrees can linger after completion, containing outdated task data.
|
||||
const taskMap = new Map<string, Task>();
|
||||
for (const task of allTasks) {
|
||||
const existing = taskMap.get(task.id);
|
||||
if (!existing || task.location === 'worktree') {
|
||||
if (!existing) {
|
||||
// First occurrence wins
|
||||
taskMap.set(task.id, task);
|
||||
} else {
|
||||
// PREFER MAIN PROJECT over worktree - main has current user changes
|
||||
// Only use status priority when both are from same location
|
||||
const existingIsMain = existing.location === 'main';
|
||||
const newIsMain = task.location === 'main';
|
||||
|
||||
if (existingIsMain && !newIsMain) {
|
||||
} else if (!existingIsMain && newIsMain) {
|
||||
// New is main, replace existing worktree
|
||||
taskMap.set(task.id, task);
|
||||
} else {
|
||||
// Same location - use status priority to determine which is more complete
|
||||
const existingPriority = TASK_STATUS_PRIORITY[existing.status] || 0;
|
||||
const newPriority = TASK_STATUS_PRIORITY[task.status] || 0;
|
||||
|
||||
if (newPriority > existingPriority) {
|
||||
// New version has higher priority (more complete status)
|
||||
taskMap.set(task.id, task);
|
||||
}
|
||||
// Otherwise keep existing version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,10 +388,10 @@ export class ProjectStore {
|
||||
*/
|
||||
private loadTasksFromSpecsDir(
|
||||
specsDir: string,
|
||||
basePath: string,
|
||||
_basePath: string,
|
||||
location: 'main' | 'worktree',
|
||||
projectId: string,
|
||||
specsBaseDir: string
|
||||
_specsBaseDir: string
|
||||
): Task[] {
|
||||
const tasks: Task[] = [];
|
||||
let specDirs: Dirent[] = [];
|
||||
@@ -492,7 +518,7 @@ export class ProjectStore {
|
||||
// "# Specification: Title" -> "Title"
|
||||
// "# Title" -> "Title"
|
||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
if (titleMatch?.[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
@@ -500,15 +526,33 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
|
||||
// Priority: executionPhase > xstateState > inferred from status
|
||||
// Use xstateState as the authoritative source for execution phase when available.
|
||||
// Only fall back to persistedPhase for active running states where xstateState
|
||||
// might not reflect the detailed phase yet (e.g., planning vs coding within in_progress).
|
||||
// Priority: xstateState (when settled) > persistedPhase (when running) > inferred from status
|
||||
const persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
|
||||
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
|
||||
const executionProgress = persistedPhase
|
||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
|
||||
: xstateState
|
||||
? this.inferExecutionProgressFromXState(xstateState)
|
||||
: this.inferExecutionProgress(plan?.status);
|
||||
|
||||
// XState settled states take priority - these override any stale persisted phase
|
||||
// because the task is no longer actively running
|
||||
const isSettledState = xstateState && XSTATE_SETTLED_STATES.has(xstateState);
|
||||
|
||||
// Status-based override: if the task has reached a terminal status (human_review, done, pr_created)
|
||||
// but the persisted executionPhase is stale (e.g. still "planning"), correct it.
|
||||
// This happens when the XState machine didn't persist the final phase before being reset to backlog.
|
||||
const STATUS_IMPLIES_COMPLETE: ReadonlySet<string> = new Set(['human_review', 'done', 'pr_created']);
|
||||
const phaseIsStale = persistedPhase && persistedPhase !== 'complete' && persistedPhase !== 'failed'
|
||||
&& STATUS_IMPLIES_COMPLETE.has(finalStatus);
|
||||
|
||||
const executionProgress = isSettledState
|
||||
? this.inferExecutionProgressFromXState(xstateState) // Use XState for settled states
|
||||
: phaseIsStale
|
||||
? { phase: 'complete' as ExecutionPhase, phaseProgress: 100, overallProgress: 100 } // Fix stale phase
|
||||
: persistedPhase
|
||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 } // Use persisted for running
|
||||
: xstateState
|
||||
? this.inferExecutionProgressFromXState(xstateState)
|
||||
: this.inferExecutionProgress(plan?.status);
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
|
||||
@@ -92,6 +92,25 @@ const BILLING_FAILURE_PATTERNS = [
|
||||
/top\s*up\s*(your\s*)?(account|credits|balance)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum length for error messages sent to renderer.
|
||||
* Truncates to prevent exposing excessive internal details.
|
||||
*/
|
||||
const MAX_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Sanitize error output before sending to renderer.
|
||||
* Truncates long output to prevent exposing excessive internal details
|
||||
* like full paths, API responses, or stack traces.
|
||||
*/
|
||||
function sanitizeErrorOutput(output: string): string {
|
||||
// Truncate long output to limit exposure of internal details
|
||||
if (output.length > MAX_ERROR_LENGTH) {
|
||||
return output.substring(0, MAX_ERROR_LENGTH) + '... (truncated)';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -109,7 +128,7 @@ export interface RateLimitDetectionResult {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
@@ -193,7 +212,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +230,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -265,7 +284,6 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' |
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -303,7 +321,6 @@ function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment
|
||||
return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'subscription_inactive':
|
||||
return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -333,7 +350,7 @@ export function detectAuthFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -375,7 +392,7 @@ export function detectBillingFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getBillingFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -631,7 +648,7 @@ export interface SDKRateLimitInfo {
|
||||
};
|
||||
/** When detected */
|
||||
detectedAt: Date;
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
|
||||
// Auto-swap information
|
||||
|
||||
@@ -24,9 +24,6 @@ import { refreshGitIndex } from './utils/git-isolation';
|
||||
* If a worktree exists for a task NOT in this release, it won't block the release.
|
||||
*/
|
||||
export class ReleaseService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CHANGELOG.md to extract releaseable versions.
|
||||
@@ -75,7 +72,7 @@ export class ReleaseService extends EventEmitter {
|
||||
* This allows us to scope worktree checks to only those tasks.
|
||||
*/
|
||||
getTasksForVersion(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
version: string,
|
||||
tasks: Task[]
|
||||
): { taskIds: string[]; specIds: string[] } {
|
||||
|
||||
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function getProfilesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getAPIProfileEnv,
|
||||
testConnection
|
||||
} from './profile-service';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
|
||||
import type { ProfilesFile, } from '../../shared/types/profile';
|
||||
|
||||
// Mock profile-manager
|
||||
vi.mock('../utils/profile-manager', () => ({
|
||||
|
||||
@@ -90,8 +90,6 @@ export async function deleteProfile(id: string): Promise<void> {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
const profile = file.profiles[profileIndex];
|
||||
|
||||
// Active Profile Check: Cannot delete active profile (AC3)
|
||||
if (file.activeProfileId === id) {
|
||||
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
testConnection,
|
||||
discoverModels
|
||||
} from './profile-service';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
|
||||
import type { ProfilesFile, } from '@shared/types/profile';
|
||||
|
||||
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
|
||||
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
|
||||
|
||||
@@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SDKSessionRecoveryCoordinator,
|
||||
getRecoveryCoordinator,
|
||||
type SDKOperationType,
|
||||
type RecoveryCoordinatorConfig
|
||||
} from './sdk-session-recovery-coordinator';
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* SDK Session Recovery Coordinator
|
||||
*
|
||||
* @deprecated This module is deprecated in favor of ClaudeOperationRegistry
|
||||
* (src/main/claude-profile/operation-registry.ts). The OperationRegistry provides
|
||||
* similar functionality with a simpler API and is actively integrated with
|
||||
* AgentManager and UsageMonitor. This module is retained for backward compatibility
|
||||
* but should not be used for new code.
|
||||
*
|
||||
* TODO: Target removal in v0.5.0 (Q2 2026). Before removal:
|
||||
* 1. Identify any remaining usages in the codebase
|
||||
* 2. Migrate all remaining consumers to ClaudeOperationRegistry
|
||||
* 3. Remove this file and associated tests
|
||||
* 4. Update imports across the codebase
|
||||
*
|
||||
* Migration guide:
|
||||
* - Use getOperationRegistry() from '../claude-profile/operation-registry'
|
||||
* - registerOperation() -> operationRegistry.registerOperation()
|
||||
* - unregisterOperation() -> operationRegistry.unregisterOperation()
|
||||
* - getOperationsByProfile() -> operationRegistry.getOperationsByProfile()
|
||||
*
|
||||
* Original description:
|
||||
* Central coordinator for all SDK operations and rate limit recovery.
|
||||
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
|
||||
*
|
||||
@@ -97,6 +116,9 @@ interface PendingNotification {
|
||||
/**
|
||||
* SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery
|
||||
*
|
||||
* @deprecated Use ClaudeOperationRegistry from '../claude-profile/operation-registry' instead.
|
||||
* This class is retained for backward compatibility but is no longer actively maintained.
|
||||
*
|
||||
* This singleton coordinates all SDK operations across the application:
|
||||
* - Tasks (via AgentManager)
|
||||
* - Background operations (roadmap, ideation, changelog)
|
||||
@@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get the global coordinator instance
|
||||
* @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead.
|
||||
*/
|
||||
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
||||
return SDKSessionRecoveryCoordinator.getInstance();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, watchFile } from 'fs';
|
||||
import { existsSync, readFileSync, } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
||||
import { findTaskWorktree } from './worktree-paths';
|
||||
@@ -26,7 +26,6 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath:
|
||||
* watches both locations and merges logs from both sources.
|
||||
*/
|
||||
export class TaskLogService extends EventEmitter {
|
||||
private watchers: Map<string, { watcher: ReturnType<typeof watchFile>; specDir: string }> = new Map();
|
||||
private logCache: Map<string, TaskLogs> = new Map();
|
||||
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
// Store paths being watched for each specId (main + worktree)
|
||||
@@ -35,10 +34,6 @@ export class TaskLogService extends EventEmitter {
|
||||
// Poll interval for watching log changes (more reliable than fs.watch on some systems)
|
||||
private readonly POLL_INTERVAL_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
@@ -125,7 +120,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
let worktreeSpecDir: string | null = null;
|
||||
|
||||
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
|
||||
if (watchedInfo?.[1].worktreeSpecDir) {
|
||||
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
||||
} else if (projectPath && specsRelPath && specId) {
|
||||
// Calculate worktree path from provided params
|
||||
|
||||
@@ -123,7 +123,14 @@ export class TaskStateManager {
|
||||
} else if (!currentState && task.reviewReason === 'plan_review') {
|
||||
// Fallback: No actor exists (e.g., after app restart), use task data
|
||||
this.handleUiEvent(taskId, { type: 'PLAN_APPROVED' }, task, project);
|
||||
} else if (currentState === 'backlog' || !currentState) {
|
||||
// Fresh start from backlog or no actor - send PLANNING_STARTED
|
||||
// USER_RESUMED only works from human_review/error states
|
||||
this.handleUiEvent(taskId, { type: 'PLANNING_STARTED' }, task, project);
|
||||
} else {
|
||||
// Already in a running state (planning, coding, qa_*) - send USER_RESUMED
|
||||
// Note: USER_RESUMED may be ignored if state doesn't handle it, but that's OK
|
||||
// since the task is already running
|
||||
this.handleUiEvent(taskId, { type: 'USER_RESUMED' }, task, project);
|
||||
}
|
||||
return true;
|
||||
@@ -413,7 +420,6 @@ export class TaskStateManager {
|
||||
stateValue = 'error';
|
||||
contextReviewReason = reviewReason ?? 'errors';
|
||||
break;
|
||||
case 'backlog':
|
||||
default:
|
||||
stateValue = 'backlog';
|
||||
break;
|
||||
|
||||
@@ -377,12 +377,23 @@ export class TerminalSessionStore {
|
||||
todaySessions[projectPath] = [];
|
||||
}
|
||||
|
||||
// Debug: Log incoming outputBuffer info
|
||||
const incomingBufferLen = session.outputBuffer?.length ?? 0;
|
||||
debugLog('[TerminalSessionStore] Updating session in memory:', session.id,
|
||||
'incoming outputBuffer:', incomingBufferLen, 'bytes',
|
||||
'isClaudeMode:', session.isClaudeMode);
|
||||
|
||||
// Update existing or add new
|
||||
const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Preserve displayOrder from existing session if not provided in incoming session
|
||||
// This prevents periodic saves (which don't include displayOrder) from losing tab order
|
||||
const existingSession = todaySessions[projectPath][existingIndex];
|
||||
const existingBufferLen = existingSession.outputBuffer?.length ?? 0;
|
||||
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
|
||||
debugLog('[TerminalSessionStore] Updating existing session:', session.id,
|
||||
'existing outputBuffer:', existingBufferLen, 'bytes',
|
||||
'new outputBuffer (after truncation):', truncatedLen, 'bytes');
|
||||
|
||||
todaySessions[projectPath][existingIndex] = {
|
||||
...session,
|
||||
@@ -393,6 +404,10 @@ export class TerminalSessionStore {
|
||||
displayOrder: session.displayOrder ?? existingSession.displayOrder,
|
||||
};
|
||||
} else {
|
||||
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
|
||||
debugLog('[TerminalSessionStore] Creating new session:', session.id,
|
||||
'outputBuffer (after truncation):', truncatedLen, 'bytes');
|
||||
|
||||
todaySessions[projectPath].push({
|
||||
...session,
|
||||
outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER),
|
||||
|
||||
@@ -1555,7 +1555,7 @@ async function waitForClaudeExit(
|
||||
export async function switchClaudeProfile(
|
||||
terminal: TerminalProcess,
|
||||
profileId: string,
|
||||
getWindow: WindowGetter,
|
||||
_getWindow: WindowGetter,
|
||||
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise<void>,
|
||||
clearRateLimitCallback: (terminalId: string) => void
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
|
||||
@@ -60,7 +60,7 @@ const LOGIN_SUCCESS_PATTERN = /(?:Login successful|Successfully logged in|Logged
|
||||
export function extractClaudeSessionId(data: string): string | null {
|
||||
for (const pattern of CLAUDE_SESSION_PATTERNS) {
|
||||
const match = data.match(pattern);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null {
|
||||
|
||||
for (const pattern of EMAIL_PATTERNS) {
|
||||
const match = cleanData.match(pattern);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
|
||||
import { getIsShuttingDown } from './pty-manager';
|
||||
|
||||
@@ -271,7 +270,7 @@ class PtyDaemonClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
||||
this.socket?.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -427,7 +426,7 @@ class PtyDaemonClient {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Kill the daemon process if we spawned it
|
||||
if (this.daemonProcess && this.daemonProcess.pid) {
|
||||
if (this.daemonProcess?.pid) {
|
||||
try {
|
||||
if (isWindows()) {
|
||||
// Windows: use taskkill to force kill process tree
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
loadProfilesFile,
|
||||
saveProfilesFile,
|
||||
@@ -48,7 +46,7 @@ vi.mock('fs', () => {
|
||||
});
|
||||
|
||||
describe('profile-manager', () => {
|
||||
const mockProfilesPath = '/mock/userdata/profiles.json';
|
||||
const _mockProfilesPath = '/mock/userdata/profiles.json';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function loadProfilesFile(): Promise<ProfilesFile> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as ProfilesFile;
|
||||
return data;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// File doesn't exist or is corrupted - return default
|
||||
return {
|
||||
profiles: [],
|
||||
|
||||
@@ -72,7 +72,7 @@ export class SpecNumberLock {
|
||||
const pidStr = readFileSync(this.lockFile, 'utf-8').trim();
|
||||
const pid = parseInt(pidStr, 10);
|
||||
|
||||
if (!isNaN(pid) && !this.isProcessRunning(pid)) {
|
||||
if (!Number.isNaN(pid) && !this.isProcessRunning(pid)) {
|
||||
// Stale lock - remove it
|
||||
try {
|
||||
unlinkSync(this.lockFile);
|
||||
@@ -194,7 +194,7 @@ export class SpecNumberLock {
|
||||
const match = entry.name.match(/^(\d{3})-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (!isNaN(num)) {
|
||||
if (!Number.isNaN(num)) {
|
||||
maxNum = Math.max(maxNum, num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AppUpdateProgress,
|
||||
AppUpdateAvailableEvent,
|
||||
AppUpdateDownloadedEvent,
|
||||
AppUpdateErrorEvent,
|
||||
IPCResult
|
||||
} from '../../shared/types';
|
||||
import { createIpcListener, invokeIpc, IpcListenerCleanup } from './modules/ipc-utils';
|
||||
@@ -34,6 +35,12 @@ export interface AppUpdateAPI {
|
||||
onAppUpdateStableDowngrade: (
|
||||
callback: (info: AppUpdateInfo) => void
|
||||
) => IpcListenerCleanup;
|
||||
onAppUpdateReadOnlyVolume: (
|
||||
callback: (info: { appPath: string }) => void
|
||||
) => IpcListenerCleanup;
|
||||
onAppUpdateError: (
|
||||
callback: (error: AppUpdateErrorEvent) => void
|
||||
) => IpcListenerCleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +58,9 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE),
|
||||
|
||||
installAppUpdate: (): void => {
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL);
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL).catch((err) =>
|
||||
console.error('[app-update] Install failed:', err)
|
||||
);
|
||||
},
|
||||
|
||||
getAppVersion: (): Promise<string> =>
|
||||
@@ -79,5 +88,15 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
|
||||
onAppUpdateStableDowngrade: (
|
||||
callback: (info: AppUpdateInfo) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, callback)
|
||||
createIpcListener(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, callback),
|
||||
|
||||
onAppUpdateReadOnlyVolume: (
|
||||
callback: (info: { appPath: string }) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, callback),
|
||||
|
||||
onAppUpdateError: (
|
||||
callback: (error: AppUpdateErrorEvent) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.APP_UPDATE_ERROR, callback)
|
||||
});
|
||||
|
||||
@@ -80,8 +80,9 @@ export interface ClaudeCodeAPI {
|
||||
/**
|
||||
* Check Claude Code CLI version status
|
||||
* Returns installed version, latest version, and whether update is available
|
||||
* @param forceRefresh - If true, bypasses the 24-hour cache and fetches fresh data from npm
|
||||
*/
|
||||
checkClaudeCodeVersion: () => Promise<ClaudeCodeVersionResult>;
|
||||
checkClaudeCodeVersion: (forceRefresh?: boolean) => Promise<ClaudeCodeVersionResult>;
|
||||
|
||||
/**
|
||||
* Install or update Claude Code CLI
|
||||
@@ -118,8 +119,8 @@ export interface ClaudeCodeAPI {
|
||||
* Creates the Claude Code API implementation
|
||||
*/
|
||||
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
|
||||
checkClaudeCodeVersion: (): Promise<ClaudeCodeVersionResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
|
||||
checkClaudeCodeVersion: (forceRefresh?: boolean): Promise<ClaudeCodeVersionResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, forceRefresh),
|
||||
|
||||
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
|
||||
|
||||
@@ -81,7 +81,7 @@ export const createProfileAPI = (): ProfileAPI => ({
|
||||
const requestId = ++testConnectionRequestId;
|
||||
|
||||
// Check if already aborted before initiating request
|
||||
if (signal && signal.aborted) {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const createProfileAPI = (): ProfileAPI => ({
|
||||
console.log('[preload/profile-api] Request ID:', requestId);
|
||||
|
||||
// Check if already aborted before initiating request
|
||||
if (signal && signal.aborted) {
|
||||
if (signal?.aborted) {
|
||||
console.log('[preload/profile-api] Already aborted, rejecting');
|
||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TaskAPI {
|
||||
options?: import('../../shared/types').TaskRecoveryOptions
|
||||
) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||
resumePausedTask: (taskId: string) => Promise<IPCResult>;
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||
@@ -144,6 +145,9 @@ export const createTaskAPI = (): TaskAPI => ({
|
||||
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
|
||||
|
||||
resumePausedTask: (taskId: string): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId),
|
||||
|
||||
// Image Operations
|
||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user