Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba49fd064e | |||
| f5f521da2a | |||
| 624c15d29b | |||
| 23f712c094 | |||
| 2f4393b7f1 | |||
| 5046d37d62 | |||
| a5e3cc9a2a | |||
| 4587162e43 | |||
| b4e6b2fe43 | |||
| d9cd300fee | |||
| f5a7e26d99 | |||
| 5f63daa3cc | |||
| e6e8da17c8 | |||
| 9317148b6b | |||
| 4730206214 | |||
| ae703be9f3 | |||
| 5293fb3996 | |||
| 8030c59f25 | |||
| ab91f7baf8 | |||
| a2c3507d61 | |||
| 26134c289c | |||
| 303b3781a4 |
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
packages/
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.1"
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -14,9 +14,7 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# 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
|
||||
# Concurrency retry constants
|
||||
MAX_CONCURRENCY_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY_SECONDS = 2
|
||||
MAX_RETRY_DELAY_SECONDS = 32
|
||||
|
||||
@@ -56,14 +56,10 @@ def _apply_qa_update(
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
# The frontend XState task state machine owns status transitions.
|
||||
# Writing status here races with XState's persistPlanStatusAndReasonSync()
|
||||
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ def handle_build_command(
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import get_base_branch_from_metadata
|
||||
from prompts_pkg.prompts import (
|
||||
get_base_branch_from_metadata,
|
||||
get_use_local_branch_from_metadata,
|
||||
)
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
@@ -203,6 +206,9 @@ def handle_build_command(
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
# Check if user requested local branch (preserves gitignored files like .env)
|
||||
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
@@ -213,6 +219,7 @@ def handle_build_command(
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
use_local_branch=use_local_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
|
||||
@@ -325,6 +325,7 @@ def setup_workspace(
|
||||
mode: WorkspaceMode,
|
||||
source_spec_dir: Path | None = None,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
) -> tuple[Path, WorktreeManager | None, Path | None]:
|
||||
"""
|
||||
Set up the workspace based on user's choice.
|
||||
@@ -337,6 +338,7 @@ def setup_workspace(
|
||||
mode: The workspace mode to use
|
||||
source_spec_dir: Optional source spec directory to copy to worktree
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
use_local_branch: If True, use local branch directly instead of preferring origin/branch
|
||||
|
||||
Returns:
|
||||
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
|
||||
@@ -357,7 +359,9 @@ def setup_workspace(
|
||||
# Ensure timeline tracking hook is installed (once per session)
|
||||
ensure_timeline_hook_installed(project_dir)
|
||||
|
||||
manager = WorktreeManager(project_dir, base_branch=base_branch)
|
||||
manager = WorktreeManager(
|
||||
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
|
||||
)
|
||||
manager.setup()
|
||||
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
|
||||
@@ -186,9 +186,15 @@ class WorktreeManager:
|
||||
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
|
||||
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
|
||||
|
||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
):
|
||||
self.project_dir = project_dir
|
||||
self.base_branch = base_branch or self._detect_base_branch()
|
||||
self.use_local_branch = use_local_branch
|
||||
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
self._merge_lock = asyncio.Lock()
|
||||
|
||||
@@ -695,18 +701,23 @@ class WorktreeManager:
|
||||
else:
|
||||
# Branch doesn't exist - create new branch from remote or local base
|
||||
# Determine the start point for the worktree
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
start_point = self.base_branch # Default to local branch
|
||||
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
if self.use_local_branch:
|
||||
# User explicitly requested local branch - skip auto-switch to remote
|
||||
# This preserves gitignored files (.env, configs) that may not exist on remote
|
||||
print(f"Creating worktree from local branch: {self.base_branch}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
|
||||
# Create worktree with new branch from the start point
|
||||
result = self._run_git(
|
||||
|
||||
@@ -91,11 +91,14 @@ class IdeationGenerator:
|
||||
prompt += f"\n{additional_context}\n"
|
||||
|
||||
# Create client with thinking budget
|
||||
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
|
||||
# which can cause 60-second timeout delays
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -184,11 +187,13 @@ Common fixes:
|
||||
Write the fixed JSON to the file now.
|
||||
"""
|
||||
|
||||
# Use agent_type="ideation" for recovery agent as well
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .types import IdeationPhaseResult
|
||||
|
||||
# Configuration
|
||||
MAX_RETRIES = 3
|
||||
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
|
||||
|
||||
|
||||
class IdeationOrchestrator:
|
||||
@@ -173,16 +174,45 @@ class IdeationOrchestrator:
|
||||
"progress",
|
||||
)
|
||||
|
||||
# Create tasks for all enabled types
|
||||
ideation_tasks = [
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
# Create tasks explicitly so we can cancel them on timeout
|
||||
ideation_task_objs = [
|
||||
asyncio.create_task(
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
)
|
||||
)
|
||||
for ideation_type in self.enabled_types
|
||||
]
|
||||
|
||||
# Run all ideation types concurrently
|
||||
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
|
||||
# Run all ideation types concurrently with timeout protection
|
||||
# 5 minute timeout prevents infinite hangs if one type stalls
|
||||
try:
|
||||
ideation_results = await asyncio.wait_for(
|
||||
asyncio.gather(*ideation_task_objs, return_exceptions=True),
|
||||
timeout=IDEATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
print_status(
|
||||
"Ideation generation timed out after 5 minutes",
|
||||
"error",
|
||||
)
|
||||
# Cancel all pending tasks to prevent resource leaks
|
||||
for task in ideation_task_objs:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
# Wait for cancellation to complete and preserve results from completed tasks
|
||||
# Tasks that finished before timeout will return their results;
|
||||
# cancelled tasks will return CancelledError
|
||||
results_after_cancel = await asyncio.gather(
|
||||
*ideation_task_objs, return_exceptions=True
|
||||
)
|
||||
# Convert CancelledError to timeout exception, preserve completed results
|
||||
ideation_results = [
|
||||
Exception("Ideation timed out")
|
||||
if isinstance(res, asyncio.CancelledError)
|
||||
else res
|
||||
for res in results_after_cancel
|
||||
]
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(ideation_results):
|
||||
|
||||
@@ -297,6 +297,41 @@ 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,6 +626,44 @@ 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:
|
||||
|
||||
@@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_use_local_branch_from_metadata(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Read useLocalBranch from task_metadata.json if it exists.
|
||||
|
||||
When True, the worktree should be created from the local branch directly
|
||||
instead of preferring origin/branch. This preserves gitignored files
|
||||
(.env, configs) that may not exist on the remote.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
True if useLocalBranch is set in metadata, False otherwise
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
return bool(metadata.get("useLocalBranch", False))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# Alias for backwards compatibility (internal use)
|
||||
_get_base_branch_from_metadata = get_base_branch_from_metadata
|
||||
|
||||
|
||||
@@ -818,7 +818,9 @@ class GHClient:
|
||||
return commits[-1].get("oid")
|
||||
return None
|
||||
|
||||
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
|
||||
async def get_pr_checks(
|
||||
self, pr_number: int, excluded_checks: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get CI check runs status for a PR.
|
||||
|
||||
@@ -826,6 +828,7 @@ class GHClient:
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
@@ -834,7 +837,9 @@ 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
|
||||
@@ -849,11 +854,20 @@ 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
|
||||
@@ -870,6 +884,7 @@ 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}")
|
||||
@@ -879,6 +894,7 @@ class GHClient:
|
||||
"failing": 0,
|
||||
"pending": 0,
|
||||
"failed_checks": [],
|
||||
"excluded_checks": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -973,7 +989,9 @@ 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) -> dict[str, Any]:
|
||||
async def get_pr_checks_comprehensive(
|
||||
self, pr_number: int, excluded_checks: list[str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive CI status including workflows awaiting approval.
|
||||
|
||||
@@ -983,12 +1001,13 @@ 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
|
||||
checks = await self.get_pr_checks(pr_number)
|
||||
# Get standard checks (with exclusions applied)
|
||||
checks = await self.get_pr_checks(pr_number, excluded_checks=excluded_checks)
|
||||
|
||||
# Get workflows awaiting approval
|
||||
awaiting = await self.get_workflows_awaiting_approval(pr_number)
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -156,6 +157,17 @@ 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,
|
||||
@@ -431,7 +443,10 @@ class GitHubOrchestrator:
|
||||
)
|
||||
|
||||
# Check CI status (comprehensive - includes workflows awaiting approval)
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
# 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
|
||||
)
|
||||
|
||||
# Log CI status with awaiting approval info
|
||||
awaiting = ci_status.get("awaiting_approval", 0)
|
||||
@@ -704,7 +719,10 @@ class GitHubOrchestrator:
|
||||
|
||||
# ALWAYS fetch current CI status to detect CI recovery
|
||||
# This must happen BEFORE the early return check to avoid stale CI verdicts
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
# 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
|
||||
)
|
||||
followup_context.ci_status = ci_status
|
||||
|
||||
if not has_commits and not has_file_changes:
|
||||
@@ -714,11 +732,14 @@ 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)
|
||||
# Helper to detect CI-related blockers (includes workflows pending and CI pending)
|
||||
def is_ci_blocker(b: str) -> bool:
|
||||
return b.startswith("CI Failed:") or b.startswith(
|
||||
"Workflows Pending:"
|
||||
return (
|
||||
b.startswith("CI Failed:")
|
||||
or b.startswith("Workflows Pending:")
|
||||
or b.startswith("CI Pending:")
|
||||
)
|
||||
|
||||
previous_blockers = getattr(previous_review, "blockers", [])
|
||||
@@ -728,11 +749,13 @@ class GitHubOrchestrator:
|
||||
)
|
||||
|
||||
# Determine the appropriate verdict based on current CI status
|
||||
# CI/Workflow status check (both block merging)
|
||||
ci_or_workflow_blocking = current_failing > 0 or current_awaiting > 0
|
||||
# CI/Workflow/Pending status check (all block merging)
|
||||
ci_or_workflow_blocking = (
|
||||
current_failing > 0 or current_awaiting > 0 or current_pending > 0
|
||||
)
|
||||
|
||||
if ci_or_workflow_blocking:
|
||||
# CI is still failing or workflows pending - keep blocked verdict
|
||||
# CI is still failing, pending, or workflows pending - keep blocked verdict
|
||||
updated_verdict = MergeVerdict.BLOCKED
|
||||
if current_failing > 0:
|
||||
updated_reasoning = (
|
||||
@@ -749,6 +772,15 @@ 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. "
|
||||
@@ -837,6 +869,14 @@ 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"
|
||||
@@ -959,6 +999,36 @@ 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
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,6 +46,7 @@ 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
|
||||
@@ -66,6 +67,7 @@ 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
|
||||
@@ -565,13 +567,27 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
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']}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
@@ -1047,12 +1063,35 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
logger.warning("[ParallelFollowup] Falling back to text parsing")
|
||||
|
||||
# Simple heuristic parsing
|
||||
findings = []
|
||||
# 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"
|
||||
)
|
||||
|
||||
# Look for verdict keywords
|
||||
text_lower = text.lower()
|
||||
@@ -1063,13 +1102,18 @@ 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:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
# If we found findings, default to NEEDS_REVISION
|
||||
verdict = (
|
||||
MergeVerdict.NEEDS_REVISION
|
||||
if findings
|
||||
else 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",
|
||||
}
|
||||
|
||||
@@ -17,14 +17,19 @@ Key Design:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
# Note: AgentDefinition import kept for backwards compatibility but no longer used
|
||||
# The Task tool's custom subagent_type feature is broken in Claude Code CLI
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
from claude_agent_sdk import AgentDefinition # noqa: F401
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
@@ -43,11 +48,13 @@ 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,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
@@ -67,15 +74,62 @@ 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,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Specialist Configuration for Parallel SDK Sessions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistConfig:
|
||||
"""Configuration for a specialist agent in parallel SDK sessions."""
|
||||
|
||||
name: str
|
||||
prompt_file: str
|
||||
tools: list[str]
|
||||
description: str
|
||||
|
||||
|
||||
# Define specialist configurations
|
||||
# Each specialist runs as its own SDK session with its own system prompt and tools
|
||||
SPECIALIST_CONFIGS: list[SpecialistConfig] = [
|
||||
SpecialistConfig(
|
||||
name="security",
|
||||
prompt_file="pr_security_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Security vulnerabilities, OWASP Top 10, auth issues, injection, XSS",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="quality",
|
||||
prompt_file="pr_quality_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Code quality, complexity, duplication, error handling, patterns",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="logic",
|
||||
prompt_file="pr_logic_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Logic correctness, edge cases, algorithms, race conditions",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="codebase-fit",
|
||||
prompt_file="pr_codebase_fit_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Naming conventions, ecosystem fit, architectural alignment",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
@@ -335,6 +389,317 @@ class ParallelOrchestratorReviewer:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Parallel SDK Sessions Implementation
|
||||
# =========================================================================
|
||||
# This replaces the broken Task tool subagent approach.
|
||||
# Each specialist runs as its own SDK session in parallel via asyncio.gather()
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
|
||||
def _build_specialist_prompt(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
"""Build the full prompt for a specialist agent.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context with files and patches
|
||||
project_root: Working directory for the agent
|
||||
|
||||
Returns:
|
||||
Full system prompt with context injected
|
||||
"""
|
||||
# Load base prompt from file
|
||||
base_prompt = self._load_prompt(config.prompt_file)
|
||||
if not base_prompt:
|
||||
base_prompt = f"You are a {config.name} specialist for PR review."
|
||||
|
||||
# Inject working directory using the existing helper
|
||||
with_working_dir = create_working_dir_injector(project_root)
|
||||
prompt_with_cwd = with_working_dir(
|
||||
base_prompt,
|
||||
f"You are a {config.name} specialist. Find {config.description}.",
|
||||
)
|
||||
|
||||
# Build file list
|
||||
files_list = []
|
||||
for file in context.changed_files:
|
||||
files_list.append(
|
||||
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
|
||||
)
|
||||
|
||||
# Build diff content (limited to avoid context overflow)
|
||||
patches = []
|
||||
MAX_DIFF_CHARS = 150_000 # Smaller limit per specialist
|
||||
|
||||
for file in context.changed_files:
|
||||
if file.patch:
|
||||
patches.append(f"\n### File: {file.path}\n{file.patch}")
|
||||
|
||||
diff_content = "\n".join(patches)
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
# Compose full prompt with PR context
|
||||
pr_context = f"""
|
||||
## PR Context
|
||||
|
||||
**PR #{context.pr_number}**: {context.title}
|
||||
|
||||
**Description:**
|
||||
{context.description or "(No description provided)"}
|
||||
|
||||
### Changed Files ({len(context.changed_files)} files, +{context.total_additions}/-{context.total_deletions})
|
||||
{chr(10).join(files_list)}
|
||||
|
||||
### Diff
|
||||
{diff_content}
|
||||
|
||||
## Your Task
|
||||
|
||||
Analyze this PR for {config.description}.
|
||||
Use the Read, Grep, and Glob tools to explore the codebase as needed.
|
||||
Report findings with specific file paths, line numbers, and code evidence.
|
||||
"""
|
||||
|
||||
return prompt_with_cwd + pr_context
|
||||
|
||||
async def _run_specialist_session(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[str, list[PRReviewFinding]]:
|
||||
"""Run a single specialist as its own SDK session.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (specialist_name, findings)
|
||||
"""
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Starting analysis...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Build the specialist prompt with PR context
|
||||
prompt = self._build_specialist_prompt(config, context, project_root)
|
||||
|
||||
try:
|
||||
# Create SDK client for this specialist
|
||||
# Note: Agent type uses the generic "pr_reviewer" since individual
|
||||
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
|
||||
# system prompt handles differentiation.
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": SpecialistResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Process SDK stream
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name=f"Specialist:{config.name}",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions={}, # No subagents for specialists
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# 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, [])
|
||||
|
||||
# Parse structured output
|
||||
structured_output = stream_result.get("structured_output")
|
||||
findings = self._parse_specialist_output(
|
||||
config.name, structured_output, stream_result.get("result_text", "")
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Complete: {len(findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (config.name, findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] Session failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Error: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
|
||||
def _parse_specialist_output(
|
||||
self,
|
||||
specialist_name: str,
|
||||
structured_output: dict[str, Any] | None,
|
||||
result_text: str,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Parse findings from specialist output.
|
||||
|
||||
Args:
|
||||
specialist_name: Name of the specialist
|
||||
structured_output: Structured JSON output if available
|
||||
result_text: Raw text output as fallback
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding objects
|
||||
"""
|
||||
findings = []
|
||||
|
||||
if structured_output:
|
||||
try:
|
||||
result = SpecialistResponse.model_validate(structured_output)
|
||||
|
||||
for f in result.findings:
|
||||
finding_id = hashlib.md5(
|
||||
f"{f.file}:{f.line}:{f.title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f.category)
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(f.severity.lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=f.file,
|
||||
line=f.line,
|
||||
end_line=f.end_line,
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f.suggested_fix or "",
|
||||
evidence=f.evidence,
|
||||
source_agents=[specialist_name],
|
||||
is_impact_finding=f.is_impact_finding,
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
logger.info(
|
||||
f"[Specialist:{specialist_name}] Parsed {len(findings)} findings from structured output"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
|
||||
)
|
||||
# Fall through to text parsing
|
||||
|
||||
if not findings and result_text:
|
||||
# Fallback to text parsing
|
||||
findings = self._parse_text_output(result_text)
|
||||
for f in findings:
|
||||
f.source_agents = [specialist_name]
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_specialists(
|
||||
self,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[list[PRReviewFinding], list[str]]:
|
||||
"""Run all specialists in parallel and collect findings.
|
||||
|
||||
Args:
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (all_findings, agents_invoked)
|
||||
"""
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Launching {len(SPECIALIST_CONFIGS)} specialists in parallel...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Create tasks for all specialists
|
||||
tasks = [
|
||||
self._run_specialist_session(
|
||||
config=config,
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
for config in SPECIALIST_CONFIGS
|
||||
]
|
||||
|
||||
# Run all specialists in parallel
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Collect findings and track which agents ran
|
||||
all_findings: list[PRReviewFinding] = []
|
||||
agents_invoked: list[str] = []
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"[ParallelOrchestrator] Specialist task failed: {result}")
|
||||
continue
|
||||
|
||||
specialist_name, findings = result
|
||||
agents_invoked.append(specialist_name)
|
||||
all_findings.extend(findings)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] All specialists complete. "
|
||||
f"Total findings: {len(all_findings)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (all_findings, agents_invoked)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: PRContext) -> str:
|
||||
"""Build full prompt for orchestrator with PR context."""
|
||||
# Load orchestrator prompt
|
||||
@@ -720,11 +1085,6 @@ The SDK will run invoked agents in parallel automatically.
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
# No need to pre-scan the codebase programmatically
|
||||
|
||||
# Build orchestrator prompt AFTER worktree creation and related files rescan
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
# Capture agent definitions for debug logging (with worktree path)
|
||||
agent_defs = self._define_specialist_agents(project_root)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
# Resolve model shorthand via environment variable override if configured
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
@@ -737,122 +1097,39 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
# SDK handles parallel execution when Claude invokes multiple Task tools
|
||||
client = self._create_sdk_client(project_root, model, thinking_budget)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
"Running specialist agents in parallel...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
# Retry logic for tool use concurrency errors
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 2.0 # seconds between retries
|
||||
# =================================================================
|
||||
# PARALLEL SDK SESSIONS APPROACH
|
||||
# =================================================================
|
||||
# Instead of using broken Task tool subagents, we spawn each
|
||||
# specialist as its own SDK session and run them in parallel.
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
#
|
||||
# This gives us:
|
||||
# - True parallel execution via asyncio.gather()
|
||||
# - Full control over each specialist's tools and prompts
|
||||
# - No dependency on broken CLI features
|
||||
# =================================================================
|
||||
|
||||
result_text = ""
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
last_error = None
|
||||
# Run all specialists in parallel
|
||||
findings, agents_invoked = await self._run_parallel_specialists(
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
|
||||
f"after tool concurrency error"
|
||||
)
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
|
||||
f"(tool concurrency error detected)"
|
||||
)
|
||||
# Small delay before retry
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
|
||||
# Recreate client for retry (fresh session)
|
||||
client = self._create_sdk_client(
|
||||
project_root, model, thinking_budget
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions=agent_defs,
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
|
||||
# Check for tool concurrency error specifically
|
||||
if (
|
||||
error == "tool_use_concurrency_error"
|
||||
and attempt < MAX_RETRIES - 1
|
||||
):
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Tool concurrency error on attempt {attempt + 1}, "
|
||||
f"will retry..."
|
||||
)
|
||||
last_error = error
|
||||
continue # Retry
|
||||
|
||||
# Check for other stream processing errors
|
||||
if error:
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {error}"
|
||||
)
|
||||
raise RuntimeError(f"SDK stream processing failed: {error}")
|
||||
|
||||
# Success - extract results and break retry loop
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
break # Success, exit retry loop
|
||||
|
||||
except Exception as e:
|
||||
# Check if this is a retryable error
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Retryable error on attempt {attempt + 1}: {e}"
|
||||
)
|
||||
last_error = str(e)
|
||||
continue # Retry
|
||||
|
||||
# Not retryable or out of retries - re-raise
|
||||
raise
|
||||
else:
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] Failed after {MAX_RETRIES} attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Orchestrator failed after {MAX_RETRIES} retry attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
# Log results
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Parallel specialists complete: "
|
||||
f"{len(findings)} findings from {len(agents_invoked)} agents"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
@@ -861,20 +1138,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (structured output also returns agents)
|
||||
findings, agents_from_structured = self._extract_structured_output(
|
||||
structured_output, result_text
|
||||
)
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
final_agents = (
|
||||
agents_from_structured if agents_from_structured else agents_invoked
|
||||
)
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
# Log completion with agent info
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -977,7 +1243,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
agents_invoked=final_agents,
|
||||
agents_invoked=agents_invoked,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
@@ -1134,6 +1400,19 @@ 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.
|
||||
|
||||
@@ -1168,23 +1447,39 @@ The SDK will run invoked agents in parallel automatically.
|
||||
)
|
||||
|
||||
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from text output (fallback)."""
|
||||
findings = []
|
||||
"""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] = []
|
||||
|
||||
try:
|
||||
# Extract JSON from text
|
||||
# First, try to extract JSON from text
|
||||
data = self._extract_json_from_text(output)
|
||||
if not data:
|
||||
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"
|
||||
)
|
||||
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}")
|
||||
|
||||
@@ -1498,25 +1793,34 @@ For EACH finding above:
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
# Check for specific error types that warrant retry
|
||||
error_str = str(error).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "circuit breaker" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
# Handle structured output validation failure - use text fallback
|
||||
if error == "structured_output_validation_failed":
|
||||
logger.warning(
|
||||
f"[PRReview] Retryable validation error: {error}"
|
||||
"[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
|
||||
)
|
||||
last_error = Exception(error)
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation failed: {error}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
|
||||
logger.warning(
|
||||
f"[PRReview] Retryable validation error: {error}"
|
||||
)
|
||||
last_error = Exception(error)
|
||||
continue # Retry
|
||||
|
||||
logger.error(f"[PRReview] Validation failed: {error}")
|
||||
# Fail-safe: return original findings
|
||||
return findings
|
||||
|
||||
structured_output = stream_result.get("structured_output")
|
||||
|
||||
|
||||
@@ -26,7 +26,87 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Verification Evidence (Required for All Findings)
|
||||
@@ -112,11 +192,22 @@ class QualityFinding(BaseFinding):
|
||||
|
||||
category: Literal[
|
||||
"redundancy", "quality", "test", "performance", "pattern", "docs"
|
||||
] = Field(description="Issue category")
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: redundancy, quality, test, "
|
||||
"performance, pattern, docs. Use 'redundancy' for code duplication "
|
||||
"(NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
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."""
|
||||
@@ -128,11 +219,20 @@ class DeepAnalysisFinding(BaseFinding):
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
] = Field(description="Issue category")
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
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."""
|
||||
@@ -194,7 +294,10 @@ class FollowupFinding(BaseModel):
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
|
||||
description="Issue category"
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"test, docs."
|
||||
)
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
@@ -206,6 +309,11 @@ 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."""
|
||||
@@ -222,9 +330,19 @@ class FollowupReviewResponse(BaseModel):
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
)
|
||||
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)
|
||||
@@ -289,9 +407,19 @@ class StructuralPassResult(BaseModel):
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Structural verdict")
|
||||
] = Field(
|
||||
description=(
|
||||
"Structural verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
|
||||
)
|
||||
)
|
||||
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."""
|
||||
@@ -366,7 +494,11 @@ class OrchestratorFinding(BaseModel):
|
||||
"performance",
|
||||
"logic",
|
||||
"test",
|
||||
] = Field(description="Issue category")
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
|
||||
)
|
||||
)
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -379,19 +511,34 @@ 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")
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
|
||||
)
|
||||
)
|
||||
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)
|
||||
@@ -446,7 +593,13 @@ class ParallelOrchestratorFinding(BaseModel):
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(description="Issue category")
|
||||
] = 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')."
|
||||
)
|
||||
)
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -482,6 +635,11 @@ 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."""
|
||||
@@ -537,6 +695,61 @@ class ValidationSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SpecialistFinding(BaseModel):
|
||||
"""A finding from a specialist agent (used in parallel SDK sessions)."""
|
||||
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"performance, pattern, test, docs."
|
||||
)
|
||||
)
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line number if multi-line")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet examined that shows the issue. Required.",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
@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).
|
||||
|
||||
Used when each specialist runs as its own SDK session rather than via Task tool.
|
||||
"""
|
||||
|
||||
specialist_name: str = Field(
|
||||
description="Name of the specialist (security, quality, logic, codebase-fit)"
|
||||
)
|
||||
analysis_summary: str = Field(description="Brief summary of what was analyzed")
|
||||
files_examined: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of files that were examined",
|
||||
)
|
||||
findings: list[SpecialistFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues found during analysis",
|
||||
)
|
||||
|
||||
|
||||
class ParallelOrchestratorResponse(BaseModel):
|
||||
"""Complete response schema for parallel orchestrator PR review."""
|
||||
|
||||
@@ -567,10 +780,18 @@ class ParallelOrchestratorResponse(BaseModel):
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
|
||||
description="Overall PR verdict"
|
||||
description=(
|
||||
"Overall PR verdict. MUST be exactly one of: APPROVE, COMMENT, "
|
||||
"NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
)
|
||||
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)
|
||||
@@ -610,7 +831,12 @@ class ParallelFollowupFinding(BaseModel):
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(description="Issue category")
|
||||
] = Field(
|
||||
description=(
|
||||
"Issue category. MUST be exactly one of: security, quality, logic, "
|
||||
"test, docs, regression, incomplete_fix."
|
||||
)
|
||||
)
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
@@ -638,6 +864,11 @@ 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."""
|
||||
@@ -709,9 +940,19 @@ class ParallelFollowupResponse(BaseModel):
|
||||
# Verdict
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
] = Field(
|
||||
description=(
|
||||
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
|
||||
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
|
||||
)
|
||||
)
|
||||
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,12 +460,17 @@ 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"after maximum retries - schema validation failed. "
|
||||
f"is_error={is_error}, result_preview={str(result_detail)[:500] if result_detail else 'None'}"
|
||||
)
|
||||
safe_print(
|
||||
f"[{context_name}] WARNING: Structured output validation failed after retries"
|
||||
f"[{context_name}] WARNING: Structured output validation failed after retries. "
|
||||
f"Result preview: {str(result_detail)[:300] if result_detail else 'None'}"
|
||||
)
|
||||
if not stream_error:
|
||||
stream_error = "structured_output_validation_failed"
|
||||
|
||||
@@ -43,7 +43,9 @@ export default defineConfig({
|
||||
'debug',
|
||||
'ms',
|
||||
// Minimatch for glob pattern matching in worktree handlers
|
||||
'minimatch'
|
||||
'minimatch',
|
||||
// XState for task state machine
|
||||
'xstate'
|
||||
]
|
||||
})],
|
||||
build: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.1",
|
||||
"version": "2.7.6-beta.2",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stdout data (must include newline for buffered output processing)
|
||||
mockStdout.emit('data', Buffer.from('Test log output\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit log events from stderr', async () => {
|
||||
@@ -313,7 +313,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stderr data (must include newline for buffered output processing)
|
||||
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit exit event when process exits', async () => {
|
||||
@@ -329,8 +329,8 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process exit
|
||||
mockProcess.emit('exit', 0);
|
||||
|
||||
// Exit event includes taskId, exit code, and process type
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
|
||||
// Exit event includes taskId, exit code, process type, and optional projectId
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit error event when process errors', async () => {
|
||||
@@ -346,7 +346,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process error
|
||||
mockProcess.emit('error', new Error('Spawn failed'));
|
||||
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should kill task and remove from tracking', async () => {
|
||||
|
||||
@@ -564,7 +564,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
|
||||
expect(result).toHaveProperty("success", true);
|
||||
const data = (result as { data: { theme: string } }).data;
|
||||
expect(data).toHaveProperty("theme", "system");
|
||||
expect(data).toHaveProperty("theme", "dark");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -390,6 +390,83 @@ describe('TaskStateManager', () => {
|
||||
// Should have sent PROCESS_EXITED event with unexpected=true
|
||||
// This should transition to error state
|
||||
});
|
||||
|
||||
it('should NOT mark exit code 0 as unexpected (plan_review stays intact)', () => {
|
||||
// Simulate: PLANNING_STARTED → PLANNING_COMPLETE (requireReview) → process exits code 0
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: false,
|
||||
subtaskCount: 0,
|
||||
requireReviewBeforeCoding: true
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in plan_review now
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
|
||||
// Process exits with code 0 - should NOT transition to error
|
||||
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
|
||||
|
||||
// PLANNING_COMPLETE is a terminal event, so handleProcessExited should skip entirely
|
||||
// Task should remain in plan_review
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
});
|
||||
|
||||
it('should treat PLANNING_COMPLETE as a terminal event', () => {
|
||||
// PLANNING_COMPLETE should prevent handleProcessExited from running
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 3,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in coding (no review required)
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
|
||||
// Process exits with code 1 - should still skip because PLANNING_COMPLETE is terminal
|
||||
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
|
||||
|
||||
// Task should remain in coding, NOT transition to error
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actor state restoration', () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export class AgentManager extends EventEmitter {
|
||||
metadata?: SpecCreationMetadata;
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
projectId?: string;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
@@ -51,7 +52,7 @@ export class AgentManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// Listen for task completion to clean up context (prevent memory leak)
|
||||
this.on('exit', (taskId: string, code: number | null) => {
|
||||
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
|
||||
// Clean up context when:
|
||||
// 1. Task completed successfully (code === 0), or
|
||||
// 2. Task failed and won't be restarted (handled by auto-swap logic)
|
||||
@@ -93,7 +94,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
@@ -173,10 +175,10 @@ export class AgentManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +188,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
options: TaskExecutionOptions = {},
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
@@ -251,9 +254,9 @@ export class AgentManager extends EventEmitter {
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +265,8 @@ export class AgentManager extends EventEmitter {
|
||||
async startQAProcess(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string
|
||||
specId: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
|
||||
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
|
||||
@@ -290,7 +294,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,7 +391,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription?: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): void {
|
||||
// Preserve swapCount if context already exists (for restarts)
|
||||
const existingContext = this.taskExecutionContext.get(taskId);
|
||||
@@ -402,7 +407,8 @@ export class AgentManager extends EventEmitter {
|
||||
specDir,
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount // Preserve existing count instead of resetting
|
||||
swapCount, // Preserve existing count instead of resetting
|
||||
projectId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,7 +470,8 @@ export class AgentManager extends EventEmitter {
|
||||
context.taskDescription!,
|
||||
context.specDir,
|
||||
context.metadata,
|
||||
context.baseBranch
|
||||
context.baseBranch,
|
||||
context.projectId
|
||||
);
|
||||
} else {
|
||||
console.log('[AgentManager] Restarting as task execution');
|
||||
@@ -472,7 +479,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.specId,
|
||||
context.options
|
||||
context.options,
|
||||
context.projectId
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -510,7 +510,8 @@ export class AgentProcessManager {
|
||||
cwd: string,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
processType: ProcessType = 'task-execution'
|
||||
processType: ProcessType = 'task-execution',
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
const isSpecRunner = processType === 'spec-creation';
|
||||
this.killProcess(taskId);
|
||||
@@ -562,7 +563,7 @@ export class AgentProcessManager {
|
||||
// spawn() failed synchronously (e.g., command not found, permission denied)
|
||||
// Clean up tracking entry and propagate error
|
||||
this.state.deleteProcess(taskId);
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err), projectId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -609,7 +610,7 @@ export class AgentProcessManager {
|
||||
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
|
||||
|
||||
@@ -629,7 +630,7 @@ export class AgentProcessManager {
|
||||
const taskEvent = parseTaskEvent(line);
|
||||
if (taskEvent) {
|
||||
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
|
||||
this.emitter.emit('task-event', taskId, taskEvent);
|
||||
this.emitter.emit('task-event', taskId, taskEvent, projectId);
|
||||
}
|
||||
|
||||
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
|
||||
@@ -689,7 +690,7 @@ export class AgentProcessManager {
|
||||
message: lastMessage,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -709,7 +710,7 @@ export class AgentProcessManager {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.emitter.emit('log', taskId, line + '\n');
|
||||
this.emitter.emit('log', taskId, line + '\n', projectId);
|
||||
processLog(line);
|
||||
if (isDebug) {
|
||||
console.log(`[Agent:${taskId}] ${line}`);
|
||||
@@ -730,11 +731,11 @@ export class AgentProcessManager {
|
||||
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
if (stdoutBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
|
||||
processLog(stdoutBuffer);
|
||||
}
|
||||
if (stderrBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
|
||||
processLog(stderrBuffer);
|
||||
}
|
||||
|
||||
@@ -749,7 +750,7 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
|
||||
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
|
||||
if (wasHandled) {
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -762,10 +763,10 @@ export class AgentProcessManager {
|
||||
message: `Process exited with code ${code}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
@@ -780,9 +781,9 @@ export class AgentProcessManager {
|
||||
message: `Error: ${err.message}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
this.emitter.emit('error', taskId, err.message);
|
||||
this.emitter.emit('error', taskId, err.message, projectId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,12 @@ export class AgentQueueManager {
|
||||
|
||||
// Track completed types for progress calculation
|
||||
const completedTypes = new Set<string>();
|
||||
const totalTypes = 7; // Default all types
|
||||
// Derive totalTypes from --types argument instead of hardcoding
|
||||
const typesArgIndex = args.findIndex((arg) => arg === '--types');
|
||||
const totalTypes =
|
||||
typesArgIndex > -1 && args[typesArgIndex + 1]
|
||||
? args[typesArgIndex + 1].split(',').length
|
||||
: 6; // Default to 6 if not specified
|
||||
|
||||
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
|
||||
@@ -31,11 +31,11 @@ export interface ExecutionProgressData {
|
||||
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
|
||||
|
||||
export interface AgentManagerEvents {
|
||||
log: (taskId: string, log: string) => void;
|
||||
error: (taskId: string, error: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
'task-event': (taskId: string, event: TaskEventPayload) => void;
|
||||
log: (taskId: string, log: string, projectId?: string) => void;
|
||||
error: (taskId: string, error: string, projectId?: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData, projectId?: string) => void;
|
||||
'task-event': (taskId: string, event: TaskEventPayload, projectId?: string) => void;
|
||||
}
|
||||
|
||||
// IdeationConfig now imported from shared types to maintain consistency
|
||||
@@ -50,6 +50,7 @@ export interface TaskExecutionOptions {
|
||||
workers?: number;
|
||||
baseBranch?: string;
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
@@ -73,6 +74,7 @@ export interface SpecCreationMetadata {
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
// Workspace mode - whether to use worktree isolation
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
shouldProactivelySwitch as shouldProactivelySwitchImpl,
|
||||
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
||||
} from './claude-profile/profile-scorer';
|
||||
import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils';
|
||||
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
|
||||
import {
|
||||
CLAUDE_PROFILES_DIR,
|
||||
generateProfileId as generateProfileIdImpl,
|
||||
@@ -54,6 +54,24 @@ 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.
|
||||
@@ -96,6 +114,10 @@ export class ClaudeProfileManager {
|
||||
// This repairs emails that were truncated due to ANSI escape codes in terminal output
|
||||
this.migrateCorruptedEmails();
|
||||
|
||||
// Populate missing subscription metadata for existing profiles
|
||||
// This reads subscriptionType and rateLimitTier from Keychain credentials
|
||||
this.populateSubscriptionMetadata();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -117,7 +139,7 @@ export class ClaudeProfileManager {
|
||||
const configEmail = getEmailFromConfigDir(profile.configDir);
|
||||
|
||||
if (configEmail && profile.email !== configEmail) {
|
||||
console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', {
|
||||
debugLog('[ClaudeProfileManager] Migrating corrupted email for profile:', {
|
||||
profileId: profile.id,
|
||||
oldEmail: profile.email,
|
||||
newEmail: configEmail
|
||||
@@ -129,7 +151,59 @@ export class ClaudeProfileManager {
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
console.warn('[ClaudeProfileManager] Email migration complete');
|
||||
debugLog('[ClaudeProfileManager] Email migration complete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
|
||||
*
|
||||
* This reads from Keychain credentials and updates profiles that don't have this metadata.
|
||||
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
|
||||
*/
|
||||
private populateSubscriptionMetadata(): void {
|
||||
let needsSave = false;
|
||||
|
||||
for (const profile of this.data.profiles) {
|
||||
if (!profile.configDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if profile already has subscription metadata
|
||||
if (profile.subscriptionType && profile.rateLimitTier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedConfigDir = normalizeWindowsPath(
|
||||
profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir
|
||||
);
|
||||
|
||||
// Use helper with onlyIfMissing option to preserve existing values
|
||||
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
|
||||
|
||||
if (result.subscriptionTypeUpdated) {
|
||||
needsSave = true;
|
||||
debugLog('[ClaudeProfileManager] Populated subscriptionType for profile:', {
|
||||
profileId: profile.id,
|
||||
subscriptionType: result.subscriptionType
|
||||
});
|
||||
}
|
||||
|
||||
if (result.rateLimitTierUpdated) {
|
||||
needsSave = true;
|
||||
debugLog('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
|
||||
profileId: profile.id,
|
||||
rateLimitTier: result.rateLimitTier
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
debugLog('[ClaudeProfileManager] Subscription metadata population complete');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +221,7 @@ export class ClaudeProfileManager {
|
||||
const loadedData = loadProfileStore(this.storePath);
|
||||
if (loadedData) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Loaded profiles:', {
|
||||
debugLog('[ClaudeProfileManager] Loaded profiles:', {
|
||||
count: loadedData.profiles.length,
|
||||
activeProfileId: loadedData.activeProfileId,
|
||||
profiles: loadedData.profiles.map(p => ({
|
||||
@@ -273,19 +347,12 @@ 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') {
|
||||
console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', {
|
||||
debugLog('[ClaudeProfileManager] getActiveProfile - using fallback:', {
|
||||
id: fallback.id,
|
||||
name: fallback.name,
|
||||
email: fallback.email
|
||||
@@ -295,7 +362,7 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] getActiveProfile:', {
|
||||
debugLog('[ClaudeProfileManager] getActiveProfile:', {
|
||||
id: active.id,
|
||||
name: active.name,
|
||||
email: active.email
|
||||
@@ -386,12 +453,12 @@ export class ClaudeProfileManager {
|
||||
const previousProfileId = this.data.activeProfileId;
|
||||
const profile = this.getProfile(profileId);
|
||||
if (!profile) {
|
||||
console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
|
||||
debugLog('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] setActiveProfile:', {
|
||||
debugLog('[ClaudeProfileManager] setActiveProfile:', {
|
||||
from: previousProfileId,
|
||||
to: profileId,
|
||||
profileName: profile.name
|
||||
@@ -506,10 +573,10 @@ export class ClaudeProfileManager {
|
||||
|
||||
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
|
||||
debugLog('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', { profileName: profile.name, configDir: expandedConfigDir });
|
||||
}
|
||||
} else {
|
||||
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
debugLog('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
|
||||
}
|
||||
|
||||
return env;
|
||||
@@ -729,7 +796,7 @@ export class ClaudeProfileManager {
|
||||
);
|
||||
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] getProfileEnv:', {
|
||||
debugLog('[ClaudeProfileManager] getProfileEnv:', {
|
||||
profileId,
|
||||
profileName: profile.name,
|
||||
isDefault: profile.isDefault,
|
||||
@@ -750,7 +817,7 @@ export class ClaudeProfileManager {
|
||||
if (credentials.token) {
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
|
||||
debugLog('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -806,7 +873,7 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
|
||||
this.save();
|
||||
console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId);
|
||||
debugLog('[ClaudeProfileManager] Cleared migrated profile:', profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,25 @@ 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.
|
||||
@@ -193,7 +212,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) {
|
||||
console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
|
||||
debugLog('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
|
||||
return 'Claude Code-credentials';
|
||||
}
|
||||
|
||||
@@ -396,13 +415,13 @@ function parseCredentialJson<T extends PlatformCredentials>(
|
||||
try {
|
||||
data = JSON.parse(credentialsJson);
|
||||
} catch {
|
||||
console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
|
||||
debugLog(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
|
||||
return extractFn({}) as T;
|
||||
}
|
||||
|
||||
// Validate JSON structure
|
||||
if (!validateCredentialData(data)) {
|
||||
console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
|
||||
debugLog(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
|
||||
return extractFn({}) as T;
|
||||
}
|
||||
|
||||
@@ -439,7 +458,7 @@ function getCredentialsFromFile(
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
|
||||
debugLog(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
|
||||
credentialsPath,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -453,7 +472,7 @@ function getCredentialsFromFile(
|
||||
// Defense-in-depth: Validate credentials path is within expected boundaries
|
||||
if (!isValidCredentialsPath(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
}
|
||||
const invalidResult = { token: null, email: null, error: 'Invalid credentials path' };
|
||||
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
|
||||
@@ -463,7 +482,7 @@ function getCredentialsFromFile(
|
||||
// Check if credentials file exists
|
||||
if (!existsSync(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
}
|
||||
const notFoundResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
|
||||
@@ -478,7 +497,7 @@ function getCredentialsFromFile(
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
} catch {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
const errorResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
|
||||
return errorResult;
|
||||
@@ -486,7 +505,7 @@ function getCredentialsFromFile(
|
||||
|
||||
// Validate JSON structure
|
||||
if (!validateCredentialData(data)) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
const invalidResult = { token: null, email: null };
|
||||
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
|
||||
return invalidResult;
|
||||
@@ -496,7 +515,7 @@ function getCredentialsFromFile(
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -506,7 +525,7 @@ function getCredentialsFromFile(
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -516,7 +535,7 @@ function getCredentialsFromFile(
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
debugLog(`[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;
|
||||
@@ -540,7 +559,7 @@ function getFullCredentialsFromFile(
|
||||
// Defense-in-depth: Validate credentials path is within expected boundaries
|
||||
if (!isValidCredentialsPath(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
|
||||
debugLog(`[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' };
|
||||
}
|
||||
@@ -548,7 +567,7 @@ function getFullCredentialsFromFile(
|
||||
// Check if credentials file exists
|
||||
if (!existsSync(credentialsPath)) {
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
|
||||
}
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
|
||||
}
|
||||
@@ -561,13 +580,13 @@ function getFullCredentialsFromFile(
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
} catch {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
|
||||
debugLog(`[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)) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
|
||||
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
|
||||
}
|
||||
|
||||
@@ -575,12 +594,12 @@ function getFullCredentialsFromFile(
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
|
||||
debugLog(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -593,7 +612,7 @@ function getFullCredentialsFromFile(
|
||||
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
|
||||
debugLog(`[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}` };
|
||||
}
|
||||
}
|
||||
@@ -616,15 +635,6 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -664,7 +674,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
|
||||
debugLog('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -674,7 +684,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
|
||||
debugLog('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -685,7 +695,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);
|
||||
console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
|
||||
debugLog('[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 });
|
||||
@@ -756,7 +766,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
|
||||
attribute,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -771,7 +781,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
|
||||
debugLog('[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' };
|
||||
@@ -795,7 +805,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
|
||||
debugLog('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -805,7 +815,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
|
||||
debugLog('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
|
||||
attribute,
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
@@ -817,7 +827,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);
|
||||
console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
|
||||
debugLog('[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}` };
|
||||
}
|
||||
@@ -840,7 +850,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) {
|
||||
console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
debugLog('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +904,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
|
||||
if ((now - cached.timestamp) < ttl) {
|
||||
if (isDebug) {
|
||||
const cacheAge = now - cached.timestamp;
|
||||
console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
|
||||
debugLog('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
|
||||
targetName,
|
||||
hasToken: !!cached.credentials.token,
|
||||
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
|
||||
@@ -910,7 +920,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) {
|
||||
console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
|
||||
debugLog('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
|
||||
}
|
||||
return invalidResult;
|
||||
}
|
||||
@@ -1017,7 +1027,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName);
|
||||
debugLog('[CredentialUtils:Windows] Invalid token format for target:', targetName);
|
||||
const result = { token: null, email };
|
||||
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
|
||||
return result;
|
||||
@@ -1027,7 +1037,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
credentialCache.set(cacheKey, { credentials, timestamp: now });
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
|
||||
debugLog('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
tokenFingerprint: getTokenFingerprint(token),
|
||||
@@ -1037,7 +1047,7 @@ public static extern bool CredFree(IntPtr cred);
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
debugLog('[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;
|
||||
@@ -1102,13 +1112,13 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
|
||||
// If only one has a token, use that one
|
||||
if (fileResult.token && !credManagerResult.token) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
|
||||
debugLog('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
if (credManagerResult.token && !fileResult.token) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
|
||||
debugLog('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
|
||||
}
|
||||
return credManagerResult;
|
||||
}
|
||||
@@ -1120,7 +1130,7 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
|
||||
|
||||
// Both have tokens - prefer file since Claude CLI writes there after login
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
debugLog('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
@@ -1242,12 +1252,12 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
|
||||
debugLog('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
|
||||
debugLog('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -1261,7 +1271,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);
|
||||
console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
|
||||
debugLog('[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}` };
|
||||
}
|
||||
}
|
||||
@@ -1277,7 +1287,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
|
||||
debugLog('[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' };
|
||||
}
|
||||
@@ -1299,12 +1309,12 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
|
||||
);
|
||||
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
|
||||
attribute,
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
@@ -1319,7 +1329,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);
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
|
||||
debugLog('[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}` };
|
||||
}
|
||||
}
|
||||
@@ -1339,7 +1349,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials {
|
||||
|
||||
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
debugLog('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1366,7 +1376,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) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
|
||||
debugLog('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
|
||||
}
|
||||
return invalidResult;
|
||||
}
|
||||
@@ -1461,12 +1471,12 @@ public static extern bool CredFree(IntPtr cred);
|
||||
|
||||
// Validate token format if present
|
||||
if (token && !isValidTokenFormat(token)) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
|
||||
debugLog('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
|
||||
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
|
||||
debugLog('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
|
||||
hasToken: !!token,
|
||||
hasEmail: !!email,
|
||||
hasRefreshToken: !!refreshToken,
|
||||
@@ -1479,7 +1489,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);
|
||||
console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
|
||||
debugLog('[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}` };
|
||||
}
|
||||
}
|
||||
@@ -1508,13 +1518,13 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
|
||||
// If only one has a token, use that one
|
||||
if (fileResult.token && !credManagerResult.token) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
|
||||
debugLog('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
if (credManagerResult.token && !fileResult.token) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
|
||||
debugLog('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
|
||||
}
|
||||
return credManagerResult;
|
||||
}
|
||||
@@ -1529,7 +1539,7 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
|
||||
// Using file as primary ensures consistency: the same token is returned whether
|
||||
// calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain().
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
debugLog('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
|
||||
}
|
||||
return fileResult;
|
||||
}
|
||||
@@ -1633,12 +1643,12 @@ function updateMacOSKeychainCredentials(
|
||||
}
|
||||
);
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
|
||||
debugLog('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
|
||||
}
|
||||
} catch {
|
||||
// Entry didn't exist - that's fine, we'll create it
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
|
||||
debugLog('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1656,7 +1666,7 @@ function updateMacOSKeychainCredentials(
|
||||
);
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
|
||||
debugLog('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1689,7 +1699,7 @@ function updateLinuxSecretServiceCredentials(
|
||||
const secretToolPath = findSecretToolPath();
|
||||
if (!secretToolPath) {
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
|
||||
}
|
||||
return { success: false, error: 'secret-tool not found' };
|
||||
}
|
||||
@@ -1730,7 +1740,7 @@ function updateLinuxSecretServiceCredentials(
|
||||
);
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
|
||||
debugLog('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1766,7 +1776,7 @@ function updateLinuxCredentials(
|
||||
return secretServiceResult;
|
||||
}
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
|
||||
debugLog('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1827,7 +1837,7 @@ function updateLinuxFileCredentials(
|
||||
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
|
||||
debugLog('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -1966,7 +1976,7 @@ function updateWindowsCredentialManagerCredentials(
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
|
||||
debugLog('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -2009,13 +2019,13 @@ function restrictWindowsFilePermissions(filePath: string): void {
|
||||
});
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
|
||||
debugLog('[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);
|
||||
console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
|
||||
debugLog('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2105,7 +2115,7 @@ function updateWindowsFileCredentials(
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
|
||||
debugLog('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
|
||||
}
|
||||
|
||||
// Clear cached credentials to ensure fresh values are read
|
||||
@@ -2162,7 +2172,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) {
|
||||
console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
|
||||
debugLog('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2205,3 +2215,101 @@ export function updateKeychainCredentials(
|
||||
|
||||
return { success: false, error: `Unsupported platform: ${process.platform}` };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Profile Subscription Metadata Helper
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of updating profile subscription metadata
|
||||
*/
|
||||
export interface UpdateSubscriptionMetadataResult {
|
||||
/** Whether subscriptionType was updated */
|
||||
subscriptionTypeUpdated: boolean;
|
||||
/** Whether rateLimitTier was updated */
|
||||
rateLimitTierUpdated: boolean;
|
||||
/** The subscriptionType value (if found) */
|
||||
subscriptionType?: string | null;
|
||||
/** The rateLimitTier value (if found) */
|
||||
rateLimitTier?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updateProfileSubscriptionMetadata
|
||||
*/
|
||||
export interface UpdateSubscriptionMetadataOptions {
|
||||
/**
|
||||
* If true, only update fields that are currently missing (undefined/null/empty).
|
||||
* This is useful for migration/initialization code that should not overwrite existing values.
|
||||
* Default: false (always update if credentials have values)
|
||||
*/
|
||||
onlyIfMissing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials.
|
||||
*
|
||||
* This helper centralizes the common pattern of reading subscription info from Keychain
|
||||
* and updating a profile object. It's used after OAuth login, onboarding completion,
|
||||
* and profile authentication verification.
|
||||
*
|
||||
* NOTE: This function mutates the profile object directly. The caller is responsible
|
||||
* for saving the profile after calling this function.
|
||||
*
|
||||
* @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties)
|
||||
* @param configDirOrCredentials - Either a config directory path to read credentials from,
|
||||
* or pre-fetched FullOAuthCredentials to avoid redundant reads
|
||||
* @param options - Optional settings like onlyIfMissing
|
||||
* @returns Information about what was updated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Option 1: Pass configDir - helper fetches credentials
|
||||
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir);
|
||||
*
|
||||
* // Option 2: Pass pre-fetched credentials (more efficient when already fetched)
|
||||
* const fullCreds = getFullCredentialsFromKeychain(profile.configDir);
|
||||
* const result = updateProfileSubscriptionMetadata(profile, fullCreds);
|
||||
*
|
||||
* // Option 3: Only populate if missing (for migration/initialization)
|
||||
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true });
|
||||
*
|
||||
* if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) {
|
||||
* profileManager.saveProfile(profile);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function updateProfileSubscriptionMetadata(
|
||||
profile: { subscriptionType?: string | null; rateLimitTier?: string | null },
|
||||
configDirOrCredentials: string | undefined | FullOAuthCredentials,
|
||||
options?: UpdateSubscriptionMetadataOptions
|
||||
): UpdateSubscriptionMetadataResult {
|
||||
const result: UpdateSubscriptionMetadataResult = {
|
||||
subscriptionTypeUpdated: false,
|
||||
rateLimitTierUpdated: false,
|
||||
};
|
||||
|
||||
const onlyIfMissing = options?.onlyIfMissing ?? false;
|
||||
|
||||
// Determine if we received pre-fetched credentials or a configDir
|
||||
const fullCreds: FullOAuthCredentials =
|
||||
typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null
|
||||
? configDirOrCredentials
|
||||
: getFullCredentialsFromKeychain(configDirOrCredentials);
|
||||
|
||||
// Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it)
|
||||
if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) {
|
||||
profile.subscriptionType = fullCreds.subscriptionType;
|
||||
result.subscriptionTypeUpdated = true;
|
||||
result.subscriptionType = fullCreds.subscriptionType;
|
||||
}
|
||||
|
||||
// Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it)
|
||||
if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) {
|
||||
profile.rateLimitTier = fullCreds.rateLimitTier;
|
||||
result.rateLimitTierUpdated = true;
|
||||
result.rateLimitTier = fullCreds.rateLimitTier;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -319,12 +319,6 @@ 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);
|
||||
|
||||
|
||||
@@ -90,60 +90,22 @@ 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
|
||||
|
||||
const finalUrl = url.toString();
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Successfully constructed endpoint:', {
|
||||
provider,
|
||||
originalPath,
|
||||
newPath: endpointConfig.usagePath,
|
||||
finalUrl
|
||||
});
|
||||
}
|
||||
|
||||
return finalUrl;
|
||||
return url.toString();
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -163,18 +125,7 @@ export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string
|
||||
*/
|
||||
export function detectProvider(baseUrl: string): ApiProvider {
|
||||
// Wrapper around shared detectProvider with debug logging for main process
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
const provider = sharedDetectProvider(baseUrl);
|
||||
|
||||
if (isDebug) {
|
||||
console.warn('[UsageMonitor:PROVIDER_DETECTION] Detected provider:', {
|
||||
baseUrl,
|
||||
provider
|
||||
});
|
||||
}
|
||||
|
||||
return provider;
|
||||
return sharedDetectProvider(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -844,18 +795,12 @@ 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
|
||||
@@ -863,7 +808,6 @@ export class UsageMonitor extends EventEmitter {
|
||||
}
|
||||
|
||||
// No credential available
|
||||
this.debugLog('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for the XState settled-state guard logic used in agent-events-handlers.
|
||||
*
|
||||
* The guard prevents execution-progress events from overwriting XState's
|
||||
* persisted status when the state machine has already settled into a
|
||||
* terminal/review state.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, TASK_STATE_NAMES } from '../../../shared/state-machines';
|
||||
|
||||
describe('XSTATE_SETTLED_STATES', () => {
|
||||
it('should contain the expected settled states', () => {
|
||||
expect(XSTATE_SETTLED_STATES.has('plan_review')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('human_review')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('error')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('creating_pr')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('pr_created')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT contain active processing states', () => {
|
||||
expect(XSTATE_SETTLED_STATES.has('backlog')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('planning')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('coding')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('qa_review')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('qa_fixing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should only contain valid task state names', () => {
|
||||
const validNames = new Set(TASK_STATE_NAMES);
|
||||
for (const state of XSTATE_SETTLED_STATES) {
|
||||
expect(validNames.has(state as typeof TASK_STATE_NAMES[number])).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('settled state guard behavior', () => {
|
||||
/**
|
||||
* Simulates the guard logic from agent-events-handlers execution-progress handler.
|
||||
* Returns true if the event should be blocked (XState is in a settled state).
|
||||
*/
|
||||
function shouldBlockExecutionProgress(currentXState: string | undefined): boolean {
|
||||
return !!(currentXState && XSTATE_SETTLED_STATES.has(currentXState));
|
||||
}
|
||||
|
||||
it('should block execution-progress when XState is in plan_review', () => {
|
||||
// After PLANNING_COMPLETE with requireReviewBeforeCoding=true,
|
||||
// process exits with code 1 emitting phase='failed' — must be blocked
|
||||
expect(shouldBlockExecutionProgress('plan_review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in human_review', () => {
|
||||
// After QA_PASSED, any stale events from the dying process must be blocked
|
||||
expect(shouldBlockExecutionProgress('human_review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in error', () => {
|
||||
// After PLANNING_FAILED/CODING_FAILED, stale events must not overwrite error status
|
||||
expect(shouldBlockExecutionProgress('error')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in done', () => {
|
||||
expect(shouldBlockExecutionProgress('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in planning', () => {
|
||||
expect(shouldBlockExecutionProgress('planning')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in coding', () => {
|
||||
// After USER_RESUMED from error, XState transitions to coding synchronously.
|
||||
// New agent events should flow through normally.
|
||||
expect(shouldBlockExecutionProgress('coding')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in qa_review', () => {
|
||||
expect(shouldBlockExecutionProgress('qa_review')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when no XState actor exists', () => {
|
||||
// No actor yet (first event for this task) — must not block
|
||||
expect(shouldBlockExecutionProgress(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSTATE_TO_PHASE', () => {
|
||||
it('should have a mapping for every task state', () => {
|
||||
for (const state of TASK_STATE_NAMES) {
|
||||
expect(XSTATE_TO_PHASE[state]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should map settled states to non-active phases', () => {
|
||||
// Settled states should map to phases that indicate completion or stoppage
|
||||
expect(XSTATE_TO_PHASE['plan_review']).toBe('planning');
|
||||
expect(XSTATE_TO_PHASE['human_review']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['error']).toBe('failed');
|
||||
expect(XSTATE_TO_PHASE['done']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['pr_created']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['creating_pr']).toBe('complete');
|
||||
});
|
||||
|
||||
it('should map active states to processing phases', () => {
|
||||
expect(XSTATE_TO_PHASE['planning']).toBe('planning');
|
||||
expect(XSTATE_TO_PHASE['coding']).toBe('coding');
|
||||
expect(XSTATE_TO_PHASE['qa_review']).toBe('qa_review');
|
||||
expect(XSTATE_TO_PHASE['qa_fixing']).toBe('qa_fixing');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown states', () => {
|
||||
expect(XSTATE_TO_PHASE['nonexistent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,13 @@ import type {
|
||||
AuthFailureInfo,
|
||||
ImplementationPlan,
|
||||
} from "../../shared/types";
|
||||
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines";
|
||||
import { AgentManager } from "../agent";
|
||||
import type { ProcessType, ExecutionProgressData } from "../agent";
|
||||
import { titleGenerator } from "../title-generator";
|
||||
import { fileWatcher } from "../file-watcher";
|
||||
import { notificationService } from "../notification-service";
|
||||
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync } from "./task/plan-file-utils";
|
||||
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync } from "./task/plan-file-utils";
|
||||
import { findTaskWorktree } from "../worktree-paths";
|
||||
import { findTaskAndProject } from "./task/shared";
|
||||
import { safeSendToRenderer } from "./utils";
|
||||
@@ -32,16 +33,22 @@ export function registerAgenteventsHandlers(
|
||||
// Agent Manager Events → Renderer
|
||||
// ============================================
|
||||
|
||||
agentManager.on("log", (taskId: string, log: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, project?.id);
|
||||
agentManager.on("log", (taskId: string, log: string, projectId?: string) => {
|
||||
// Use projectId from event when available; fall back to lookup for backward compatibility
|
||||
if (!projectId) {
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
projectId = project?.id;
|
||||
}
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, projectId);
|
||||
});
|
||||
|
||||
agentManager.on("error", (taskId: string, error: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
|
||||
agentManager.on("error", (taskId: string, error: string, projectId?: string) => {
|
||||
// Use projectId from event when available; fall back to lookup for backward compatibility
|
||||
if (!projectId) {
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
projectId = project?.id;
|
||||
}
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, projectId);
|
||||
});
|
||||
|
||||
// Handle SDK rate limit events from agent manager
|
||||
@@ -82,10 +89,10 @@ export function registerAgenteventsHandlers(
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
});
|
||||
|
||||
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => {
|
||||
// Get task + project for context and multi-project filtering (issue #723)
|
||||
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId);
|
||||
const exitProjectId = exitProject?.id;
|
||||
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => {
|
||||
// Use projectId from event to scope the lookup (prevents cross-project contamination)
|
||||
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId, projectId);
|
||||
const exitProjectId = exitProject?.id || projectId;
|
||||
|
||||
taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject);
|
||||
|
||||
@@ -109,7 +116,7 @@ export function registerAgenteventsHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (!task || !project) return;
|
||||
|
||||
const taskTitle = task.title || task.specId;
|
||||
@@ -120,11 +127,11 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on("task-event", (taskId: string, event) => {
|
||||
console.log(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
|
||||
agentManager.on("task-event", (taskId: string, event, projectId?: string) => {
|
||||
console.debug(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
|
||||
|
||||
if (taskStateManager.getLastSequence(taskId) === undefined) {
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (task && project) {
|
||||
try {
|
||||
const planPath = getPlanPath(project, task);
|
||||
@@ -140,20 +147,20 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (!task || !project) {
|
||||
console.log(`[agent-events-handlers] No task/project found for ${taskId}`);
|
||||
console.debug(`[agent-events-handlers] No task/project found for ${taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[agent-events-handlers] Task state before handleTaskEvent:`, {
|
||||
console.debug(`[agent-events-handlers] Task state before handleTaskEvent:`, {
|
||||
status: task.status,
|
||||
reviewReason: task.reviewReason,
|
||||
phase: task.executionProgress?.phase
|
||||
});
|
||||
|
||||
const accepted = taskStateManager.handleTaskEvent(taskId, event, task, project);
|
||||
console.log(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
|
||||
console.debug(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
@@ -176,14 +183,27 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData) => {
|
||||
// Use shared helper to find task and project (issue #723 - deduplicate lookup)
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const taskProjectId = project?.id;
|
||||
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData, projectId?: string) => {
|
||||
// Use projectId from event to scope the lookup (prevents cross-project contamination)
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
const taskProjectId = project?.id || projectId;
|
||||
|
||||
// Check if XState has already established a terminal/review state for this task.
|
||||
// XState is the source of truth for status. When XState is in a terminal state
|
||||
// (e.g., plan_review after PLANNING_COMPLETE), execution-progress events from the
|
||||
// agent process are stale and must not overwrite XState's persisted status.
|
||||
//
|
||||
// Example: When requireReviewBeforeCoding=true, the process exits with code 1 after
|
||||
// PLANNING_COMPLETE. The exit handler emits execution-progress with phase='failed',
|
||||
// which would incorrectly overwrite status='human_review' with status='error' via
|
||||
// persistPlanPhaseSync, and send a 'failed' phase to the renderer overwriting the
|
||||
// 'planning' phase that XState already emitted via emitPhaseFromState.
|
||||
const currentXState = taskStateManager.getCurrentState(taskId);
|
||||
const xstateInTerminalState = currentXState && XSTATE_SETTLED_STATES.has(currentXState);
|
||||
|
||||
// Persist phase to plan file for restoration on app refresh
|
||||
// Must persist to BOTH main project and worktree (if exists) since task may be loaded from either
|
||||
if (task && project && progress.phase) {
|
||||
if (task && project && progress.phase && !xstateInTerminalState) {
|
||||
const mainPlanPath = getPlanPath(project, task);
|
||||
persistPlanPhaseSync(mainPlanPath, progress.phase, project.id);
|
||||
|
||||
@@ -201,9 +221,16 @@ export function registerAgenteventsHandlers(
|
||||
persistPlanPhaseSync(worktreePlanPath, progress.phase, project.id);
|
||||
}
|
||||
}
|
||||
} else if (xstateInTerminalState && progress.phase) {
|
||||
console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`);
|
||||
}
|
||||
|
||||
// Include projectId in execution progress event for multi-project filtering
|
||||
// Skip sending execution-progress to renderer when XState has settled.
|
||||
// XState's emitPhaseFromState already sent the correct phase to the renderer.
|
||||
if (xstateInTerminalState) {
|
||||
console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`);
|
||||
return;
|
||||
}
|
||||
safeSendToRenderer(
|
||||
getMainWindow,
|
||||
IPC_CHANNELS.TASK_EXECUTION_PROGRESS,
|
||||
@@ -218,13 +245,42 @@ export function registerAgenteventsHandlers(
|
||||
// ============================================
|
||||
|
||||
fileWatcher.on("progress", (taskId: string, plan: ImplementationPlan) => {
|
||||
// Use shared helper to find project (issue #723 - deduplicate lookup)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
// File watcher events don't carry projectId — fall back to lookup
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id);
|
||||
|
||||
// Re-stamp XState status fields if the backend overwrote the plan file without them.
|
||||
// The planner agent writes implementation_plan.json via the Write tool, which replaces
|
||||
// the entire file and strips the frontend's status/xstateState/executionPhase fields.
|
||||
// This causes tasks to snap back to backlog on refresh.
|
||||
const planWithStatus = plan as { xstateState?: string; executionPhase?: string; status?: string };
|
||||
const currentXState = taskStateManager.getCurrentState(taskId);
|
||||
if (currentXState && !planWithStatus.xstateState && task && project) {
|
||||
console.debug(`[agent-events-handlers] Re-stamping XState status on plan file for ${taskId} (state: ${currentXState})`);
|
||||
const mainPlanPath = getPlanPath(project, task);
|
||||
const { status, reviewReason } = mapStateToLegacy(currentXState);
|
||||
const phase = XSTATE_TO_PHASE[currentXState] || 'idle';
|
||||
persistPlanStatusAndReasonSync(mainPlanPath, status, reviewReason, project.id, currentXState, phase);
|
||||
|
||||
// Also re-stamp worktree copy if it exists
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
if (worktreePath) {
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const worktreePlanPath = path.join(
|
||||
worktreePath,
|
||||
specsBaseDir,
|
||||
task.specId,
|
||||
AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN
|
||||
);
|
||||
if (existsSync(worktreePlanPath)) {
|
||||
persistPlanStatusAndReasonSync(worktreePlanPath, status, reviewReason, project.id, currentXState, phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fileWatcher.on("error", (taskId: string, error: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
// File watcher events don't carry projectId — fall back to lookup
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
|
||||
import { isWindows, isMacOS, isLinux } from '../platform';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils';
|
||||
import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
|
||||
import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import semver from 'semver';
|
||||
|
||||
@@ -1370,7 +1370,7 @@ export function registerClaudeCodeHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// If authenticated, update the profile with the email
|
||||
// If authenticated, update the profile with metadata from credentials
|
||||
// NOTE: We intentionally do NOT store the OAuth token in the profile.
|
||||
// Storing the token causes AutoClaude to use a stale cached token instead of
|
||||
// letting Claude CLI read fresh tokens from Keychain (which auto-refreshes).
|
||||
@@ -1384,7 +1384,11 @@ export function registerClaudeCodeHandlers(): void {
|
||||
profile.email = result.email;
|
||||
}
|
||||
|
||||
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
|
||||
// Update subscription metadata from Keychain credentials
|
||||
// These are needed to display "Max" vs "Pro" in the UI
|
||||
updateProfileSubscriptionMetadata(profile, expandedConfigDir);
|
||||
|
||||
// Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token
|
||||
profileManager.saveProfile(profile);
|
||||
|
||||
// CRITICAL: Clear keychain cache for this profile's configDir
|
||||
|
||||
@@ -118,6 +118,14 @@ 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';
|
||||
@@ -251,6 +259,9 @@ ${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)
|
||||
@@ -430,6 +441,13 @@ ${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]) {
|
||||
|
||||
@@ -40,6 +40,23 @@ import {
|
||||
* GraphQL response type for PR list query
|
||||
* Note: repository can be null if the repo doesn't exist or user lacks access
|
||||
*/
|
||||
interface GraphQLPRNode {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
author: { login: string } | null;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
assignees: { nodes: Array<{ login: string }> };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GraphQLPRListResponse {
|
||||
data: {
|
||||
repository: {
|
||||
@@ -48,28 +65,37 @@ interface GraphQLPRListResponse {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
author: { login: string } | null;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
assignees: { nodes: Array<{ login: string }> };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
nodes: GraphQLPRNode[];
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a GraphQL PR node to the frontend PRData format.
|
||||
* Shared between listPRs and listMorePRs handlers.
|
||||
*/
|
||||
function mapGraphQLPRToData(pr: GraphQLPRNode): PRData {
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GraphQL request to GitHub API
|
||||
*/
|
||||
@@ -219,6 +245,29 @@ 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
|
||||
*/
|
||||
@@ -487,6 +536,7 @@ export interface PRData {
|
||||
export interface PRListResult {
|
||||
prs: PRData[];
|
||||
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
|
||||
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -870,6 +920,16 @@ function parseLogLine(line: string): { source: string; content: string; isError:
|
||||
};
|
||||
}
|
||||
|
||||
// Check for parallel SDK specialist logs (Specialist:name format)
|
||||
const specialistMatch = line.match(/^\[Specialist:([\w-]+)\]\s*(.*)$/);
|
||||
if (specialistMatch) {
|
||||
return {
|
||||
source: `Specialist:${specialistMatch[1]}`,
|
||||
content: specialistMatch[2],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
@@ -970,8 +1030,9 @@ function getPhaseFromSource(source: string): PRLogPhase {
|
||||
|
||||
if (contextSources.includes(source)) return "context";
|
||||
if (analysisSources.includes(source)) return "analysis";
|
||||
// Specialist agents (Agent:xxx) are part of analysis phase
|
||||
// Specialist agents (Agent:xxx and Specialist:xxx) are part of analysis phase
|
||||
if (source.startsWith("Agent:")) return "analysis";
|
||||
if (source.startsWith("Specialist:")) return "analysis";
|
||||
if (synthesisSources.includes(source)) return "synthesis";
|
||||
return "synthesis"; // Default to synthesis for unknown sources
|
||||
}
|
||||
@@ -1266,8 +1327,8 @@ async function runPRReview(
|
||||
const repo = config?.repo || project.name || "unknown";
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, false);
|
||||
|
||||
// Build environment with project settings
|
||||
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
// Build environment with project settings (including excluded CI checks)
|
||||
const subprocessEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
@@ -1336,13 +1397,75 @@ async function runPRReview(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper to fetch PRs via GraphQL API.
|
||||
* Used by both listPRs and listMorePRs handlers to avoid code duplication.
|
||||
*/
|
||||
async function fetchPRsFromGraphQL(
|
||||
config: { token: string; repo: string },
|
||||
cursor: string | null,
|
||||
debugContext: string
|
||||
): Promise<PRListResult> {
|
||||
// Parse owner/repo from config - must be exactly "owner/repo" format
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
const repoParts = normalizedRepo.split("/");
|
||||
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", {
|
||||
repo: config.repo,
|
||||
normalized: normalizedRepo,
|
||||
context: debugContext,
|
||||
});
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
const [owner, repo] = repoParts;
|
||||
|
||||
try {
|
||||
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
|
||||
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
|
||||
const response = await githubGraphQL<GraphQLPRListResponse>(
|
||||
config.token,
|
||||
LIST_PRS_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
first: 100, // GitHub GraphQL max is 100
|
||||
after: cursor,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle case where repository doesn't exist or user lacks access
|
||||
if (!response.data.repository) {
|
||||
debugLog("Repository not found or access denied", { owner, repo, context: debugContext });
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
|
||||
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
|
||||
|
||||
debugLog(`Fetched PRs via GraphQL (${debugContext})`, {
|
||||
count: prNodes.length,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
});
|
||||
return {
|
||||
prs: prNodes.map(mapGraphQLPRToData),
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog(`Failed to fetch PRs (${debugContext})`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register PR-related handlers
|
||||
*/
|
||||
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||
debugLog("Registering PR handlers");
|
||||
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST,
|
||||
async (_, projectId: string): Promise<PRListResult> => {
|
||||
@@ -1351,69 +1474,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse owner/repo from config - must be exactly "owner/repo" format
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
const repoParts = normalizedRepo.split("/");
|
||||
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
const [owner, repo] = repoParts;
|
||||
|
||||
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
|
||||
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
|
||||
const response = await githubGraphQL<GraphQLPRListResponse>(
|
||||
config.token,
|
||||
LIST_PRS_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
first: 100, // GitHub GraphQL max is 100
|
||||
after: null, // Start from beginning
|
||||
}
|
||||
);
|
||||
|
||||
// Handle case where repository doesn't exist or user lacks access
|
||||
if (!response.data.repository) {
|
||||
debugLog("Repository not found or access denied", { owner, repo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
|
||||
|
||||
debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage });
|
||||
return {
|
||||
prs: prNodes.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
})),
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog("Failed to fetch PRs", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
return { prs: [], hasNextPage: false };
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, null, "initial");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false };
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
// Load more PRs (pagination) - fetches next page of PRs using cursor
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST_MORE,
|
||||
async (_, projectId: string, cursor: string): Promise<PRListResult> => {
|
||||
debugLog("listMorePRs handler called", { projectId, cursor });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, cursor, "pagination");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2650,8 +2732,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
|
||||
const followupEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
// Build environment with project settings (including excluded CI checks)
|
||||
const followupEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
|
||||
@@ -19,8 +19,7 @@ import { getWhichCommand } from '../../platform';
|
||||
*/
|
||||
function checkGhCli(): { installed: boolean; error?: string } {
|
||||
try {
|
||||
const checkCmd = `${getWhichCommand()} gh`;
|
||||
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
|
||||
execFileSync(getWhichCommand(), ['gh'], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export interface GitHubConfig {
|
||||
token: string;
|
||||
repo: string;
|
||||
excludedCIChecks?: string[];
|
||||
}
|
||||
|
||||
export interface GitHubAPIIssue {
|
||||
|
||||
@@ -81,7 +81,14 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
|
||||
}
|
||||
|
||||
if (!token || !repo) return null;
|
||||
return { token, repo };
|
||||
|
||||
// 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 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import type {
|
||||
IPCResult,
|
||||
InitializationResult,
|
||||
AutoBuildVersionInfo,
|
||||
GitStatus
|
||||
GitStatus,
|
||||
GitBranchDetail
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import {
|
||||
@@ -89,6 +90,96 @@ function getGitBranches(projectPath: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured branch information for a directory (both local and remote)
|
||||
* Returns GitBranchDetail[] with type indicators, keeping both local and remote versions
|
||||
* when a branch exists in both places (no deduplication)
|
||||
*/
|
||||
function getGitBranchesWithInfo(projectPath: string): GitBranchDetail[] {
|
||||
try {
|
||||
// First fetch to ensure we have latest remote refs
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['fetch', '--prune'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000 // 10 second timeout for fetch
|
||||
});
|
||||
} catch {
|
||||
// Fetch may fail if offline or no remote, continue with local refs
|
||||
}
|
||||
|
||||
// Get current branch for isCurrent indicator
|
||||
let currentBranch: string | null = null;
|
||||
try {
|
||||
const currentResult = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
currentBranch = currentResult.trim() || null;
|
||||
} catch {
|
||||
// Ignore - current branch detection may fail in some edge cases
|
||||
}
|
||||
|
||||
// Get local branches
|
||||
const localResult = execFileSync(getToolPath('git'), ['branch', '--format=%(refname:short)'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const localBranches: GitBranchDetail[] = localResult.trim().split('\n')
|
||||
.filter(b => b.trim())
|
||||
.map(b => {
|
||||
const name = b.trim();
|
||||
return {
|
||||
name,
|
||||
type: 'local' as const,
|
||||
displayName: name,
|
||||
isCurrent: name === currentBranch
|
||||
};
|
||||
});
|
||||
|
||||
// Get remote branches
|
||||
let remoteBranches: GitBranchDetail[] = [];
|
||||
try {
|
||||
const remoteResult = execFileSync(getToolPath('git'), ['branch', '-r', '--format=%(refname:short)'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
remoteBranches = remoteResult.trim().split('\n')
|
||||
.filter(b => b.trim())
|
||||
.map(b => b.trim())
|
||||
// Remove HEAD pointer entries like "origin/HEAD"
|
||||
.filter(b => !b.endsWith('/HEAD'))
|
||||
.map(name => ({
|
||||
name,
|
||||
type: 'remote' as const,
|
||||
displayName: name,
|
||||
isCurrent: false
|
||||
}));
|
||||
} catch {
|
||||
// Remote branches may not exist, continue with local only
|
||||
}
|
||||
|
||||
// Combine and sort: local branches first, then remote branches, alphabetically within each group
|
||||
const allBranches = [...localBranches, ...remoteBranches];
|
||||
|
||||
return allBranches.sort((a, b) => {
|
||||
// Local branches come first
|
||||
if (a.type === 'local' && b.type === 'remote') return -1;
|
||||
if (a.type === 'remote' && b.type === 'local') return 1;
|
||||
// Within same type, sort alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current git branch for a directory
|
||||
*/
|
||||
@@ -449,7 +540,7 @@ export function registerProjectHandlers(
|
||||
// Git Operations
|
||||
// ============================================
|
||||
|
||||
// Get all branches for a project
|
||||
// Get all branches for a project (legacy - returns string[])
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_BRANCHES,
|
||||
async (_, projectPath: string): Promise<IPCResult<string[]>> => {
|
||||
@@ -468,6 +559,25 @@ export function registerProjectHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get all branches with structured type information (local vs remote)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO,
|
||||
async (_, projectPath: string): Promise<IPCResult<GitBranchDetail[]>> => {
|
||||
try {
|
||||
if (!existsSync(projectPath)) {
|
||||
return { success: false, error: 'Directory does not exist' };
|
||||
}
|
||||
const branches = getGitBranchesWithInfo(projectPath);
|
||||
return { success: true, data: branches };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current branch for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_CURRENT_BRANCH,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for findTaskAndProject cross-project scoping.
|
||||
* Verifies that projectId prevents cross-project task contamination
|
||||
* when multiple projects have tasks with the same specId.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { findTaskAndProject } from '../shared';
|
||||
import type { Task, Project } from '../../../../shared/types';
|
||||
|
||||
// Mock projectStore
|
||||
const mockProjects: Project[] = [];
|
||||
const mockTasksByProject: Map<string, Task[]> = new Map();
|
||||
|
||||
vi.mock('../../../project-store', () => ({
|
||||
projectStore: {
|
||||
getProjects: () => mockProjects,
|
||||
getTasks: (projectId: string) => mockTasksByProject.get(projectId) || []
|
||||
}
|
||||
}));
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||
specId: 'test-spec',
|
||||
projectId: 'project-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: `project-${Date.now()}`,
|
||||
name: 'Test Project',
|
||||
path: '/test/project',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastOpenedAt: new Date().toISOString(),
|
||||
...overrides
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe('findTaskAndProject', () => {
|
||||
beforeEach(() => {
|
||||
mockProjects.length = 0;
|
||||
mockTasksByProject.clear();
|
||||
});
|
||||
|
||||
it('should find task by specId without projectId (backward compatibility)', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
const result = findTaskAndProject('write-to-file');
|
||||
expect(result.task).toBe(task);
|
||||
expect(result.project).toBe(project);
|
||||
});
|
||||
|
||||
it('should scope search to specified project when projectId is provided', () => {
|
||||
const projectA = createProject({ id: 'proj-a', name: 'Project A' });
|
||||
const projectB = createProject({ id: 'proj-b', name: 'Project B' });
|
||||
|
||||
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
|
||||
const taskB = createTask({ id: 'task-b', specId: 'write-to-file', projectId: 'proj-b' });
|
||||
|
||||
mockProjects.push(projectA, projectB);
|
||||
mockTasksByProject.set('proj-a', [taskA]);
|
||||
mockTasksByProject.set('proj-b', [taskB]);
|
||||
|
||||
// Without projectId - returns first match (Project A)
|
||||
const resultNoScope = findTaskAndProject('write-to-file');
|
||||
expect(resultNoScope.task).toBe(taskA);
|
||||
expect(resultNoScope.project).toBe(projectA);
|
||||
|
||||
// With projectId for Project B - returns Project B's task
|
||||
const resultScopedB = findTaskAndProject('write-to-file', 'proj-b');
|
||||
expect(resultScopedB.task).toBe(taskB);
|
||||
expect(resultScopedB.project).toBe(projectB);
|
||||
|
||||
// With projectId for Project A - returns Project A's task
|
||||
const resultScopedA = findTaskAndProject('write-to-file', 'proj-a');
|
||||
expect(resultScopedA.task).toBe(taskA);
|
||||
expect(resultScopedA.project).toBe(projectA);
|
||||
});
|
||||
|
||||
it('should NOT fall back to other projects when projectId is provided but task not found', () => {
|
||||
const projectA = createProject({ id: 'proj-a' });
|
||||
const projectB = createProject({ id: 'proj-b' });
|
||||
|
||||
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
|
||||
|
||||
mockProjects.push(projectA, projectB);
|
||||
mockTasksByProject.set('proj-a', [taskA]);
|
||||
mockTasksByProject.set('proj-b', []);
|
||||
|
||||
// Search Project B (which has no tasks) — should NOT find Project A's task
|
||||
const result = findTaskAndProject('write-to-file', 'proj-b');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when projectId refers to a non-existent project', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
// Search with a projectId that doesn't exist — should NOT fall back
|
||||
const result = findTaskAndProject('write-to-file', 'non-existent-project');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when task not found in any project', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', []);
|
||||
|
||||
const result = findTaskAndProject('nonexistent-task');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find task by id as well as specId', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'unique-uuid', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
const result = findTaskAndProject('unique-uuid', 'proj-1');
|
||||
expect(result.task).toBe(task);
|
||||
expect(result.project).toBe(project);
|
||||
});
|
||||
|
||||
it('should log warning when provided projectId is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
mockProjects.push(createProject({ id: 'proj-1' }));
|
||||
|
||||
findTaskAndProject('some-task', 'ghost-project');
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ghost-project'),
|
||||
// Flexible match on the rest of the message
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,7 @@ export function registerTaskExecutionHandlers(
|
||||
// Start spec creation process - pass the existing spec directory
|
||||
// so spec_runner uses it instead of creating a new one
|
||||
// Also pass baseBranch so worktrees are created from the correct branch
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch, project.id);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
// Read the spec.md to get the task description
|
||||
@@ -268,8 +268,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false, // Sequential for planning phase
|
||||
workers: 1,
|
||||
baseBranch,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
} else {
|
||||
// Task has subtasks, start normal execution
|
||||
@@ -284,8 +286,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -495,7 +499,7 @@ export function registerTaskExecutionHandlers(
|
||||
// The QA process needs to run where the implementation_plan.json with completed subtasks is
|
||||
const qaProjectPath = hasWorktree ? worktreePath : project.path;
|
||||
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId, project.id);
|
||||
|
||||
taskStateManager.handleUiEvent(
|
||||
taskId,
|
||||
@@ -727,7 +731,7 @@ export function registerTaskExecutionHandlers(
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate, project.id);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
|
||||
@@ -739,8 +743,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
} else {
|
||||
// Task has subtasks, start normal execution
|
||||
@@ -754,8 +760,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1108,7 +1116,7 @@ export function registerTaskExecutionHandlers(
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery, project.id);
|
||||
} else {
|
||||
// Spec exists - run task execution
|
||||
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
|
||||
@@ -1120,8 +1128,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForRecovery,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -314,8 +314,8 @@ export function persistPlanPhaseSync(
|
||||
const phaseToStatus: Record<string, TaskStatus> = {
|
||||
'planning': 'in_progress',
|
||||
'coding': 'in_progress',
|
||||
'qa_review': 'in_progress',
|
||||
'qa_fixing': 'in_progress',
|
||||
'qa_review': 'ai_review',
|
||||
'qa_fixing': 'ai_review',
|
||||
'complete': 'human_review',
|
||||
'failed': 'error'
|
||||
};
|
||||
|
||||
@@ -2,21 +2,39 @@ import type { Task, Project } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
|
||||
/**
|
||||
* Helper function to find task and project by taskId
|
||||
* Helper function to find task and project by taskId.
|
||||
*
|
||||
* When projectId is provided, the search is strictly scoped to that project.
|
||||
* If the task is not found in the specified project, returns undefined (does NOT
|
||||
* fall back to other projects). This prevents cross-project contamination when
|
||||
* multiple projects have tasks with the same specId.
|
||||
*
|
||||
* When projectId is NOT provided, searches all projects for backward
|
||||
* compatibility with callers that don't have projectId (e.g., file watcher events).
|
||||
*/
|
||||
export const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => {
|
||||
export const findTaskAndProject = (taskId: string, projectId?: string): { task: Task | undefined; project: Project | undefined } => {
|
||||
const projects = projectStore.getProjects();
|
||||
let task: Task | undefined;
|
||||
let project: Project | undefined;
|
||||
|
||||
// If projectId provided, search ONLY that project (no fallback)
|
||||
if (projectId) {
|
||||
const targetProject = projects.find((p) => p.id === projectId);
|
||||
if (!targetProject) {
|
||||
console.warn(`[findTaskAndProject] projectId "${projectId}" not found in projects list, returning undefined`);
|
||||
return { task: undefined, project: undefined };
|
||||
}
|
||||
const tasks = projectStore.getTasks(targetProject.id);
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
return { task, project: task ? targetProject : undefined };
|
||||
}
|
||||
|
||||
// No projectId: search all projects (backward compatibility for file watcher etc.)
|
||||
for (const p of projects) {
|
||||
const tasks = projectStore.getTasks(p.id);
|
||||
task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
if (task) {
|
||||
project = p;
|
||||
break;
|
||||
return { task, project: p };
|
||||
}
|
||||
}
|
||||
|
||||
return { task, project };
|
||||
return { task: undefined, project: undefined };
|
||||
};
|
||||
|
||||
@@ -55,10 +55,11 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
async (_, id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> => {
|
||||
const success = terminalManager.resize(id, cols, rows);
|
||||
return { success, data: { success } };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree
|
||||
async function createTerminalWorktree(
|
||||
request: CreateTerminalWorktreeRequest
|
||||
): Promise<TerminalWorktreeResult> {
|
||||
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request;
|
||||
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch, useLocalBranch } = request;
|
||||
|
||||
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch });
|
||||
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch, useLocalBranch });
|
||||
|
||||
// Validate projectPath against registered projects
|
||||
if (!isValidProjectPath(projectPath)) {
|
||||
@@ -418,8 +418,13 @@ async function createTerminalWorktree(
|
||||
// Already a remote ref, use as-is
|
||||
baseRef = baseBranch;
|
||||
debugLog('[TerminalWorktree] Using remote ref directly:', baseRef);
|
||||
} else if (useLocalBranch) {
|
||||
// User explicitly requested local branch - skip auto-switch to remote
|
||||
// This preserves gitignored files (.env, configs) that may not exist on remote
|
||||
baseRef = baseBranch;
|
||||
debugLog('[TerminalWorktree] Using local branch (explicit):', baseRef);
|
||||
} else {
|
||||
// Check if remote version exists and use it for latest code
|
||||
// Default behavior: check if remote version exists and use it for latest code
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], {
|
||||
cwd: projectPath,
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { isWindows, isMacOS, getHomebrewPath, joinPaths, getExecutableExtension } from './index';
|
||||
import { getWhereExePath } from '../utils/windows-paths';
|
||||
|
||||
/**
|
||||
* Resolve Claude CLI executable path
|
||||
@@ -174,7 +175,7 @@ export function getWindowsShellPaths(): Record<string, string[]> {
|
||||
return {};
|
||||
}
|
||||
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
|
||||
|
||||
// Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo'
|
||||
// We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths
|
||||
@@ -297,11 +298,14 @@ export function getOllamaInstallCommand(): string {
|
||||
/**
|
||||
* Get the command to find executables in PATH
|
||||
*
|
||||
* Windows: where.exe
|
||||
* Windows: Full path to where.exe (C:\Windows\System32\where.exe)
|
||||
* Using full path ensures it works even when System32 isn't in PATH,
|
||||
* which can happen in restricted environments or when Electron doesn't
|
||||
* inherit the full system PATH.
|
||||
* Unix: which
|
||||
*/
|
||||
export function getWhichCommand(): string {
|
||||
return isWindows() ? 'where.exe' : 'which';
|
||||
return isWindows() ? getWhereExePath() : 'which';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActorRefFrom } from 'xstate';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { TaskEventPayload } from './agent/task-event-schema';
|
||||
import type { Project, Task, TaskStatus, ReviewReason, ExecutionPhase } from '../shared/types';
|
||||
import { taskMachine, type TaskEvent } from '../shared/state-machines';
|
||||
import { taskMachine, XSTATE_TO_PHASE, mapStateToLegacy, type TaskEvent } from '../shared/state-machines';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import { safeSendToRenderer } from './ipc-handlers/utils';
|
||||
import { getPlanPath, persistPlanStatusAndReasonSync } from './ipc-handlers/task/plan-file-utils';
|
||||
@@ -14,21 +14,6 @@ import path from 'path';
|
||||
|
||||
type TaskActor = ActorRefFrom<typeof taskMachine>;
|
||||
|
||||
/** Maps XState states to execution phases. Shared by mapStateToExecutionPhase and emitPhaseFromState. */
|
||||
const XSTATE_TO_PHASE: Record<string, ExecutionPhase> = {
|
||||
'backlog': 'idle',
|
||||
'planning': 'planning',
|
||||
'plan_review': 'planning',
|
||||
'coding': 'coding',
|
||||
'qa_review': 'qa_review',
|
||||
'qa_fixing': 'qa_fixing',
|
||||
'human_review': 'complete',
|
||||
'error': 'failed',
|
||||
'creating_pr': 'complete',
|
||||
'pr_created': 'complete',
|
||||
'done': 'complete'
|
||||
};
|
||||
|
||||
interface TaskContextEntry {
|
||||
task: Task;
|
||||
project: Project;
|
||||
@@ -36,6 +21,7 @@ interface TaskContextEntry {
|
||||
|
||||
const TERMINAL_EVENTS = new Set<string>([
|
||||
'QA_PASSED',
|
||||
'PLANNING_COMPLETE',
|
||||
'PLANNING_FAILED',
|
||||
'CODING_FAILED',
|
||||
'QA_MAX_ITERATIONS',
|
||||
@@ -57,10 +43,10 @@ export class TaskStateManager {
|
||||
|
||||
handleTaskEvent(taskId: string, event: TaskEventPayload, task: Task, project: Project): boolean {
|
||||
const lastSeq = this.lastSequenceByTask.get(taskId);
|
||||
console.log(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
|
||||
console.debug(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
|
||||
|
||||
if (!this.isNewSequence(taskId, event.sequence)) {
|
||||
console.log(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
|
||||
console.debug(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
|
||||
return false;
|
||||
}
|
||||
this.setTaskContext(taskId, task, project);
|
||||
@@ -72,10 +58,10 @@ export class TaskStateManager {
|
||||
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
const stateBefore = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
|
||||
console.debug(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
|
||||
actor.send(event as TaskEvent);
|
||||
const stateAfter = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
console.debug(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,22 +78,26 @@ export class TaskStateManager {
|
||||
return;
|
||||
}
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
// Only mark as unexpected if the process exited with a non-zero code.
|
||||
// A code-0 exit is normal (e.g., spec creation finished, plan created, waiting for review).
|
||||
// Sending unexpected:true for code-0 exits incorrectly transitions plan_review → error.
|
||||
const isUnexpected = exitCode !== 0;
|
||||
actor.send({
|
||||
type: 'PROCESS_EXITED',
|
||||
exitCode: exitCode ?? -1,
|
||||
unexpected: true
|
||||
unexpected: isUnexpected
|
||||
} satisfies TaskEvent);
|
||||
}
|
||||
|
||||
handleUiEvent(taskId: string, event: TaskEvent, task: Task, project: Project): void {
|
||||
console.log(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
|
||||
console.debug(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
|
||||
this.setTaskContext(taskId, task, project);
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
const stateBefore = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
|
||||
console.debug(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
|
||||
actor.send(event);
|
||||
const stateAfter = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
console.debug(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
}
|
||||
|
||||
handleManualStatusChange(taskId: string, status: TaskStatus, task: Task, project: Project): boolean {
|
||||
@@ -220,7 +210,7 @@ export class TaskStateManager {
|
||||
private getOrCreateActor(taskId: string): TaskActor {
|
||||
const existing = this.actors.get(taskId);
|
||||
if (existing) {
|
||||
console.log(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
|
||||
console.debug(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -230,14 +220,14 @@ export class TaskStateManager {
|
||||
: undefined;
|
||||
|
||||
if (contextEntry) {
|
||||
console.log(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
|
||||
console.debug(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
|
||||
status: contextEntry.task.status,
|
||||
reviewReason: contextEntry.task.reviewReason,
|
||||
phase: contextEntry.task.executionProgress?.phase,
|
||||
initialState: snapshot ? String(snapshot.value) : 'default (backlog)'
|
||||
});
|
||||
} else {
|
||||
console.log(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
|
||||
console.debug(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
|
||||
}
|
||||
|
||||
const actor = snapshot
|
||||
@@ -247,8 +237,7 @@ export class TaskStateManager {
|
||||
const stateValue = String(snapshot.value);
|
||||
const lastState = this.lastStateByTask.get(taskId);
|
||||
|
||||
// Debug: Log all state transitions
|
||||
console.log(`[TaskStateManager] XState transition for ${taskId}:`, {
|
||||
console.debug(`[TaskStateManager] XState transition for ${taskId}:`, {
|
||||
from: lastState,
|
||||
to: stateValue,
|
||||
contextReviewReason: snapshot.context.reviewReason
|
||||
@@ -273,8 +262,7 @@ export class TaskStateManager {
|
||||
// Map XState state to execution phase for persistence
|
||||
const executionPhase = this.mapStateToExecutionPhase(stateValue);
|
||||
|
||||
// Debug: Log the mapped status and reviewReason
|
||||
console.log(`[TaskStateManager] Emitting status for ${taskId}:`, {
|
||||
console.debug(`[TaskStateManager] Emitting status for ${taskId}:`, {
|
||||
status,
|
||||
reviewReason,
|
||||
xstateState: stateValue,
|
||||
@@ -338,7 +326,7 @@ export class TaskStateManager {
|
||||
console.warn(`[TaskStateManager] emitStatus: No main window, cannot emit status ${status} for ${taskId}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
|
||||
console.debug(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
|
||||
safeSendToRenderer(
|
||||
this.getMainWindow,
|
||||
IPC_CHANNELS.TASK_STATUS_CHANGE,
|
||||
@@ -441,33 +429,3 @@ export class TaskStateManager {
|
||||
}
|
||||
|
||||
export const taskStateManager = new TaskStateManager();
|
||||
|
||||
function mapStateToLegacy(
|
||||
state: string,
|
||||
reviewReason?: ReviewReason
|
||||
): { status: TaskStatus; reviewReason?: ReviewReason } {
|
||||
switch (state) {
|
||||
case 'backlog':
|
||||
return { status: 'backlog' };
|
||||
case 'planning':
|
||||
case 'coding':
|
||||
return { status: 'in_progress' };
|
||||
case 'plan_review':
|
||||
return { status: 'human_review', reviewReason: 'plan_review' };
|
||||
case 'qa_review':
|
||||
case 'qa_fixing':
|
||||
return { status: 'ai_review' };
|
||||
case 'human_review':
|
||||
return { status: 'human_review', reviewReason };
|
||||
case 'error':
|
||||
return { status: 'human_review', reviewReason: 'errors' };
|
||||
case 'creating_pr':
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
case 'pr_created':
|
||||
return { status: 'pr_created' };
|
||||
case 'done':
|
||||
return { status: 'done' };
|
||||
default:
|
||||
return { status: 'backlog' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,23 +377,12 @@ 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,
|
||||
@@ -404,10 +393,6 @@ 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),
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils';
|
||||
import { getFullCredentialsFromKeychain, clearKeychainCache, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
|
||||
import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
|
||||
import * as OutputParser from './output-parser';
|
||||
@@ -486,8 +486,8 @@ export function handleOAuthToken(
|
||||
// Clear Keychain cache to get fresh credentials
|
||||
clearKeychainCache(profile.configDir);
|
||||
|
||||
// Extract token from Keychain using the profile's configDir
|
||||
const keychainCreds = getCredentialsFromKeychain(profile.configDir, true);
|
||||
// Extract full credentials from Keychain including subscriptionType and rateLimitTier
|
||||
const keychainCreds = getFullCredentialsFromKeychain(profile.configDir);
|
||||
|
||||
// Check if there was a keychain access error (not just "not found")
|
||||
if (keychainCreds.error) {
|
||||
@@ -525,6 +525,8 @@ export function handleOAuthToken(
|
||||
if (email) {
|
||||
profile.email = email;
|
||||
}
|
||||
// Update subscription metadata from Keychain credentials
|
||||
updateProfileSubscriptionMetadata(profile, keychainCreds);
|
||||
profile.isAuthenticated = true;
|
||||
profileManager.saveProfile(profile);
|
||||
|
||||
@@ -611,6 +613,8 @@ export function handleOAuthToken(
|
||||
if (email) {
|
||||
profile.email = email;
|
||||
}
|
||||
// Update subscription metadata from Keychain credentials
|
||||
updateProfileSubscriptionMetadata(profile, profile.configDir);
|
||||
profile.isAuthenticated = true;
|
||||
profileManager.saveProfile(profile);
|
||||
|
||||
@@ -673,6 +677,8 @@ export function handleOAuthToken(
|
||||
if (email) {
|
||||
activeProfile.email = email;
|
||||
}
|
||||
// Update subscription metadata from Keychain credentials
|
||||
updateProfileSubscriptionMetadata(activeProfile, activeProfile.configDir);
|
||||
activeProfile.isAuthenticated = true;
|
||||
profileManager.saveProfile(activeProfile);
|
||||
|
||||
@@ -766,11 +772,13 @@ export function handleOnboardingComplete(
|
||||
bufferLength: terminal.outputBuffer.length
|
||||
});
|
||||
|
||||
// Update profile with email if found and profile exists
|
||||
// Update profile with email and subscription metadata if found and profile exists
|
||||
// Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email
|
||||
if (profileId && email && profile) {
|
||||
const previousEmail = profile.email;
|
||||
profile.email = email;
|
||||
// Also update subscription metadata from Keychain credentials
|
||||
updateProfileSubscriptionMetadata(profile, profile.configDir);
|
||||
profileManager.saveProfile(profile);
|
||||
if (previousEmail !== email) {
|
||||
console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')');
|
||||
|
||||
@@ -168,6 +168,7 @@ export function spawnPtyProcess(
|
||||
const shellArgs = isWindows() ? [] : ['-l'];
|
||||
|
||||
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', shellType:', shellType, ')');
|
||||
debugLog('[PtyManager] PTY dimensions requested - cols:', cols, 'rows:', rows, 'cwd:', cwd || os.homedir());
|
||||
|
||||
// Create a clean environment without DEBUG to prevent Claude Code from
|
||||
// enabling debug mode when the Electron app is run in development mode.
|
||||
@@ -355,10 +356,30 @@ export function writeToPty(terminal: TerminalProcess, data: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY process
|
||||
* Resize a PTY process with validation and error handling.
|
||||
* @param terminal The terminal process to resize
|
||||
* @param cols New column count
|
||||
* @param rows New row count
|
||||
* @returns true if resize was successful, false otherwise
|
||||
*/
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): void {
|
||||
terminal.pty.resize(cols, rows);
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean {
|
||||
// Validate dimensions
|
||||
if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) {
|
||||
debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const prevCols = terminal.pty.cols;
|
||||
const prevRows = terminal.pty.rows;
|
||||
debugLog('[PtyManager] Resizing PTY - terminal:', terminal.id, 'from:', prevCols, 'x', prevRows, 'to:', cols, 'x', rows);
|
||||
terminal.pty.resize(cols, rows);
|
||||
debugLog('[PtyManager] PTY resized - actual dimensions now:', terminal.pty.cols, 'x', terminal.pty.rows);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[PtyManager] Resize failed for terminal:', terminal.id, 'error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,12 +137,14 @@ export class TerminalManager {
|
||||
|
||||
/**
|
||||
* Resize a terminal
|
||||
* @returns true if resize was successful, false otherwise
|
||||
*/
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
resize(id: string, cols: number, rows: number): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
PtyManager.resizePty(terminal, cols, rows);
|
||||
if (!terminal) {
|
||||
return false;
|
||||
}
|
||||
return PtyManager.resizePty(terminal, cols, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -130,6 +130,19 @@ export function getWindowsExecutablePaths(
|
||||
return validPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to where.exe.
|
||||
* Using the full path ensures where.exe works even when System32 isn't in PATH,
|
||||
* which can happen in restricted environments or when Electron doesn't inherit
|
||||
* the full system PATH.
|
||||
*
|
||||
* @returns Full path to where.exe (e.g., C:\Windows\System32\where.exe)
|
||||
*/
|
||||
export function getWhereExePath(): string {
|
||||
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
|
||||
return path.join(systemRoot, 'System32', 'where.exe');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a Windows executable using the `where` command.
|
||||
* This is the most reliable method as it searches:
|
||||
@@ -158,9 +171,10 @@ export function findWindowsExecutableViaWhere(
|
||||
}
|
||||
|
||||
try {
|
||||
// Use 'where' command to find the executable
|
||||
// where.exe is a built-in Windows command that finds executables
|
||||
const result = execFileSync('where.exe', [executable], {
|
||||
// Use full path to where.exe to ensure it works even when System32 isn't in PATH
|
||||
// This fixes issues in restricted environments or when Electron doesn't inherit system PATH
|
||||
const whereExe = getWhereExePath();
|
||||
const result = execFileSync(whereExe, [executable], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
@@ -261,9 +275,10 @@ export async function findWindowsExecutableViaWhereAsync(
|
||||
}
|
||||
|
||||
try {
|
||||
// Use 'where' command to find the executable
|
||||
// where.exe is a built-in Windows command that finds executables
|
||||
const { stdout } = await execFileAsync('where.exe', [executable], {
|
||||
// Use full path to where.exe to ensure it works even when System32 isn't in PATH
|
||||
// This fixes issues in restricted environments or when Electron doesn't inherit system PATH
|
||||
const whereExe = getWhereExePath();
|
||||
const { stdout } = await execFileAsync(whereExe, [executable], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
|
||||
@@ -269,6 +269,8 @@ export interface GitHubAPI {
|
||||
|
||||
// PR operations (fetches up to 100 open PRs at once - GitHub GraphQL limit)
|
||||
listPRs: (projectId: string) => Promise<PRListResult>;
|
||||
/** Load more PRs using cursor-based pagination */
|
||||
listMorePRs: (projectId: string, cursor: string) => Promise<PRListResult>;
|
||||
getPR: (projectId: string, prNumber: number) => Promise<PRData | null>;
|
||||
runPRReview: (projectId: string, prNumber: number) => void;
|
||||
cancelPRReview: (projectId: string, prNumber: number) => Promise<boolean>;
|
||||
@@ -338,6 +340,7 @@ export interface PRData {
|
||||
export interface PRListResult {
|
||||
prs: PRData[];
|
||||
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
|
||||
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -675,6 +678,10 @@ export const createGitHubAPI = (): GitHubAPI => ({
|
||||
listPRs: (projectId: string): Promise<PRListResult> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST, projectId),
|
||||
|
||||
// Load more PRs using cursor-based pagination
|
||||
listMorePRs: (projectId: string, cursor: string): Promise<PRListResult> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_LIST_MORE, projectId, cursor),
|
||||
|
||||
getPR: (projectId: string, prNumber: number): Promise<PRData | null> =>
|
||||
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET, projectId, prNumber),
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
GraphitiValidationResult,
|
||||
GraphitiConnectionTestResult,
|
||||
GitStatus,
|
||||
KanbanPreferences
|
||||
KanbanPreferences,
|
||||
GitBranchDetail
|
||||
} from '../../shared/types';
|
||||
|
||||
// Tab state interface (persisted in main process)
|
||||
@@ -97,7 +98,10 @@ export interface ProjectAPI {
|
||||
}) => void) => () => void;
|
||||
|
||||
// Git Operations
|
||||
/** @deprecated Use getGitBranchesWithInfo for structured branch data with type indicators */
|
||||
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
|
||||
/** Get branches with structured type information (local vs remote) */
|
||||
getGitBranchesWithInfo: (projectPath: string) => Promise<IPCResult<GitBranchDetail[]>>;
|
||||
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
|
||||
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
|
||||
checkGitStatus: (projectPath: string) => Promise<IPCResult<GitStatus>>;
|
||||
@@ -277,6 +281,9 @@ export const createProjectAPI = (): ProjectAPI => ({
|
||||
getGitBranches: (projectPath: string): Promise<IPCResult<string[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES, projectPath),
|
||||
|
||||
getGitBranchesWithInfo: (projectPath: string): Promise<IPCResult<GitBranchDetail[]>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO, projectPath),
|
||||
|
||||
getCurrentGitBranch: (projectPath: string): Promise<IPCResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.GIT_GET_CURRENT_BRANCH, projectPath),
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface TerminalAPI {
|
||||
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
|
||||
destroyTerminal: (id: string) => Promise<IPCResult>;
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => Promise<IPCResult<{ success: boolean }>>;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
|
||||
setTerminalTitle: (id: string, title: string) => void;
|
||||
@@ -137,8 +137,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
sendTerminalInput: (id: string, data: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data),
|
||||
|
||||
resizeTerminal: (id: string, cols: number, rows: number): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
|
||||
resizeTerminal: (id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
|
||||
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd),
|
||||
|
||||
@@ -9,3 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
// Expose debug flag for debug logging
|
||||
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
|
||||
|
||||
// Expose platform information for platform-specific behavior (e.g., PTY resize timing)
|
||||
contextBridge.exposeInMainWorld('platform', {
|
||||
isWindows: process.platform === 'win32',
|
||||
isMacOS: process.platform === 'darwin',
|
||||
isLinux: process.platform === 'linux',
|
||||
isUnix: process.platform !== 'win32',
|
||||
});
|
||||
|
||||
@@ -192,6 +192,42 @@ describe('Task Store', () => {
|
||||
originalDate.getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply reviewReason when provided', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({ id: 'task-1', status: 'in_progress' })]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskStatus('task-1', 'human_review', 'plan_review');
|
||||
|
||||
const task = useTaskStore.getState().tasks[0];
|
||||
expect(task.status).toBe('human_review');
|
||||
expect(task.reviewReason).toBe('plan_review');
|
||||
});
|
||||
|
||||
it('should clear reviewReason when not provided', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({ id: 'task-1', status: 'human_review', reviewReason: 'plan_review' })]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskStatus('task-1', 'in_progress');
|
||||
|
||||
const task = useTaskStore.getState().tasks[0];
|
||||
expect(task.status).toBe('in_progress');
|
||||
expect(task.reviewReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update when only reviewReason changes', () => {
|
||||
useTaskStore.setState({
|
||||
tasks: [createTestTask({ id: 'task-1', status: 'human_review', reviewReason: 'plan_review' })]
|
||||
});
|
||||
|
||||
useTaskStore.getState().updateTaskStatus('task-1', 'human_review', 'completed');
|
||||
|
||||
const task = useTaskStore.getState().tasks[0];
|
||||
expect(task.status).toBe('human_review');
|
||||
expect(task.reviewReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskFromPlan', () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from './ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useClaudeProfileStore } from '../stores/claude-profile-store';
|
||||
import { detectProvider, getProviderLabel, getProviderBadgeColor, type ApiProvider } from '../../shared/utils/provider-detection';
|
||||
import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time';
|
||||
import type { ClaudeUsageSnapshot } from '../../shared/types/agent';
|
||||
@@ -50,13 +49,8 @@ const OAUTH_FALLBACK = {
|
||||
} as const;
|
||||
|
||||
export function AuthStatusIndicator() {
|
||||
// Subscribe to profile state from settings store (API profiles)
|
||||
// Subscribe to profile state from settings store
|
||||
const { profiles, activeProfileId } = useSettingsStore();
|
||||
|
||||
// Subscribe to Claude OAuth profile state
|
||||
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
|
||||
const activeClaudeProfileId = useClaudeProfileStore((state) => state.activeProfileId);
|
||||
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track usage data for warning badge
|
||||
@@ -108,7 +102,6 @@ export function AuthStatusIndicator() {
|
||||
|
||||
// Compute auth status and provider detection using useMemo to avoid unnecessary re-renders
|
||||
const authStatus = useMemo(() => {
|
||||
// First check if user is using API profile auth (has active API profile)
|
||||
if (activeProfileId) {
|
||||
const activeProfile = profiles.find(p => p.id === activeProfileId);
|
||||
if (activeProfile) {
|
||||
@@ -126,36 +119,12 @@ export function AuthStatusIndicator() {
|
||||
badgeColor: getProviderBadgeColor(provider)
|
||||
};
|
||||
}
|
||||
// Profile ID set but profile not found - fallback to OAuth
|
||||
return OAUTH_FALLBACK;
|
||||
}
|
||||
|
||||
// No active API profile - check Claude OAuth profiles directly
|
||||
if (activeClaudeProfileId && claudeProfiles.length > 0) {
|
||||
const activeClaudeProfile = claudeProfiles.find(p => p.id === activeClaudeProfileId);
|
||||
if (activeClaudeProfile) {
|
||||
return {
|
||||
type: 'oauth' as const,
|
||||
name: activeClaudeProfile.email || activeClaudeProfile.name,
|
||||
provider: 'anthropic' as const,
|
||||
providerLabel: 'Anthropic',
|
||||
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to usage data if Claude profiles aren't loaded yet
|
||||
if (usage && (usage.profileName || usage.profileEmail)) {
|
||||
return {
|
||||
type: 'oauth' as const,
|
||||
name: usage.profileEmail || usage.profileName,
|
||||
provider: 'anthropic' as const,
|
||||
providerLabel: 'Anthropic',
|
||||
badgeColor: 'bg-orange-500/10 text-orange-500 border-orange-500/20 hover:bg-orange-500/15'
|
||||
};
|
||||
}
|
||||
|
||||
// No auth info available - fallback to generic OAuth
|
||||
// No active profile - using OAuth
|
||||
return OAUTH_FALLBACK;
|
||||
}, [activeProfileId, profiles, activeClaudeProfileId, claudeProfiles, usage]);
|
||||
}, [activeProfileId, profiles]);
|
||||
|
||||
// Helper function to truncate ID for display
|
||||
const truncateId = (id: string): string => {
|
||||
@@ -305,22 +274,6 @@ export function AuthStatusIndicator() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Account details for OAuth profiles */}
|
||||
{isOAuth && authStatus.name && authStatus.name !== 'OAuth' && (
|
||||
<>
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
{/* Account name/email with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
<span className="text-[10px]">{t('common:usage.account')}</span>
|
||||
</div>
|
||||
<span className="font-medium text-[10px]">{authStatus.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Checkbox } from './ui/checkbox';
|
||||
import { Progress } from './ui/progress';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import type { Task, WorktreeCreatePRResult } from '../../shared/types';
|
||||
import { useTaskStore } from '../stores/task-store';
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a worktree-related issue (missing worktree, no branch, etc.)
|
||||
@@ -154,6 +155,15 @@ export function BulkPRDialog({
|
||||
error: data.success ? undefined : (data.error || t('taskReview:pr.errors.unknown'))
|
||||
} : r
|
||||
));
|
||||
|
||||
// Update task state in store with new status and prUrl (more efficient than reloading all tasks)
|
||||
if (data.success && data.prUrl && !data.alreadyExists) {
|
||||
const currentTask = tasks[i];
|
||||
useTaskStore.getState().updateTask(currentTask.id, {
|
||||
status: 'done',
|
||||
metadata: { ...currentTask.metadata, prUrl: data.prUrl }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const errorMsg = prResult?.error || '';
|
||||
setTaskResults(prev => prev.map((r, idx) =>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { TaskCard } from './TaskCard';
|
||||
import { SortableTaskCard } from './SortableTaskCard';
|
||||
import { QueueSettingsModal } from './QueueSettingsModal';
|
||||
import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
import { cn } from '../lib/utils';
|
||||
import { persistTaskStatus, forceCompleteTask, archiveTasks, deleteTasks, useTaskStore } from '../stores/task-store';
|
||||
import { updateProjectSettings, useProjectStore } from '../stores/project-store';
|
||||
@@ -1068,74 +1067,29 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
isProcessingQueueRef.current = true;
|
||||
|
||||
try {
|
||||
// Track tasks we've already processed in this call to prevent duplicates
|
||||
// This is critical because store updates happen synchronously but we need to ensure
|
||||
// we never process the same task twice, even if there are timing issues
|
||||
const processedTaskIds = new Set<string>();
|
||||
// Track tasks we've already attempted to promote (to avoid infinite retries)
|
||||
const attemptedTaskIds = new Set<string>();
|
||||
let consecutiveFailures = 0;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10; // Safety limit to prevent infinite loop
|
||||
|
||||
// Track promotions in this call to enforce max parallel tasks limit
|
||||
let promotedInThisCall = 0;
|
||||
|
||||
// Log initial state
|
||||
const initialTasks = useTaskStore.getState().tasks;
|
||||
const initialInProgress = initialTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
|
||||
const initialQueued = initialTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
|
||||
debugLog(`[Queue] === PROCESS QUEUE START ===`, {
|
||||
maxParallelTasks,
|
||||
initialInProgressCount: initialInProgress.length,
|
||||
initialInProgressIds: initialInProgress.map(t => t.id),
|
||||
initialQueuedCount: initialQueued.length,
|
||||
initialQueuedIds: initialQueued.map(t => t.id),
|
||||
projectId
|
||||
});
|
||||
|
||||
// Loop until capacity is full or queue is empty
|
||||
let iteration = 0;
|
||||
while (true) {
|
||||
iteration++;
|
||||
// Calculate total in-progress count: tasks that were already in progress + tasks promoted in this call
|
||||
const totalInProgressCount = initialInProgress.length + promotedInThisCall;
|
||||
|
||||
debugLog(`[Queue] --- Iteration ${iteration} ---`, {
|
||||
initialInProgressCount: initialInProgress.length,
|
||||
promotedInThisCall,
|
||||
totalInProgressCount,
|
||||
capacityCheck: totalInProgressCount >= maxParallelTasks,
|
||||
processedCount: processedTaskIds.size
|
||||
});
|
||||
|
||||
// Stop if no capacity (initial in-progress + promoted in this call)
|
||||
if (totalInProgressCount >= maxParallelTasks) {
|
||||
debugLog(`[Queue] Capacity reached (${totalInProgressCount}/${maxParallelTasks}), stopping queue processing`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Get CURRENT state from store to find queued tasks
|
||||
const latestTasks = useTaskStore.getState().tasks;
|
||||
const latestInProgress = latestTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
|
||||
const queuedTasks = latestTasks.filter((t) =>
|
||||
t.status === 'queue' && !t.metadata?.archivedAt && !processedTaskIds.has(t.id)
|
||||
// Get CURRENT state from store to ensure accuracy
|
||||
const currentTasks = useTaskStore.getState().tasks;
|
||||
const inProgressCount = currentTasks.filter((t) =>
|
||||
t.status === 'in_progress' && !t.metadata?.archivedAt
|
||||
).length;
|
||||
const queuedTasks = currentTasks.filter((t) =>
|
||||
t.status === 'queue' && !t.metadata?.archivedAt && !attemptedTaskIds.has(t.id)
|
||||
);
|
||||
|
||||
debugLog(`[Queue] Current store state:`, {
|
||||
totalTasks: latestTasks.length,
|
||||
inProgressCount: latestInProgress.length,
|
||||
inProgressIds: latestInProgress.map(t => t.id),
|
||||
queuedCount: queuedTasks.length,
|
||||
queuedIds: queuedTasks.map(t => t.id),
|
||||
processedIds: Array.from(processedTaskIds)
|
||||
});
|
||||
|
||||
// Stop if no queued tasks or too many consecutive failures
|
||||
if (queuedTasks.length === 0) {
|
||||
debugLog('[Queue] No more queued tasks to process');
|
||||
// Stop if no capacity, no queued tasks, or too many consecutive failures
|
||||
if (inProgressCount >= maxParallelTasks || queuedTasks.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
debugLog(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
|
||||
console.warn(`[Queue] Stopping queue processing after ${MAX_CONSECUTIVE_FAILURES} consecutive failures`);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1146,62 +1100,28 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
return dateA - dateB; // Ascending order (oldest first)
|
||||
})[0];
|
||||
|
||||
debugLog(`[Queue] Selected task for promotion:`, {
|
||||
id: nextTask.id,
|
||||
currentStatus: nextTask.status,
|
||||
title: nextTask.title?.substring(0, 50)
|
||||
});
|
||||
|
||||
// Mark task as processed BEFORE attempting promotion to prevent duplicates
|
||||
processedTaskIds.add(nextTask.id);
|
||||
|
||||
debugLog(`[Queue] Promoting task ${nextTask.id} (${promotedInThisCall + 1}/${maxParallelTasks})`);
|
||||
console.log(`[Queue] Auto-promoting task ${nextTask.id} from Queue to In Progress (${inProgressCount + 1}/${maxParallelTasks})`);
|
||||
const result = await persistTaskStatus(nextTask.id, 'in_progress');
|
||||
|
||||
// Check store state after promotion
|
||||
const afterPromoteTasks = useTaskStore.getState().tasks;
|
||||
const afterPromoteInProgress = afterPromoteTasks.filter((t) => t.status === 'in_progress' && !t.metadata?.archivedAt);
|
||||
const afterPromoteQueued = afterPromoteTasks.filter((t) => t.status === 'queue' && !t.metadata?.archivedAt);
|
||||
|
||||
debugLog(`[Queue] After promotion attempt:`, {
|
||||
resultSuccess: result.success,
|
||||
promotedInThisCall,
|
||||
inProgressCount: afterPromoteInProgress.length,
|
||||
inProgressIds: afterPromoteInProgress.map(t => t.id),
|
||||
queuedCount: afterPromoteQueued.length,
|
||||
queuedIds: afterPromoteQueued.map(t => t.id)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Increment our local promotion counter
|
||||
promotedInThisCall++;
|
||||
// Reset consecutive failures on success
|
||||
consecutiveFailures = 0;
|
||||
} else {
|
||||
// If promotion failed, log error and continue to next task
|
||||
// If promotion failed, log error, mark as attempted, and skip to next task
|
||||
console.error(`[Queue] Failed to promote task ${nextTask.id} to In Progress:`, result.error);
|
||||
attemptedTaskIds.add(nextTask.id);
|
||||
consecutiveFailures++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log summary
|
||||
debugLog(`[Queue] === PROCESS QUEUE COMPLETE ===`, {
|
||||
totalIterations: iteration,
|
||||
tasksProcessed: processedTaskIds.size,
|
||||
tasksPromoted: promotedInThisCall,
|
||||
processedIds: Array.from(processedTaskIds)
|
||||
});
|
||||
|
||||
// Trigger UI refresh if tasks were promoted to ensure UI reflects all changes
|
||||
// This handles the case where store updates are batched/delayed via IPC events
|
||||
if (promotedInThisCall > 0 && onRefresh) {
|
||||
debugLog('[Queue] Triggering UI refresh after queue promotion');
|
||||
onRefresh();
|
||||
// Log if we had failed tasks
|
||||
if (attemptedTaskIds.size > 0) {
|
||||
console.warn(`[Queue] Skipped ${attemptedTaskIds.size} task(s) that failed to promote`);
|
||||
}
|
||||
} finally {
|
||||
isProcessingQueueRef.current = false;
|
||||
}
|
||||
}, [maxParallelTasks, projectId, onRefresh]);
|
||||
}, [maxParallelTasks]);
|
||||
|
||||
// Register task status change listener for queue auto-promotion
|
||||
// This ensures processQueue() is called whenever a task leaves in_progress
|
||||
@@ -1210,7 +1130,7 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
|
||||
(taskId, oldStatus, newStatus) => {
|
||||
// When a task leaves in_progress (e.g., goes to human_review), process the queue
|
||||
if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
|
||||
debugLog(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
|
||||
console.log(`[Queue] Task ${taskId} left in_progress, processing queue to fill slot`);
|
||||
processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function Roadmap({ projectId, onGoToTask }: RoadmapProps) {
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<RoadmapTabs
|
||||
roadmap={roadmap}
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ChevronDown, ChevronUp, RotateCcw, FolderTree, GitBranch, Info } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import { Combobox, type ComboboxOption } from './ui/combobox';
|
||||
import { Combobox } from './ui/combobox';
|
||||
import { TaskModalLayout } from './task-form/TaskModalLayout';
|
||||
import { TaskFormFields } from './task-form/TaskFormFields';
|
||||
import { type FileReferenceData } from './task-form/useImageUpload';
|
||||
@@ -23,8 +23,9 @@ import { TaskFileExplorerDrawer } from './TaskFileExplorerDrawer';
|
||||
import { FileAutocomplete } from './FileAutocomplete';
|
||||
import { createTask, saveDraft, loadDraft, clearDraft, isDraftEmpty } from '../stores/task-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import { buildBranchOptions } from '../lib/branch-utils';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile } from '../../shared/types';
|
||||
import type { TaskCategory, TaskPriority, TaskComplexity, TaskImpact, TaskMetadata, ImageAttachment, TaskDraft, ModelType, ThinkingLevel, ReferencedFile, GitBranchDetail } from '../../shared/types';
|
||||
import type { PhaseModelConfig, PhaseThinkingConfig } from '../../shared/types/settings';
|
||||
import {
|
||||
DEFAULT_AGENT_PROFILES,
|
||||
@@ -62,8 +63,8 @@ export function TaskCreationWizard({
|
||||
const [showFileExplorer, setShowFileExplorer] = useState(false);
|
||||
const [showGitOptions, setShowGitOptions] = useState(false);
|
||||
|
||||
// Git options state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
// Git options state - using structured GitBranchDetail for type indicators
|
||||
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
@@ -77,22 +78,27 @@ export function TaskCreationWizard({
|
||||
return project?.path ?? null;
|
||||
}, [projects, projectId]);
|
||||
|
||||
// Convert branches to ComboboxOption[] format for searchable dropdown
|
||||
const branchOptions: ComboboxOption[] = useMemo(() => {
|
||||
const options: ComboboxOption[] = [
|
||||
{
|
||||
// Build branch options using shared utility - groups by local/remote with type indicators
|
||||
const branchOptions = useMemo(() => {
|
||||
return buildBranchOptions(branches, {
|
||||
t,
|
||||
includeProjectDefault: {
|
||||
value: PROJECT_DEFAULT_BRANCH,
|
||||
label: projectDefaultBranch
|
||||
? t('tasks:wizard.gitOptions.useProjectDefaultWithBranch', { branch: projectDefaultBranch })
|
||||
: t('tasks:wizard.gitOptions.useProjectDefault')
|
||||
}
|
||||
];
|
||||
branches.forEach((branch) => {
|
||||
options.push({ value: branch, label: branch });
|
||||
branchName: projectDefaultBranch,
|
||||
labelKey: projectDefaultBranch
|
||||
? 'tasks:wizard.gitOptions.useProjectDefaultWithBranch'
|
||||
: 'tasks:wizard.gitOptions.useProjectDefault',
|
||||
},
|
||||
});
|
||||
return options;
|
||||
}, [branches, projectDefaultBranch, t]);
|
||||
|
||||
// Determine if the selected branch is local (for useLocalBranch flag)
|
||||
const isSelectedBranchLocal = useMemo(() => {
|
||||
if (baseBranch === PROJECT_DEFAULT_BRANCH) return false;
|
||||
const selectedGitBranchDetail = branches.find((b) => b.name === baseBranch);
|
||||
return selectedGitBranchDetail?.type === 'local';
|
||||
}, [baseBranch, branches]);
|
||||
|
||||
// Classification fields
|
||||
const [category, setCategory] = useState<TaskCategory | ''>('');
|
||||
const [priority, setPriority] = useState<TaskPriority | ''>('');
|
||||
@@ -187,7 +193,7 @@ export function TaskCreationWizard({
|
||||
}
|
||||
}, [open, projectId, settings.selectedAgentProfile, settings.customPhaseModels, settings.customPhaseThinking, selectedProfile.model, selectedProfile.thinkingLevel, selectedProfile.phaseModels, selectedProfile.phaseThinking]);
|
||||
|
||||
// Fetch branches when dialog opens
|
||||
// Fetch branches when dialog opens - using structured branch data with type indicators
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -195,7 +201,8 @@ export function TaskCreationWizard({
|
||||
if (!projectPath) return;
|
||||
if (isMounted) setIsLoadingBranches(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
// Use structured branch data with type indicators
|
||||
const result = await window.electronAPI.getGitBranchesWithInfo(projectPath);
|
||||
if (isMounted && result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
}
|
||||
@@ -432,6 +439,9 @@ export function TaskCreationWizard({
|
||||
}
|
||||
// Pass worktree preference - false means use --direct mode
|
||||
if (!useWorktree) metadata.useWorktree = false;
|
||||
// Set useLocalBranch when user explicitly selects a local branch
|
||||
// This preserves gitignored files (.env, configs) by not switching to origin
|
||||
if (isSelectedBranchLocal) metadata.useLocalBranch = true;
|
||||
|
||||
const task = await createTask(projectId, title.trim(), description.trim(), metadata);
|
||||
if (task) {
|
||||
|
||||
@@ -15,11 +15,30 @@ import { usePtyProcess } from './terminal/usePtyProcess';
|
||||
import { useTerminalEvents } from './terminal/useTerminalEvents';
|
||||
import { useAutoNaming } from './terminal/useAutoNaming';
|
||||
import { useTerminalFileDrop } from './terminal/useTerminalFileDrop';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
import { isWindows as checkIsWindows } from '../lib/os-detection';
|
||||
|
||||
// Minimum dimensions to prevent PTY creation with invalid sizes
|
||||
const MIN_COLS = 10;
|
||||
const MIN_ROWS = 3;
|
||||
|
||||
// Platform detection for platform-specific timing
|
||||
// Windows ConPTY is slower than Unix PTY, so we need longer grace periods
|
||||
const platformIsWindows = checkIsWindows();
|
||||
|
||||
// Threshold in milliseconds to allow for async PTY resize acknowledgment
|
||||
// Mismatches within this window after a resize are expected and not logged as warnings
|
||||
// Windows needs longer grace period due to slower ConPTY resize
|
||||
const DIMENSION_MISMATCH_GRACE_PERIOD_MS = platformIsWindows ? 500 : 100;
|
||||
|
||||
// Cooldown between auto-corrections to prevent rapid-fire corrections
|
||||
// Windows needs longer cooldown due to slower ConPTY operations
|
||||
const AUTO_CORRECTION_COOLDOWN_MS = platformIsWindows ? 1000 : 300;
|
||||
|
||||
// Auto-correction frequency monitoring
|
||||
const AUTO_CORRECTION_WARNING_THRESHOLD = 5; // Warn if > 5 corrections per minute
|
||||
const AUTO_CORRECTION_WINDOW_MS = 60000; // 1 minute window
|
||||
|
||||
/**
|
||||
* Handle interface exposed by Terminal component for external control.
|
||||
* Used by parent components (e.g., SortableTerminalWrapper) to trigger operations
|
||||
@@ -57,6 +76,23 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// Track last sent PTY dimensions to prevent redundant resize calls
|
||||
// This ensures terminal.resize() stays in sync with PTY dimensions
|
||||
const lastPtyDimensionsRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
// Track when the last resize was sent to PTY for grace period logic
|
||||
// This prevents false positive mismatch warnings during async resize acknowledgment
|
||||
const lastResizeTimeRef = useRef<number>(0);
|
||||
// Track previous isExpanded state to detect actual expansion changes
|
||||
// This prevents forcing PTY resize on initial mount (only on actual state changes)
|
||||
const prevIsExpandedRef = useRef<boolean | undefined>(undefined);
|
||||
// Track when last auto-correction was performed to implement cooldown
|
||||
const lastAutoCorrectionTimeRef = useRef<number>(0);
|
||||
// Track auto-correction frequency to detect potential deeper issues
|
||||
// If corrections exceed threshold, it may indicate a persistent sync problem
|
||||
const autoCorrectionCountRef = useRef<number>(0);
|
||||
const autoCorrectionWindowStartRef = useRef<number>(Date.now());
|
||||
// Sequence number for resize operations to prevent race conditions
|
||||
// When concurrent resize calls complete out-of-order, only the latest result is applied
|
||||
const resizeSequenceRef = useRef<number>(0);
|
||||
// Track post-creation dimension check timeout for cleanup
|
||||
const postCreationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Worktree dialog state
|
||||
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
|
||||
@@ -108,18 +144,131 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// Track when xterm dimensions are ready for PTY creation
|
||||
const [readyDimensions, setReadyDimensions] = useState<{ cols: number; rows: number } | null>(null);
|
||||
|
||||
/**
|
||||
* Helper function to resize PTY with proper dimension tracking and race condition prevention.
|
||||
* Uses sequence numbers to ensure only the latest resize result updates the tracked dimensions.
|
||||
* This prevents stale dimension corruption when concurrent resize calls complete out-of-order.
|
||||
*
|
||||
* @param cols - Target column count
|
||||
* @param rows - Target row count
|
||||
* @param context - Context string for debug logging (e.g., "onResize", "performFit")
|
||||
*/
|
||||
const resizePtyWithTracking = useCallback((cols: number, rows: number, context: string) => {
|
||||
// Increment sequence number for this resize operation
|
||||
const sequence = ++resizeSequenceRef.current;
|
||||
lastResizeTimeRef.current = Date.now();
|
||||
|
||||
window.electronAPI.resizeTerminal(id, cols, rows).then((result) => {
|
||||
// Only update dimensions if this is still the latest resize operation
|
||||
// This prevents race conditions where an earlier failed call overwrites a later successful one
|
||||
if (sequence !== resizeSequenceRef.current) {
|
||||
debugLog(`[Terminal ${id}] ${context}: Ignoring stale resize result (sequence ${sequence} vs current ${resizeSequenceRef.current})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
lastPtyDimensionsRef.current = { cols, rows };
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] ${context} resize failed: ${result.error || 'unknown error'}`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Only log if this is still the latest operation
|
||||
if (sequence === resizeSequenceRef.current) {
|
||||
debugLog(`[Terminal ${id}] ${context} resize error: ${error}`);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
// Callback when xterm has measured valid dimensions
|
||||
const handleDimensionsReady = useCallback((cols: number, rows: number) => {
|
||||
// Only set dimensions if they're valid (above minimum thresholds)
|
||||
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] handleDimensionsReady: cols=${cols}, rows=${rows} - setting readyDimensions`);
|
||||
setReadyDimensions({ cols, rows });
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] handleDimensionsReady: dimensions below minimum: cols=${cols} (min=${MIN_COLS}), rows=${rows} (min=${MIN_ROWS})`);
|
||||
}
|
||||
}, []);
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Check for dimension mismatch between xterm and PTY.
|
||||
* Logs a warning if dimensions differ outside the grace period after a resize.
|
||||
* This helps diagnose text alignment issues that can occur when xterm and PTY
|
||||
* have different ideas about terminal dimensions.
|
||||
*
|
||||
* @param xtermCols - Current xterm column count
|
||||
* @param xtermRows - Current xterm row count
|
||||
* @param context - Optional context string for the log message (e.g., "after resize", "on fit")
|
||||
* @param autoCorrect - If true, automatically correct mismatches by resizing PTY
|
||||
*/
|
||||
const checkDimensionMismatch = useCallback((
|
||||
xtermCols: number,
|
||||
xtermRows: number,
|
||||
context?: string,
|
||||
autoCorrect: boolean = false
|
||||
) => {
|
||||
const ptyDims = lastPtyDimensionsRef.current;
|
||||
|
||||
// Skip check if PTY hasn't been created yet (no dimensions to compare)
|
||||
if (!ptyDims) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip check if we're within the grace period after a resize
|
||||
// This prevents false positives during async PTY resize acknowledgment
|
||||
const timeSinceLastResize = Date.now() - lastResizeTimeRef.current;
|
||||
if (timeSinceLastResize < DIMENSION_MISMATCH_GRACE_PERIOD_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for mismatch
|
||||
const colsMismatch = xtermCols !== ptyDims.cols;
|
||||
const rowsMismatch = xtermRows !== ptyDims.rows;
|
||||
|
||||
if (colsMismatch || rowsMismatch) {
|
||||
const contextStr = context ? ` (${context})` : '';
|
||||
debugLog(
|
||||
`[Terminal ${id}] DIMENSION MISMATCH DETECTED${contextStr}: ` +
|
||||
`xterm=(cols=${xtermCols}, rows=${xtermRows}) vs PTY=(cols=${ptyDims.cols}, rows=${ptyDims.rows}) - ` +
|
||||
`delta=(cols=${xtermCols - ptyDims.cols}, rows=${xtermRows - ptyDims.rows})`
|
||||
);
|
||||
|
||||
// Auto-correct if enabled, PTY is created, and cooldown has passed
|
||||
const timeSinceAutoCorrect = Date.now() - lastAutoCorrectionTimeRef.current;
|
||||
if (
|
||||
autoCorrect &&
|
||||
isCreatedRef.current &&
|
||||
timeSinceAutoCorrect >= AUTO_CORRECTION_COOLDOWN_MS &&
|
||||
xtermCols >= MIN_COLS &&
|
||||
xtermRows >= MIN_ROWS
|
||||
) {
|
||||
// Track auto-correction frequency for monitoring
|
||||
const now = Date.now();
|
||||
if (now - autoCorrectionWindowStartRef.current >= AUTO_CORRECTION_WINDOW_MS) {
|
||||
// Log warning if previous window had excessive corrections
|
||||
if (autoCorrectionCountRef.current >= AUTO_CORRECTION_WARNING_THRESHOLD) {
|
||||
debugLog(
|
||||
`[Terminal ${id}] AUTO-CORRECTION WARNING: ${autoCorrectionCountRef.current} corrections ` +
|
||||
`in last minute - this may indicate a persistent sync issue`
|
||||
);
|
||||
}
|
||||
// Reset the window
|
||||
autoCorrectionCountRef.current = 0;
|
||||
autoCorrectionWindowStartRef.current = now;
|
||||
}
|
||||
autoCorrectionCountRef.current++;
|
||||
|
||||
debugLog(`[Terminal ${id}] AUTO-CORRECTING (#${autoCorrectionCountRef.current}): resizing PTY to ${xtermCols}x${xtermRows}`);
|
||||
lastAutoCorrectionTimeRef.current = Date.now();
|
||||
resizePtyWithTracking(xtermCols, xtermRows, 'AUTO-CORRECTION');
|
||||
}
|
||||
}
|
||||
}, [id, resizePtyWithTracking]);
|
||||
|
||||
// Initialize xterm with command tracking
|
||||
const {
|
||||
terminalRef,
|
||||
xtermRef: _xtermRef,
|
||||
xtermRef,
|
||||
fit,
|
||||
write: _write, // Output now handled by useGlobalTerminalListeners
|
||||
writeln,
|
||||
@@ -150,9 +299,8 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tracked dimensions and send resize to PTY
|
||||
lastPtyDimensionsRef.current = { cols, rows };
|
||||
window.electronAPI.resizeTerminal(id, cols, rows);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(cols, rows, 'onResize');
|
||||
},
|
||||
onDimensionsReady: handleDimensionsReady,
|
||||
});
|
||||
@@ -167,15 +315,14 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// This prevents creating PTY with default 80x24 when container is smaller
|
||||
const ptyDimensions = useMemo(() => {
|
||||
if (readyDimensions) {
|
||||
debugLog(`[Terminal ${id}] ptyDimensions memo: using readyDimensions cols=${readyDimensions.cols}, rows=${readyDimensions.rows}`);
|
||||
return readyDimensions;
|
||||
}
|
||||
// Fallback to current dimensions if they're valid
|
||||
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
|
||||
return { cols, rows };
|
||||
}
|
||||
// Return null to prevent PTY creation until dimensions are ready
|
||||
// Wait for actual measurement via onDimensionsReady callback
|
||||
// Do NOT use current cols/rows as they may be initial defaults (80x24)
|
||||
debugLog(`[Terminal ${id}] ptyDimensions memo: readyDimensions is null, returning null (skipCreation will be true)`);
|
||||
return null;
|
||||
}, [readyDimensions, cols, rows]);
|
||||
}, [readyDimensions, id]);
|
||||
|
||||
// Create PTY process - only when we have valid dimensions
|
||||
const { prepareForRecreate, resetForRecreate } = usePtyProcess({
|
||||
@@ -190,10 +337,33 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
isRecreatingRef,
|
||||
onCreated: () => {
|
||||
isCreatedRef.current = true;
|
||||
// Initialize PTY dimension tracking with creation dimensions
|
||||
// This ensures the first resize check has a baseline to compare against
|
||||
if (ptyDimensions) {
|
||||
lastPtyDimensionsRef.current = { cols: ptyDimensions.cols, rows: ptyDimensions.rows };
|
||||
// ALWAYS force PTY resize on creation/remount
|
||||
// This ensures PTY matches xterm even if PTY existed before remount (expand/minimize)
|
||||
// The root cause of text alignment issues is that when terminal remounts:
|
||||
// 1. PTY persists with old dimensions (e.g., 80x20)
|
||||
// 2. New xterm measures new container (e.g., 160x40)
|
||||
// 3. Without this force resize, PTY never gets updated
|
||||
// Read current dimensions from xterm ref to avoid stale closure values
|
||||
const currentCols = xtermRef.current?.cols;
|
||||
const currentRows = xtermRef.current?.rows;
|
||||
if (currentCols !== undefined && currentRows !== undefined && currentCols >= MIN_COLS && currentRows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] PTY created - forcing PTY resize to match xterm: cols=${currentCols}, rows=${currentRows}`);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(currentCols, currentRows, 'PTY creation');
|
||||
|
||||
// Schedule initial dimension mismatch check after PTY creation
|
||||
// This helps detect if xterm dimensions drifted during PTY setup
|
||||
// Read fresh dimensions inside the timeout to avoid stale closure
|
||||
// Store timeout ID for cleanup on unmount
|
||||
postCreationTimeoutRef.current = setTimeout(() => {
|
||||
const freshCols = xtermRef.current?.cols;
|
||||
const freshRows = xtermRef.current?.rows;
|
||||
if (freshCols !== undefined && freshRows !== undefined) {
|
||||
checkDimensionMismatch(freshCols, freshRows, 'post-PTY creation');
|
||||
}
|
||||
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] PTY created - no valid dimensions available for tracking (cols=${currentCols}, rows=${currentRows})`);
|
||||
}
|
||||
// If there's a pending worktree config from a recreation attempt,
|
||||
// sync it to main process now that the terminal exists.
|
||||
@@ -217,6 +387,26 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
},
|
||||
});
|
||||
|
||||
// Monitor for dimension mismatches between xterm and PTY
|
||||
// This effect runs when xterm dimensions change and checks for mismatches
|
||||
// after the grace period to help diagnose text alignment issues
|
||||
// Auto-correction is enabled to automatically fix any detected mismatches
|
||||
useEffect(() => {
|
||||
// Only check if PTY has been created
|
||||
if (!isCreatedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule a mismatch check after the grace period
|
||||
// This allows time for the PTY resize to be acknowledged
|
||||
// Enable auto-correct to automatically fix any detected mismatches
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkDimensionMismatch(cols, rows, 'periodic dimension sync check', true);
|
||||
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [cols, rows, checkDimensionMismatch]);
|
||||
|
||||
// Handle terminal events (output is now handled globally via useGlobalTerminalListeners)
|
||||
useTerminalEvents({
|
||||
terminalId: id,
|
||||
@@ -239,6 +429,13 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// Uses transitionend event listener and RAF-based retry logic instead of fixed timeout
|
||||
// for more reliable resizing after CSS transitions complete
|
||||
useEffect(() => {
|
||||
// Detect if this is an actual expansion state change vs initial mount
|
||||
// Only force PTY resize on actual state changes to avoid resizing with invalid dimensions on mount
|
||||
const isFirstMount = prevIsExpandedRef.current === undefined;
|
||||
const expansionStateChanged = !isFirstMount && prevIsExpandedRef.current !== isExpanded;
|
||||
debugLog(`[Terminal ${id}] Expansion effect: isExpanded=${isExpanded}, isFirstMount=${isFirstMount}, expansionStateChanged=${expansionStateChanged}, prevIsExpanded=${prevIsExpandedRef.current}`);
|
||||
prevIsExpandedRef.current = isExpanded;
|
||||
|
||||
// RAF fallback for test environments where requestAnimationFrame may not be defined
|
||||
const raf = typeof requestAnimationFrame !== 'undefined'
|
||||
? requestAnimationFrame
|
||||
@@ -273,9 +470,20 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
|
||||
// fit() returns boolean indicating success (true if container had valid dimensions)
|
||||
const success = fit();
|
||||
debugLog(`[Terminal ${id}] performFit: fit returned success=${success}, expansionStateChanged=${expansionStateChanged}, isCreatedRef=${isCreatedRef.current}`);
|
||||
|
||||
if (success) {
|
||||
fitSucceeded = true;
|
||||
// Force PTY resize only on actual expansion state changes (not initial mount)
|
||||
// This ensures PTY stays in sync even when xterm.onResize() doesn't fire
|
||||
// Read fresh dimensions from xterm ref after fit() to avoid stale closure values
|
||||
const freshCols = xtermRef.current?.cols;
|
||||
const freshRows = xtermRef.current?.rows;
|
||||
if (expansionStateChanged && isCreatedRef.current && freshCols !== undefined && freshRows !== undefined && freshCols >= MIN_COLS && freshRows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] performFit: Forcing PTY resize to cols=${freshCols}, rows=${freshRows}`);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(freshCols, freshRows, 'performFit');
|
||||
}
|
||||
} else if (retryCount < MAX_RETRIES) {
|
||||
// Container not ready yet, retry after a short delay
|
||||
retryCount++;
|
||||
@@ -343,7 +551,7 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
container.parentElement?.removeEventListener('transitionend', handleTransitionEnd);
|
||||
}
|
||||
};
|
||||
}, [isExpanded, fit]);
|
||||
}, [isExpanded, fit, id, resizePtyWithTracking]);
|
||||
|
||||
// Trigger deferred Claude resume when terminal becomes active
|
||||
// This ensures Claude sessions are only resumed when the user actually views the terminal,
|
||||
@@ -390,6 +598,12 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
isMountedRef.current = false;
|
||||
cleanupAutoNaming();
|
||||
|
||||
// Clear post-creation dimension check timeout to prevent operations on unmounted component
|
||||
if (postCreationTimeoutRef.current !== null) {
|
||||
clearTimeout(postCreationTimeoutRef.current);
|
||||
postCreationTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) {
|
||||
dispose();
|
||||
|
||||
@@ -58,6 +58,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
const {
|
||||
prs,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isLoadingPRDetails,
|
||||
error,
|
||||
selectedPRNumber,
|
||||
@@ -78,6 +79,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
assignPR,
|
||||
markReviewPosted,
|
||||
refresh,
|
||||
loadMore,
|
||||
isConnected,
|
||||
repoFullName,
|
||||
getReviewStateForPR,
|
||||
@@ -96,6 +98,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
setSortBy,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
} = usePRFiltering(prs, getReviewStateForPR);
|
||||
@@ -226,6 +229,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
onSearchChange={setSearchQuery}
|
||||
onContributorsChange={setContributors}
|
||||
onStatusesChange={setStatuses}
|
||||
onSortChange={setSortBy}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
<PRList
|
||||
@@ -236,6 +240,8 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
|
||||
error={error}
|
||||
getReviewStateForPR={getReviewStateForPR}
|
||||
onSelectPR={selectPR}
|
||||
onLoadMore={loadMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Multi-select dropdowns with visible chip selections
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Users,
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
X,
|
||||
Filter,
|
||||
Check,
|
||||
Loader2
|
||||
Loader2,
|
||||
ArrowUpDown,
|
||||
Clock,
|
||||
FileCode
|
||||
} from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Badge } from '../../ui/badge';
|
||||
@@ -29,7 +32,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../../ui/dropdown-menu';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PRFilterState, PRStatusFilter } from '../hooks/usePRFiltering';
|
||||
import type { PRFilterState, PRStatusFilter, PRSortOption } from '../hooks/usePRFiltering';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
interface PRFilterBarProps {
|
||||
@@ -39,6 +42,7 @@ interface PRFilterBarProps {
|
||||
onSearchChange: (query: string) => void;
|
||||
onContributorsChange: (contributors: string[]) => void;
|
||||
onStatusesChange: (statuses: PRStatusFilter[]) => void;
|
||||
onSortChange: (sortBy: PRSortOption) => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
@@ -59,6 +63,17 @@ const STATUS_OPTIONS: Array<{
|
||||
{ value: 'ready_for_followup', labelKey: 'prReview.readyForFollowup', icon: RefreshCw, color: 'text-cyan-400', bgColor: 'bg-cyan-500/20' },
|
||||
];
|
||||
|
||||
// Sort options
|
||||
const SORT_OPTIONS: Array<{
|
||||
value: PRSortOption;
|
||||
labelKey: string;
|
||||
icon: typeof Clock;
|
||||
}> = [
|
||||
{ value: 'newest', labelKey: 'prReview.sort.newest', icon: Clock },
|
||||
{ value: 'oldest', labelKey: 'prReview.sort.oldest', icon: Clock },
|
||||
{ value: 'largest', labelKey: 'prReview.sort.largest', icon: FileCode },
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Filter Dropdown Component
|
||||
*/
|
||||
@@ -138,6 +153,13 @@ function FilterDropdown<T extends string>({
|
||||
}
|
||||
}, [filteredItems, focusedIndex, toggleItem]);
|
||||
|
||||
// Scroll focused item into view for keyboard navigation
|
||||
useEffect(() => {
|
||||
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
|
||||
itemRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [focusedIndex]);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
@@ -279,6 +301,127 @@ function FilterDropdown<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select Sort Dropdown Component
|
||||
*/
|
||||
function SortDropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
title,
|
||||
}: {
|
||||
value: PRSortOption;
|
||||
onChange: (value: PRSortOption) => void;
|
||||
options: typeof SORT_OPTIONS;
|
||||
title: string;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
|
||||
const currentOption = options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (options.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < options.length) {
|
||||
onChange(options[focusedIndex].value);
|
||||
setIsOpen(false);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
}, [options, focusedIndex, onChange]);
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (open) {
|
||||
// Focus current selection on open for better keyboard UX
|
||||
setFocusedIndex(options.findIndex((o) => o.value === value));
|
||||
} else {
|
||||
setFocusedIndex(-1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-start border-dashed bg-transparent"
|
||||
>
|
||||
<ArrowUpDown className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{title}</span>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||
{t(currentOption.labelKey)}
|
||||
</Badge>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[180px] p-0">
|
||||
<div className="px-3 py-2 border-b border-border/50">
|
||||
<div className="text-xs font-semibold text-muted-foreground">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-1"
|
||||
role="listbox"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value;
|
||||
const isFocused = focusedIndex === index;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent/50",
|
||||
isFocused && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-full border border-primary/30",
|
||||
isSelected ? "bg-primary border-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{isSelected && <Check className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
<Icon className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{t(option.labelKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRFilterBar({
|
||||
filters,
|
||||
contributors,
|
||||
@@ -286,6 +429,7 @@ export function PRFilterBar({
|
||||
onSearchChange,
|
||||
onContributorsChange,
|
||||
onStatusesChange,
|
||||
onSortChange,
|
||||
onClearFilters,
|
||||
}: PRFilterBarProps) {
|
||||
const { t } = useTranslation('common');
|
||||
@@ -393,6 +537,16 @@ export function PRFilterBar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="flex-shrink-0">
|
||||
<SortDropdown
|
||||
value={filters.sortBy}
|
||||
onChange={onSortChange}
|
||||
options={SORT_OPTIONS}
|
||||
title={t('prReview.sort.label')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reset All */}
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react';
|
||||
import { GitPullRequest, User, Clock, FileDiff, Loader2 } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PRData, PRReviewProgress, PRReviewResult } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
@@ -170,6 +171,10 @@ interface PRListProps {
|
||||
error: string | null;
|
||||
getReviewStateForPR: (prNumber: number) => PRReviewInfo | null;
|
||||
onSelectPR: (prNumber: number) => void;
|
||||
/** Callback to load more PRs when hasMore is true */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether additional PRs are currently being loaded */
|
||||
isLoadingMore?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
@@ -200,6 +205,8 @@ export function PRList({
|
||||
error,
|
||||
getReviewStateForPR,
|
||||
onSelectPR,
|
||||
onLoadMore,
|
||||
isLoadingMore,
|
||||
}: PRListProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
@@ -306,12 +313,30 @@ export function PRList({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status indicator */}
|
||||
{/* Status indicator / Load More button */}
|
||||
{prs.length > 0 && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<span className="text-xs text-muted-foreground opacity-50">
|
||||
{hasMore ? t('prReview.maxPRsShown') : t('prReview.allPRsLoaded')}
|
||||
</span>
|
||||
{hasMore && onLoadMore ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t('prReview.loadingMore')}
|
||||
</>
|
||||
) : (
|
||||
t('prReview.loadMore')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground opacity-50">
|
||||
{t('prReview.allPRsLoaded')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@ const SOURCE_COLORS: Record<string, string> = {
|
||||
'Progress': 'bg-green-500/20 text-green-400',
|
||||
'PR Review Engine': 'bg-indigo-500/20 text-indigo-400',
|
||||
'Summary': 'bg-emerald-500/20 text-emerald-400',
|
||||
// Specialist agents (from parallel orchestrator)
|
||||
// Specialist agents (from parallel orchestrator - old Task tool approach)
|
||||
'Agent:logic-reviewer': 'bg-blue-600/20 text-blue-400',
|
||||
'Agent:quality-reviewer': 'bg-indigo-600/20 text-indigo-400',
|
||||
'Agent:security-reviewer': 'bg-red-600/20 text-red-400',
|
||||
@@ -70,6 +70,11 @@ const SOURCE_COLORS: Record<string, string> = {
|
||||
'Agent:resolution-verifier': 'bg-teal-600/20 text-teal-400',
|
||||
'Agent:new-code-reviewer': 'bg-cyan-600/20 text-cyan-400',
|
||||
'Agent:comment-analyzer': 'bg-gray-500/20 text-gray-400',
|
||||
// Parallel SDK specialists (new approach using parallel SDK sessions)
|
||||
'Specialist:security': 'bg-red-600/20 text-red-400',
|
||||
'Specialist:quality': 'bg-indigo-600/20 text-indigo-400',
|
||||
'Specialist:logic': 'bg-blue-600/20 text-blue-400',
|
||||
'Specialist:codebase-fit': 'bg-emerald-600/20 text-emerald-400',
|
||||
'default': 'bg-muted text-muted-foreground'
|
||||
};
|
||||
|
||||
@@ -107,8 +112,8 @@ function groupEntriesByAgent(entries: PRLogEntry[]): {
|
||||
const otherEntries: PRLogEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.source?.startsWith('Agent:')) {
|
||||
// Agent results
|
||||
if (entry.source?.startsWith('Agent:') || entry.source?.startsWith('Specialist:')) {
|
||||
// Agent/Specialist results (both old Task tool and new parallel SDK approaches)
|
||||
const existing = agentMap.get(entry.source) || [];
|
||||
existing.push(entry);
|
||||
agentMap.set(entry.source, existing);
|
||||
@@ -451,15 +456,66 @@ interface AgentLogGroupProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// Patterns that are uninteresting as summary entries
|
||||
const SKIP_AS_SUMMARY_PATTERNS = [
|
||||
/^Starting analysis\.\.\.$/,
|
||||
/^Processing SDK stream\.\.\.$/,
|
||||
/^Processing\.\.\./,
|
||||
/^Awaiting response stream\.\.\.$/,
|
||||
];
|
||||
|
||||
function isBoringSummary(content: string): boolean {
|
||||
return SKIP_AS_SUMMARY_PATTERNS.some(pattern => pattern.test(content));
|
||||
}
|
||||
|
||||
// Find a meaningful summary entry - skip boring entries and prefer "AI response" or "Complete"
|
||||
function findSummaryEntry(entries: PRLogEntry[]): { summaryEntry: PRLogEntry | undefined; otherEntries: PRLogEntry[] } {
|
||||
if (entries.length === 0) return { summaryEntry: undefined, otherEntries: [] };
|
||||
|
||||
// Look for the most informative entry to show as summary
|
||||
// Priority: 1) "Complete:" entry, 2) "AI response:" entry, 3) first non-boring entry
|
||||
const completeEntry = entries.find(e => e.content.startsWith('Complete:'));
|
||||
if (completeEntry) {
|
||||
return {
|
||||
summaryEntry: completeEntry,
|
||||
otherEntries: entries.filter(e => e !== completeEntry),
|
||||
};
|
||||
}
|
||||
|
||||
const aiResponseEntry = entries.find(e => e.content.startsWith('AI response:'));
|
||||
if (aiResponseEntry) {
|
||||
return {
|
||||
summaryEntry: aiResponseEntry,
|
||||
otherEntries: entries.filter(e => e !== aiResponseEntry),
|
||||
};
|
||||
}
|
||||
|
||||
// Find first non-boring entry
|
||||
const meaningfulEntry = entries.find(e => !isBoringSummary(e.content));
|
||||
if (meaningfulEntry) {
|
||||
return {
|
||||
summaryEntry: meaningfulEntry,
|
||||
otherEntries: entries.filter(e => e !== meaningfulEntry),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to first entry
|
||||
return {
|
||||
summaryEntry: entries[0],
|
||||
otherEntries: entries.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
|
||||
const { t } = useTranslation(['common']);
|
||||
const { agentName, entries } = group;
|
||||
const hasMultipleEntries = entries.length > 1;
|
||||
const firstEntry = entries[0];
|
||||
const remainingEntries = entries.slice(1);
|
||||
|
||||
// Extract display name from "Agent:logic-reviewer" -> "logic-reviewer"
|
||||
const displayName = agentName.replace('Agent:', '');
|
||||
// Find a meaningful summary entry instead of just using the first one
|
||||
const { summaryEntry, otherEntries } = findSummaryEntry(entries);
|
||||
const hasMoreEntries = otherEntries.length > 0;
|
||||
|
||||
// Extract display name from "Agent:logic-reviewer" -> "logic-reviewer" or "Specialist:security" -> "security"
|
||||
const displayName = agentName.replace('Agent:', '').replace('Specialist:', '');
|
||||
|
||||
const getSourceColor = (source: string) => {
|
||||
return SOURCE_COLORS[source] || SOURCE_COLORS.default;
|
||||
@@ -477,7 +533,7 @@ function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
|
||||
>
|
||||
{displayName}
|
||||
</Badge>
|
||||
{hasMultipleEntries && (
|
||||
{hasMoreEntries && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
@@ -489,28 +545,28 @@ function AgentLogGroup({ group, isExpanded, onToggle }: AgentLogGroupProps) {
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<span>{t('common:prReview.logs.hideMore', { count: remainingEntries.length })}</span>
|
||||
<span>{t('common:prReview.logs.hideMore', { count: otherEntries.length })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{t('common:prReview.logs.showMore', { count: remainingEntries.length })}</span>
|
||||
<span>{t('common:prReview.logs.showMore', { count: otherEntries.length })}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First entry (summary) - always visible */}
|
||||
{firstEntry && (
|
||||
<LogEntry entry={{ ...firstEntry, source: undefined }} />
|
||||
{/* Summary entry - always visible (most informative entry, not necessarily first) */}
|
||||
{summaryEntry && (
|
||||
<LogEntry entry={{ ...summaryEntry, source: undefined }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible section for remaining entries */}
|
||||
{hasMultipleEntries && isExpanded && (
|
||||
{/* Collapsible section for other entries */}
|
||||
{hasMoreEntries && isExpanded && (
|
||||
<div className="border-t border-border/30 bg-secondary/10 p-2 space-y-1">
|
||||
{remainingEntries.map((entry, idx) => (
|
||||
{otherEntries.map((entry, idx) => (
|
||||
<LogEntry key={`${entry.timestamp}-${idx}`} entry={{ ...entry, source: undefined }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface UseGitHubPRsOptions {
|
||||
interface UseGitHubPRsResult {
|
||||
prs: PRData[];
|
||||
isLoading: boolean;
|
||||
isLoadingMore: boolean; // Loading additional PRs via pagination
|
||||
isLoadingPRDetails: boolean; // Loading full PR details including files
|
||||
error: string | null;
|
||||
selectedPR: PRData | null;
|
||||
@@ -38,6 +39,7 @@ interface UseGitHubPRsResult {
|
||||
hasMore: boolean; // True when 100 PRs returned (GitHub limit) - more may exist
|
||||
selectPR: (prNumber: number | null) => void;
|
||||
refresh: () => Promise<void>;
|
||||
loadMore: () => Promise<void>; // Load next page of PRs
|
||||
runReview: (prNumber: number) => void;
|
||||
runFollowupReview: (prNumber: number) => void;
|
||||
checkNewCommits: (prNumber: number) => Promise<NewCommitsCheck>;
|
||||
@@ -76,6 +78,8 @@ export function useGitHubPRs(
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [repoFullName, setRepoFullName] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [endCursor, setEndCursor] = useState<string | null>(null);
|
||||
|
||||
// Track previous isActive state to detect tab navigation
|
||||
const wasActiveRef = useRef(isActive);
|
||||
@@ -85,6 +89,10 @@ export function useGitHubPRs(
|
||||
const currentFetchPRNumberRef = useRef<number | null>(null);
|
||||
// AbortController for cancelling pending checkNewCommits calls on rapid PR switching
|
||||
const checkNewCommitsAbortRef = useRef<AbortController | null>(null);
|
||||
// Track current projectId for staleness checks in async operations
|
||||
const currentProjectIdRef = useRef(projectId);
|
||||
// Counter to detect stale loadMore responses after a refresh
|
||||
const fetchGenerationRef = useRef(0);
|
||||
|
||||
// Get PR review state from the global store
|
||||
const prReviews = usePRReviewStore((state) => state.prReviews);
|
||||
@@ -143,6 +151,9 @@ export function useGitHubPRs(
|
||||
async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
// Increment generation to invalidate any in-flight loadMore requests
|
||||
fetchGenerationRef.current += 1;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -159,6 +170,8 @@ export function useGitHubPRs(
|
||||
if (result) {
|
||||
// Use hasNextPage from API to determine if more PRs exist
|
||||
setHasMore(result.hasNextPage);
|
||||
// Store endCursor for pagination
|
||||
setEndCursor(result.endCursor ?? null);
|
||||
setPrs(result.prs);
|
||||
|
||||
// Batch preload review results for PRs not in store (single IPC call)
|
||||
@@ -223,11 +236,15 @@ export function useGitHubPRs(
|
||||
|
||||
// Reset state and selected PR when project changes
|
||||
useEffect(() => {
|
||||
currentProjectIdRef.current = projectId;
|
||||
fetchGenerationRef.current += 1;
|
||||
hasLoadedRef.current = false;
|
||||
setHasMore(false);
|
||||
setEndCursor(null);
|
||||
setPrs([]);
|
||||
setSelectedPRNumber(null);
|
||||
setSelectedPRDetails(null);
|
||||
setIsLoadingMore(false);
|
||||
currentFetchPRNumberRef.current = null;
|
||||
// Cancel any pending checkNewCommits request
|
||||
if (checkNewCommitsAbortRef.current) {
|
||||
@@ -377,6 +394,91 @@ export function useGitHubPRs(
|
||||
await fetchPRs();
|
||||
}, [fetchPRs]);
|
||||
|
||||
// Load more PRs using cursor-based pagination
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!projectId || !endCursor || !hasMore || isLoadingMore) return;
|
||||
|
||||
// Capture current state for staleness checks
|
||||
const requestProjectId = projectId;
|
||||
const requestGeneration = fetchGenerationRef.current;
|
||||
|
||||
setIsLoadingMore(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.github.listMorePRs(projectId, endCursor);
|
||||
|
||||
// Discard response if project changed or a refresh happened while loading
|
||||
if (
|
||||
requestProjectId !== currentProjectIdRef.current ||
|
||||
requestGeneration !== fetchGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
// Check if this is a failure response (empty result with no next page)
|
||||
// In this case, preserve existing pagination state to allow retry
|
||||
const isFailureResponse = result.prs.length === 0 && !result.hasNextPage && !result.endCursor;
|
||||
|
||||
if (!isFailureResponse) {
|
||||
// Update pagination state only on successful response
|
||||
setHasMore(result.hasNextPage);
|
||||
setEndCursor(result.endCursor ?? null);
|
||||
|
||||
// Append new PRs to existing list, deduplicating by PR number
|
||||
// (handles edge case where PR shifts position between pagination requests)
|
||||
setPrs((prevPrs) => {
|
||||
const existingNumbers = new Set(prevPrs.map((pr) => pr.number));
|
||||
const newPrs = result.prs.filter((pr) => !existingNumbers.has(pr.number));
|
||||
return [...prevPrs, ...newPrs];
|
||||
});
|
||||
}
|
||||
|
||||
// Batch preload review results for new PRs not in store
|
||||
const prsNeedingPreload = result.prs.filter((pr) => {
|
||||
const existingState = getPRReviewState(requestProjectId, pr.number);
|
||||
return !existingState?.result && !existingState?.isReviewing;
|
||||
});
|
||||
|
||||
if (prsNeedingPreload.length > 0) {
|
||||
const prNumbers = prsNeedingPreload.map((pr) => pr.number);
|
||||
const batchReviews = await window.electronAPI.github.getPRReviewsBatch(
|
||||
requestProjectId,
|
||||
prNumbers
|
||||
);
|
||||
|
||||
// Check staleness again after async batch fetch
|
||||
if (
|
||||
requestProjectId !== currentProjectIdRef.current ||
|
||||
requestGeneration !== fetchGenerationRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update store with loaded results
|
||||
for (const reviewResult of Object.values(batchReviews)) {
|
||||
if (reviewResult) {
|
||||
usePRReviewStore.getState().setPRReviewResult(requestProjectId, reviewResult, {
|
||||
preserveNewCommitsCheck: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Only show error if still relevant
|
||||
if (
|
||||
requestProjectId === currentProjectIdRef.current &&
|
||||
requestGeneration === fetchGenerationRef.current
|
||||
) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load more PRs");
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [projectId, endCursor, hasMore, isLoadingMore, getPRReviewState]);
|
||||
|
||||
const runReview = useCallback(
|
||||
(prNumber: number) => {
|
||||
if (!projectId) return;
|
||||
@@ -567,6 +669,7 @@ export function useGitHubPRs(
|
||||
return {
|
||||
prs,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
isLoadingPRDetails,
|
||||
error,
|
||||
selectedPR,
|
||||
@@ -582,6 +685,7 @@ export function useGitHubPRs(
|
||||
hasMore,
|
||||
selectPR,
|
||||
refresh,
|
||||
loadMore,
|
||||
runReview,
|
||||
runFollowupReview,
|
||||
checkNewCommits,
|
||||
|
||||
@@ -16,10 +16,13 @@ export type PRStatusFilter =
|
||||
| 'ready_to_merge'
|
||||
| 'ready_for_followup';
|
||||
|
||||
export type PRSortOption = 'newest' | 'oldest' | 'largest';
|
||||
|
||||
export interface PRFilterState {
|
||||
searchQuery: string;
|
||||
contributors: string[];
|
||||
statuses: PRStatusFilter[];
|
||||
sortBy: PRSortOption;
|
||||
}
|
||||
|
||||
interface PRReviewInfo {
|
||||
@@ -32,6 +35,7 @@ const DEFAULT_FILTERS: PRFilterState = {
|
||||
searchQuery: '',
|
||||
contributors: [],
|
||||
statuses: [],
|
||||
sortBy: 'newest',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,9 +102,9 @@ export function usePRFiltering(
|
||||
);
|
||||
}, [prs]);
|
||||
|
||||
// Filter PRs based on current filters
|
||||
// Filter and sort PRs based on current filters
|
||||
const filteredPRs = useMemo(() => {
|
||||
return prs.filter(pr => {
|
||||
const filtered = prs.filter(pr => {
|
||||
// Search filter - matches title or body
|
||||
if (filters.searchQuery) {
|
||||
const query = filters.searchQuery.toLowerCase();
|
||||
@@ -142,6 +146,36 @@ export function usePRFiltering(
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pre-compute timestamps to avoid creating Date objects on every comparison
|
||||
const timestamps = new Map(
|
||||
filtered.map((pr) => [pr.number, new Date(pr.createdAt).getTime()])
|
||||
);
|
||||
|
||||
// Sort the filtered results
|
||||
return filtered.sort((a, b) => {
|
||||
const aTime = timestamps.get(a.number)!;
|
||||
const bTime = timestamps.get(b.number)!;
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case 'newest':
|
||||
// Sort by createdAt descending (most recent first)
|
||||
return bTime - aTime;
|
||||
case 'oldest':
|
||||
// Sort by createdAt ascending (oldest first)
|
||||
return aTime - bTime;
|
||||
case 'largest': {
|
||||
// Sort by total changes (additions + deletions) descending
|
||||
const aChanges = (a.additions || 0) + (a.deletions || 0);
|
||||
const bChanges = (b.additions || 0) + (b.deletions || 0);
|
||||
if (bChanges !== aChanges) return bChanges - aChanges;
|
||||
// Secondary sort by createdAt (newest first) for stable ordering
|
||||
return bTime - aTime;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [prs, filters, getReviewStateForPR]);
|
||||
|
||||
// Filter setters
|
||||
@@ -157,8 +191,15 @@ export function usePRFiltering(
|
||||
setFiltersState(prev => ({ ...prev, statuses }));
|
||||
}, []);
|
||||
|
||||
const setSortBy = useCallback((sortBy: PRSortOption) => {
|
||||
setFiltersState(prev => ({ ...prev, sortBy }));
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFiltersState(DEFAULT_FILTERS);
|
||||
setFiltersState((prev) => ({
|
||||
...DEFAULT_FILTERS,
|
||||
sortBy: prev.sortBy, // Preserve sort preference when clearing filters
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
@@ -176,6 +217,7 @@ export function usePRFiltering(
|
||||
setSearchQuery,
|
||||
setContributors,
|
||||
setStatuses,
|
||||
setSortBy,
|
||||
clearFilters,
|
||||
hasActiveFilters,
|
||||
};
|
||||
|
||||
+40
-1
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Github, RefreshCw, KeyRound, Info, CheckCircle2 } from 'lucide-react';
|
||||
import { Github, RefreshCw, KeyRound, Info, CheckCircle2, AlertTriangle } from 'lucide-react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
@@ -7,6 +7,7 @@ import { ConnectionStatus } from './ConnectionStatus';
|
||||
import { GitHubOAuthFlow } from './GitHubOAuthFlow';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -214,6 +215,44 @@ export function GitHubIntegrationSection({
|
||||
onCheckedChange={(checked) => onUpdateConfig({ githubAutoSync: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* CI Check Exclusion */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<Label className="text-sm font-medium text-foreground">Excluded CI Checks</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
CI checks listed here will be ignored during PR reviews. Use this for broken or stuck checks
|
||||
that never complete. Enter check names separated by commas.
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder="license/cla, flaky-e2e-test, deprecated-scan"
|
||||
value={(envConfig.githubExcludedCIChecks || []).join(', ')}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const checks = value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
onUpdateConfig({ githubExcludedCIChecks: checks });
|
||||
}}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
{(envConfig.githubExcludedCIChecks?.length ?? 0) > 0 && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-warning">{envConfig.githubExcludedCIChecks?.length}</span> check(s) will be
|
||||
ignored. PRs may be approved even if these checks fail or remain pending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RoadmapTabs({
|
||||
</TabsList>
|
||||
|
||||
{/* Kanban View */}
|
||||
<TabsContent value="kanban" className="flex-1 overflow-hidden">
|
||||
<TabsContent value="kanban" className="flex-1 min-h-0 overflow-hidden">
|
||||
<RoadmapKanbanView
|
||||
key={roadmap.updatedAt?.toString()}
|
||||
roadmap={roadmap}
|
||||
@@ -47,7 +47,7 @@ export function RoadmapTabs({
|
||||
</TabsContent>
|
||||
|
||||
{/* Phases View */}
|
||||
<TabsContent value="phases" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="phases" className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="space-y-6">
|
||||
{roadmap.phases.map((phase: RoadmapPhase, index: number) => (
|
||||
<PhaseCard
|
||||
@@ -64,7 +64,7 @@ export function RoadmapTabs({
|
||||
</TabsContent>
|
||||
|
||||
{/* All Features View */}
|
||||
<TabsContent value="features" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="features" className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="grid gap-3">
|
||||
{roadmap.features.map((feature: RoadmapFeature) => (
|
||||
<FeatureCard
|
||||
@@ -80,7 +80,7 @@ export function RoadmapTabs({
|
||||
</TabsContent>
|
||||
|
||||
{/* By Priority View */}
|
||||
<TabsContent value="priorities" className="flex-1 overflow-auto p-4">
|
||||
<TabsContent value="priorities" className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{['must', 'should', 'could', 'wont'].map((priority: string) => {
|
||||
const features = roadmap.features.filter((f: RoadmapFeature) => f.priority === priority);
|
||||
|
||||
+123
-150
@@ -1,13 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Github, RefreshCw, KeyRound, Loader2, CheckCircle2, AlertCircle, User, Lock, Globe, ChevronDown, GitBranch, AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Label } from '../../ui/label';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { Switch } from '../../ui/switch';
|
||||
import { Separator } from '../../ui/separator';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Combobox } from '../../ui/combobox';
|
||||
import { GitHubOAuthFlow } from '../../project-settings/GitHubOAuthFlow';
|
||||
import { PasswordInput } from '../../project-settings/PasswordInput';
|
||||
import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings } from '../../../../shared/types';
|
||||
import { buildBranchOptions } from '../../../lib/branch-utils';
|
||||
import type { ProjectEnvConfig, GitHubSyncStatus, ProjectSettings, GitBranchDetail } from '../../../../shared/types';
|
||||
|
||||
// Debug logging
|
||||
const DEBUG = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
|
||||
@@ -55,14 +59,15 @@ export function GitHubIntegration({
|
||||
settings,
|
||||
setSettings
|
||||
}: GitHubIntegrationProps) {
|
||||
const { t } = useTranslation(['settings', 'common']);
|
||||
const [authMode, setAuthMode] = useState<'manual' | 'oauth' | 'oauth-success'>('manual');
|
||||
const [oauthUsername, setOauthUsername] = useState<string | null>(null);
|
||||
const [repos, setRepos] = useState<GitHubRepo[]>([]);
|
||||
const [isLoadingRepos, setIsLoadingRepos] = useState(false);
|
||||
const [reposError, setReposError] = useState<string | null>(null);
|
||||
|
||||
// Branch selection state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
// Branch selection state - now uses GitBranchDetail for local/remote distinction
|
||||
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
|
||||
@@ -118,11 +123,11 @@ export function GitHubIntegration({
|
||||
setBranchesError(null);
|
||||
|
||||
try {
|
||||
debugLog('fetchBranches: Calling getGitBranches...');
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
debugLog('fetchBranches: getGitBranches result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error });
|
||||
debugLog('fetchBranches: Calling getGitBranchesWithInfo...');
|
||||
const result = await window.electronAPI.getGitBranchesWithInfo(projectPath);
|
||||
debugLog('fetchBranches: getGitBranchesWithInfo result:', { success: result.success, dataType: typeof result.data, dataLength: Array.isArray(result.data) ? result.data.length : 'N/A', error: result.error });
|
||||
|
||||
// result.data is the array directly (not { branches: [] })
|
||||
// result.data is the GitBranchDetail[] array
|
||||
if (result.success && result.data) {
|
||||
setBranches(result.data);
|
||||
debugLog('fetchBranches: Loaded branches:', result.data.length);
|
||||
@@ -173,6 +178,18 @@ export function GitHubIntegration({
|
||||
}
|
||||
};
|
||||
|
||||
// Build branch options for Combobox using shared utility
|
||||
// Must be called before early return to satisfy React hooks rules
|
||||
const branchOptions = useMemo(() => {
|
||||
return buildBranchOptions(branches, {
|
||||
t,
|
||||
includeAutoDetect: {
|
||||
value: '',
|
||||
label: t('settings:integrations.github.defaultBranch.autoDetect'),
|
||||
},
|
||||
});
|
||||
}, [branches, t]);
|
||||
|
||||
if (!envConfig) {
|
||||
debugLog('No envConfig, returning null');
|
||||
return null;
|
||||
@@ -204,6 +221,9 @@ export function GitHubIntegration({
|
||||
updateEnvConfig({ githubRepo: repoFullName });
|
||||
};
|
||||
|
||||
// Selected branch for Combobox value
|
||||
const selectedBranch = settings?.mainBranch || envConfig?.defaultBranch || '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -336,14 +356,56 @@ export function GitHubIntegration({
|
||||
|
||||
{/* Default Branch Selector */}
|
||||
{projectPath && (
|
||||
<BranchSelector
|
||||
branches={branches}
|
||||
selectedBranch={settings?.mainBranch || envConfig.defaultBranch || ''}
|
||||
isLoading={isLoadingBranches}
|
||||
error={branchesError}
|
||||
onSelect={handleBranchChange}
|
||||
onRefresh={fetchBranches}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-info" />
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
{t('settings:integrations.github.defaultBranch.label')}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
{t('settings:integrations.github.defaultBranch.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchBranches}
|
||||
disabled={isLoadingBranches}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoadingBranches ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{branchesError && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{branchesError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pl-6">
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
value={selectedBranch}
|
||||
onValueChange={handleBranchChange}
|
||||
placeholder={t('settings:integrations.github.defaultBranch.autoDetect')}
|
||||
searchPlaceholder={t('settings:integrations.github.defaultBranch.searchPlaceholder')}
|
||||
emptyMessage={t('settings:integrations.github.defaultBranch.noBranchesFound')}
|
||||
disabled={isLoadingBranches}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
{t('settings:integrations.github.defaultBranch.selectedBranchHelp', { branch: selectedBranch })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
@@ -352,6 +414,14 @@ export function GitHubIntegration({
|
||||
enabled={envConfig.githubAutoSync || false}
|
||||
onToggle={(checked) => updateEnvConfig({ githubAutoSync: checked })}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* CI Check Exclusion */}
|
||||
<ExcludedCIChecks
|
||||
excludedChecks={envConfig.githubExcludedCIChecks || []}
|
||||
onUpdate={(checks) => updateEnvConfig({ githubExcludedCIChecks: checks })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -601,146 +671,49 @@ function AutoSyncToggle({ enabled, onToggle }: AutoSyncToggleProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface BranchSelectorProps {
|
||||
branches: string[];
|
||||
selectedBranch: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onSelect: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
interface ExcludedCIChecksProps {
|
||||
excludedChecks: string[];
|
||||
onUpdate: (checks: string[]) => void;
|
||||
}
|
||||
|
||||
function BranchSelector({
|
||||
branches,
|
||||
selectedBranch,
|
||||
isLoading,
|
||||
error,
|
||||
onSelect,
|
||||
onRefresh
|
||||
}: BranchSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredBranches = branches.filter(branch =>
|
||||
branch.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
function ExcludedCIChecks({ excludedChecks, onUpdate }: ExcludedCIChecksProps) {
|
||||
const { t } = useTranslation(['settings']);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-info" />
|
||||
<Label className="text-sm font-medium text-foreground">Default Branch</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
Base branch for creating task worktrees
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
{t('settings:integrations.github.excludedCIChecks.label')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive pl-6">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative pl-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-input rounded-md bg-background hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading branches...
|
||||
</span>
|
||||
) : selectedBranch ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
{selectedBranch}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Auto-detect (main/master)</span>
|
||||
)}
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && !isLoading && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-64 overflow-hidden">
|
||||
{/* Search filter */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder="Search branches..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto-detect option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect('');
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
!selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-muted-foreground italic">Auto-detect (main/master)</span>
|
||||
</button>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="max-h-40 overflow-y-auto border-t border-border">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
{filter ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(branch);
|
||||
setIsOpen(false);
|
||||
setFilter('');
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-accent flex items-center gap-2 ${
|
||||
branch === selectedBranch ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm">{branch}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings:integrations.github.excludedCIChecks.description')}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={t('settings:integrations.github.excludedCIChecks.placeholder')}
|
||||
value={excludedChecks.join(', ')}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const checks = value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
onUpdate(checks);
|
||||
}}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
{excludedChecks.length > 0 && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings:integrations.github.excludedCIChecks.warningCount', { count: excludedChecks.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedBranch && (
|
||||
<p className="text-xs text-muted-foreground pl-6">
|
||||
All new tasks will branch from <code className="px-1 bg-muted rounded">{selectedBranch}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { cn } from '../../lib/utils';
|
||||
import { calculateProgress } from '../../lib/utils';
|
||||
import { startTask, stopTask, submitReview, recoverStuckTask, deleteTask, useTaskStore } from '../../stores/task-store';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { TASK_STATUS_LABELS } from '../../../shared/constants';
|
||||
import { TaskEditDialog } from '../TaskEditDialog';
|
||||
import { useTaskDetail } from './hooks/useTaskDetail';
|
||||
@@ -80,6 +81,7 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
const { t } = useTranslation(['tasks']);
|
||||
const { toast } = useToast();
|
||||
const state = useTaskDetail({ task });
|
||||
const activeProject = useProjectStore(s => s.getActiveProject());
|
||||
const showFilesTab = isFilesTabEnabled();
|
||||
const progressPercent = calculateProgress(task.subtasks);
|
||||
const completedSubtasks = task.subtasks.filter(s => s.status === 'completed').length;
|
||||
@@ -410,6 +412,8 @@ function TaskDetailModalContent({ open, task, onOpenChange, onSwitchToTerminals,
|
||||
{window.DEBUG && (
|
||||
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
|
||||
status={task.status} reviewReason={task.reviewReason ?? 'none'} phase={task.executionProgress?.phase ?? 'none'} reviewRequired={task.metadata?.requireReviewBeforeCoding ? 'true' : 'false'}
|
||||
<br />
|
||||
projectId={activeProject?.id ?? 'none'} projectName={activeProject?.name ?? 'none'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useRef, useLayoutEffect, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Target,
|
||||
@@ -13,11 +14,14 @@ import {
|
||||
GitPullRequest,
|
||||
ListChecks,
|
||||
Clock,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn, formatRelativeTime } from '../../lib/utils';
|
||||
import {
|
||||
@@ -51,8 +55,15 @@ interface TaskMetadataProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
// Height threshold for collapsing long descriptions (~8 lines)
|
||||
const COLLAPSED_HEIGHT = 200;
|
||||
|
||||
export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
const { t } = useTranslation(['tasks', 'errors']);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [hasOverflow, setHasOverflow] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const contentId = useId();
|
||||
|
||||
// Handle JSON error description with i18n
|
||||
const displayDescription = (() => {
|
||||
@@ -64,6 +75,19 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
return task.description;
|
||||
})();
|
||||
|
||||
// Detect if content overflows the collapsed height
|
||||
// Re-check when description changes (content height depends on rendered description)
|
||||
// Reset expand state when switching tasks to avoid stale expanded state
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: task.description triggers re-render which changes content height
|
||||
useLayoutEffect(() => {
|
||||
setIsExpanded(false);
|
||||
const element = contentRef.current;
|
||||
if (element) {
|
||||
const hasContentOverflow = element.scrollHeight > COLLAPSED_HEIGHT;
|
||||
setHasOverflow(hasContentOverflow);
|
||||
}
|
||||
}, [task.id, task.description]);
|
||||
|
||||
const hasClassification = task.metadata && (
|
||||
task.metadata.category ||
|
||||
task.metadata.priority ||
|
||||
@@ -155,14 +179,53 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
|
||||
{/* Description - Primary Content */}
|
||||
{displayDescription && (
|
||||
<div className="bg-muted/30 rounded-lg px-4 py-3 border border-border/50 overflow-hidden max-w-full">
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none overflow-hidden prose-p:text-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-li:text-foreground/90 prose-ul:my-2 prose-li:my-0.5 prose-a:break-all prose-pre:overflow-x-auto prose-img:max-w-full [&_img]:!max-w-full [&_img]:h-auto [&_code]:break-all [&_code]:whitespace-pre-wrap [&_*]:max-w-full"
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{displayDescription}
|
||||
</ReactMarkdown>
|
||||
{/* Content container with conditional max-height */}
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={contentRef}
|
||||
id={contentId}
|
||||
className={cn(
|
||||
'prose prose-sm dark:prose-invert max-w-none overflow-hidden prose-p:text-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-strong:text-foreground prose-li:text-foreground/90 prose-ul:my-2 prose-li:my-0.5 prose-a:break-all prose-pre:overflow-x-auto prose-img:max-w-full [&_img]:!max-w-full [&_img]:h-auto [&_code]:break-all [&_code]:whitespace-pre-wrap [&_*]:max-w-full',
|
||||
!isExpanded && hasOverflow && 'max-h-[200px]'
|
||||
)}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{displayDescription}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* Gradient overlay when collapsed and has overflow */}
|
||||
{!isExpanded && hasOverflow && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-muted/80 to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse button */}
|
||||
{hasOverflow && (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={contentId}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" aria-hidden="true" />
|
||||
{t('tasks:metadata.showLess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" aria-hidden="true" />
|
||||
{t('tasks:metadata.showMore')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
||||
import type { Task, TerminalWorktreeConfig } from '../../../shared/types';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { buildBranchOptions } from '../../lib/branch-utils';
|
||||
import type { Task, TerminalWorktreeConfig, GitBranchDetail } from '../../../shared/types';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
|
||||
// Special value to represent "use project default" since Radix UI Select doesn't allow empty string values
|
||||
@@ -94,8 +95,8 @@ export function CreateWorktreeDialog({
|
||||
state.projects.find((p) => p.path === projectPath)
|
||||
);
|
||||
|
||||
// Branch selection state
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
// Branch selection state - using structured GitBranchDetail for type indicators
|
||||
const [branches, setBranches] = useState<GitBranchDetail[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [baseBranch, setBaseBranch] = useState<string>(PROJECT_DEFAULT_BRANCH);
|
||||
const [projectDefaultBranch, setProjectDefaultBranch] = useState<string>('');
|
||||
@@ -115,7 +116,8 @@ export function CreateWorktreeDialog({
|
||||
const fetchBranches = async () => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const result = await window.electronAPI.getGitBranches(projectPath);
|
||||
// Use structured branch data with type indicators
|
||||
const result = await window.electronAPI.getGitBranchesWithInfo(projectPath);
|
||||
if (!isMounted) return;
|
||||
|
||||
if (result.success && result.data) {
|
||||
@@ -176,6 +178,13 @@ export function CreateWorktreeDialog({
|
||||
}
|
||||
}, [backlogTasks, name]);
|
||||
|
||||
// Determine if the selected branch is local (for useLocalBranch flag)
|
||||
const isSelectedBranchLocal = useMemo(() => {
|
||||
if (baseBranch === PROJECT_DEFAULT_BRANCH) return false;
|
||||
const selectedGitBranchDetail = branches.find((b) => b.name === baseBranch);
|
||||
return selectedGitBranchDetail?.type === 'local';
|
||||
}, [baseBranch, branches]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
// Final sanitization: trim trailing hyphens/underscores for submission
|
||||
const finalName = sanitizeWorktreeName(name, undefined, true);
|
||||
@@ -204,6 +213,9 @@ export function CreateWorktreeDialog({
|
||||
projectPath,
|
||||
// Only include baseBranch if not using project default
|
||||
baseBranch: baseBranch !== PROJECT_DEFAULT_BRANCH ? baseBranch : undefined,
|
||||
// Set useLocalBranch when user explicitly selects a local branch
|
||||
// This preserves gitignored files (.env, configs) by not switching to origin
|
||||
useLocalBranch: isSelectedBranchLocal,
|
||||
});
|
||||
|
||||
if (result.success && result.config) {
|
||||
@@ -235,26 +247,16 @@ export function CreateWorktreeDialog({
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// Memoized branch options for the Combobox
|
||||
const branchOptions: ComboboxOption[] = useMemo(() => {
|
||||
const regularBranchOptions = branches
|
||||
.filter((b) => b !== projectDefaultBranch)
|
||||
.map((branch) => ({ value: branch, label: branch }));
|
||||
|
||||
const options: ComboboxOption[] = [
|
||||
{
|
||||
// Build branch options using shared utility - groups by local/remote with type indicators
|
||||
const branchOptions = useMemo(() => {
|
||||
return buildBranchOptions(branches, {
|
||||
t,
|
||||
includeProjectDefault: {
|
||||
value: PROJECT_DEFAULT_BRANCH,
|
||||
label: t('terminal:worktree.useProjectDefault', { branch: projectDefaultBranch || 'main' }),
|
||||
branchName: projectDefaultBranch || 'main',
|
||||
labelKey: 'terminal:worktree.useProjectDefault',
|
||||
},
|
||||
...regularBranchOptions,
|
||||
];
|
||||
|
||||
// If the project default branch is not in the list of existing branches, add it as a selectable option
|
||||
if (projectDefaultBranch && !branches.includes(projectDefaultBranch)) {
|
||||
options.push({ value: projectDefaultBranch, label: projectDefaultBranch });
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
}, [branches, projectDefaultBranch, t]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -124,7 +124,7 @@ export function usePtyProcess({
|
||||
|
||||
// Normal skip (not during recreation) - just return
|
||||
if (skipCreation) {
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready`);
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready (skipCreation=true)`);
|
||||
return;
|
||||
}
|
||||
if (isCreatingRef.current || isCreatedRef.current) {
|
||||
@@ -140,7 +140,9 @@ export function usePtyProcess({
|
||||
const alreadyRunning = terminalState?.status === 'running' || terminalState?.status === 'claude-active';
|
||||
const isRestored = terminalState?.isRestored;
|
||||
|
||||
debugLog(`[usePtyProcess] Starting PTY creation for terminal: ${terminalId}, isRestored: ${isRestored}, status: ${terminalState?.status}, cols: ${cols}, rows: ${rows}`);
|
||||
debugLog(`[usePtyProcess] Starting PTY creation for terminal: ${terminalId}`);
|
||||
debugLog(`[usePtyProcess] Terminal ${terminalId} state: isRestored=${isRestored}, status=${terminalState?.status}`);
|
||||
debugLog(`[usePtyProcess] Terminal ${terminalId} dimensions for PTY: cols=${cols}, rows=${rows}`);
|
||||
|
||||
// When recreating (e.g., worktree switching), reset status from 'exited' to 'idle'
|
||||
// This allows proper recreation after deliberate terminal destruction
|
||||
|
||||
@@ -242,7 +242,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
// Call onDimensionsReady once when we have valid dimensions
|
||||
if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) {
|
||||
dimensionsReadyCalledRef.current = true;
|
||||
debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}`);
|
||||
debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}, containerWidth: ${rect.width}, containerHeight: ${rect.height}`);
|
||||
onDimensionsReady?.(cols, rows);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Optional group name for grouping options (e.g., "Local Branches", "Remote Branches") */
|
||||
group?: string;
|
||||
/** Optional icon to display before the label */
|
||||
icon?: React.ReactNode;
|
||||
/** Optional badge to display after the label */
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
@@ -176,8 +182,14 @@ const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn('truncate', !selectedOption && 'text-muted-foreground')}>
|
||||
{displayValue}
|
||||
<span className={cn('flex items-center gap-2 truncate', !selectedOption && 'text-muted-foreground')}>
|
||||
{selectedOption?.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{selectedOption.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{displayValue}</span>
|
||||
{selectedOption?.badge && (
|
||||
<span className="shrink-0">{selectedOption.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -217,36 +229,64 @@ const Combobox = React.forwardRef<HTMLButtonElement, ComboboxProps>(
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option, index) => (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
optionRefs.current.set(index, el);
|
||||
} else {
|
||||
optionRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
id={getOptionId(index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors duration-150',
|
||||
focusedIndex === index && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
{value === option.value && <Check className="h-4 w-4 text-primary" />}
|
||||
</span>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
))
|
||||
filteredOptions.map((option, index) => {
|
||||
// Check if we need to render a group header
|
||||
const prevOption = index > 0 ? filteredOptions[index - 1] : null;
|
||||
const showGroupHeader = option.group && option.group !== prevOption?.group;
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.value}>
|
||||
{/* Group header */}
|
||||
{showGroupHeader && (
|
||||
<div
|
||||
role="presentation"
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs font-semibold text-muted-foreground',
|
||||
index > 0 && 'mt-1 border-t border-border pt-2'
|
||||
)}
|
||||
>
|
||||
{option.group}
|
||||
</div>
|
||||
)}
|
||||
{/* Option item */}
|
||||
<button
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
optionRefs.current.set(index, el);
|
||||
} else {
|
||||
optionRefs.current.delete(index);
|
||||
}
|
||||
}}
|
||||
id={getOptionId(index)}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center',
|
||||
'rounded-md py-2 pl-8 pr-2 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors duration-150',
|
||||
focusedIndex === index && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
{value === option.value && <Check className="h-4 w-4 text-primary" />}
|
||||
</span>
|
||||
<span className="flex flex-1 items-center gap-2 truncate">
|
||||
{option.icon && (
|
||||
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
|
||||
)}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.badge && (
|
||||
<span className="shrink-0">{option.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Shared utilities for branch selection across the application.
|
||||
* Used by TaskCreationWizard, CreateWorktreeDialog, and GitHubIntegration.
|
||||
*/
|
||||
import { GitBranch, Cloud } from 'lucide-react';
|
||||
import type { ComboboxOption } from '../components/ui/combobox';
|
||||
import type { GitBranchDetail } from '../../shared/types';
|
||||
import { cn } from './utils';
|
||||
|
||||
// Badge styling constants for branch type indicators
|
||||
const BADGE_BASE_CLASSES = 'text-xs px-1.5 py-0.5 rounded';
|
||||
const LOCAL_BADGE_CLASSES = 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
const REMOTE_BADGE_CLASSES = 'bg-blue-500/10 text-blue-600 dark:text-blue-400';
|
||||
|
||||
/**
|
||||
* Configuration for building branch options
|
||||
*/
|
||||
export interface BranchOptionsConfig {
|
||||
/** Translation function (must have 'common' namespace loaded for git.branchGroups/branchType) */
|
||||
t: (key: string, options?: Record<string, string>) => string;
|
||||
/** Optional: Include a "use project default" option at the top */
|
||||
includeProjectDefault?: {
|
||||
/** The special value to use for the project default option */
|
||||
value: string;
|
||||
/** The name of the project's default branch (e.g., 'develop') */
|
||||
branchName: string;
|
||||
/** Translation key for the label (will receive { branch } interpolation) */
|
||||
labelKey: string;
|
||||
};
|
||||
/** Optional: Include an "auto-detect" option (used in GitHub settings) */
|
||||
includeAutoDetect?: {
|
||||
/** The value to use for auto-detect (usually empty string) */
|
||||
value: string;
|
||||
/** The label to display */
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds ComboboxOption[] from GitBranchDetail[] with proper grouping, icons, and badges.
|
||||
* This shared function ensures consistent branch display across all branch selectors.
|
||||
*/
|
||||
export function buildBranchOptions(
|
||||
branches: GitBranchDetail[],
|
||||
config: BranchOptionsConfig
|
||||
): ComboboxOption[] {
|
||||
const { t, includeProjectDefault, includeAutoDetect } = config;
|
||||
|
||||
// Separate local and remote branches
|
||||
const localBranches = branches.filter((b) => b.type === 'local');
|
||||
const remoteBranches = branches.filter((b) => b.type === 'remote');
|
||||
|
||||
// Build local branch options
|
||||
const localOptions: ComboboxOption[] = localBranches.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.displayName,
|
||||
group: t('common:git.branchGroups.local'),
|
||||
icon: <GitBranch className="h-3.5 w-3.5" />,
|
||||
badge: (
|
||||
<span className={cn(BADGE_BASE_CLASSES, LOCAL_BADGE_CLASSES)}>
|
||||
{t('common:git.branchType.local')}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Build remote branch options
|
||||
const remoteOptions: ComboboxOption[] = remoteBranches.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.displayName,
|
||||
group: t('common:git.branchGroups.remote'),
|
||||
icon: <Cloud className="h-3.5 w-3.5" />,
|
||||
badge: (
|
||||
<span className={cn(BADGE_BASE_CLASSES, REMOTE_BADGE_CLASSES)}>
|
||||
{t('common:git.branchType.remote')}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Build final options array
|
||||
const options: ComboboxOption[] = [];
|
||||
|
||||
// Add auto-detect option if configured (for GitHub settings)
|
||||
if (includeAutoDetect) {
|
||||
options.push({
|
||||
value: includeAutoDetect.value,
|
||||
label: includeAutoDetect.label,
|
||||
});
|
||||
}
|
||||
|
||||
// Add project default option if configured (for task creation and worktree dialogs)
|
||||
if (includeProjectDefault) {
|
||||
const { value, branchName, labelKey } = includeProjectDefault;
|
||||
|
||||
// Determine if project default branch is local or remote
|
||||
const defaultBranchInfo = branches.find((b) => b.name === branchName);
|
||||
const isDefaultLocal = defaultBranchInfo?.type === 'local';
|
||||
|
||||
options.push({
|
||||
value,
|
||||
label: t(labelKey, { branch: branchName }),
|
||||
icon: isDefaultLocal ? <GitBranch className="h-3.5 w-3.5" /> : <Cloud className="h-3.5 w-3.5" />,
|
||||
badge: defaultBranchInfo ? (
|
||||
<span className={cn(
|
||||
BADGE_BASE_CLASSES,
|
||||
isDefaultLocal ? LOCAL_BADGE_CLASSES : REMOTE_BADGE_CLASSES
|
||||
)}>
|
||||
{isDefaultLocal
|
||||
? t('common:git.branchType.local')
|
||||
: t('common:git.branchType.remote')}
|
||||
</span>
|
||||
) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Add local branches, then remote branches
|
||||
options.push(...localOptions, ...remoteOptions);
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -203,6 +203,7 @@ const browserMockAPI: ElectronAPI = {
|
||||
onAutoFixComplete: () => () => {},
|
||||
onAutoFixError: () => () => {},
|
||||
listPRs: async () => ({ prs: [], hasNextPage: false }),
|
||||
listMorePRs: async () => ({ prs: [], hasNextPage: false }),
|
||||
getPR: async () => null,
|
||||
runPRReview: () => {},
|
||||
cancelPRReview: async () => true,
|
||||
|
||||
@@ -92,6 +92,17 @@ export const projectMock = {
|
||||
data: ['main', 'develop', 'feature/test']
|
||||
}),
|
||||
|
||||
getGitBranchesWithInfo: async () => ({
|
||||
success: true,
|
||||
data: [
|
||||
{ name: 'main', type: 'local' as const, displayName: 'main', isCurrent: true },
|
||||
{ name: 'develop', type: 'local' as const, displayName: 'develop', isCurrent: false },
|
||||
{ name: 'feature/test', type: 'local' as const, displayName: 'feature/test', isCurrent: false },
|
||||
{ name: 'origin/main', type: 'remote' as const, displayName: 'origin/main', isCurrent: false },
|
||||
{ name: 'origin/develop', type: 'remote' as const, displayName: 'origin/develop', isCurrent: false }
|
||||
]
|
||||
}),
|
||||
|
||||
getCurrentGitBranch: async () => ({
|
||||
success: true,
|
||||
data: 'main'
|
||||
|
||||
@@ -17,8 +17,9 @@ export const terminalMock = {
|
||||
console.warn('[Browser Mock] sendTerminalInput called');
|
||||
},
|
||||
|
||||
resizeTerminal: () => {
|
||||
resizeTerminal: async () => {
|
||||
console.warn('[Browser Mock] resizeTerminal called');
|
||||
return { success: true, data: { success: true } };
|
||||
},
|
||||
|
||||
invokeClaudeInTerminal: () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import { DEFAULT_IDEATION_CONFIG } from '../../shared/constants';
|
||||
|
||||
const GENERATION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
/** Maximum number of log entries to retain in memory for debugging */
|
||||
const MAX_LOG_ENTRIES = 500;
|
||||
|
||||
const generationTimeoutIds = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -266,7 +268,7 @@ export const useIdeationStore = create<IdeationState>((set) => ({
|
||||
|
||||
addLog: (log) =>
|
||||
set((state) => ({
|
||||
logs: [...state.logs, log].slice(-100) // Keep last 100 logs
|
||||
logs: [...state.logs, log].slice(-MAX_LOG_ENTRIES)
|
||||
})),
|
||||
|
||||
clearLogs: () => set({ logs: [] }),
|
||||
|
||||
@@ -241,15 +241,15 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
const state = get();
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) {
|
||||
console.warn('[updateTaskStatus] Task not found:', taskId);
|
||||
debugLog('[updateTaskStatus] Task not found:', taskId);
|
||||
return;
|
||||
}
|
||||
const oldTask = state.tasks[index];
|
||||
const oldStatus = oldTask.status;
|
||||
|
||||
// Skip if status is the same
|
||||
if (oldStatus === status) {
|
||||
debugLog('[updateTaskStatus] Status unchanged, skipping:', { taskId, status });
|
||||
// Skip if status AND reviewReason are the same
|
||||
if (oldStatus === status && oldTask.reviewReason === reviewReason) {
|
||||
debugLog('[updateTaskStatus] Status and reviewReason unchanged, skipping:', { taskId, status, reviewReason });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,29 +262,38 @@ export const useTaskStore = create<TaskState>((set, get) => ({
|
||||
|
||||
// Perform the state update
|
||||
set((state) => {
|
||||
const updatedTasks = updateTaskAtIndex(state.tasks, index, (t) => {
|
||||
// Determine execution progress based on status transition
|
||||
let executionProgress = t.executionProgress;
|
||||
return {
|
||||
tasks: updateTaskAtIndex(state.tasks, index, (t) => {
|
||||
// Determine execution progress based on status transition
|
||||
let executionProgress = t.executionProgress;
|
||||
|
||||
if (status === 'backlog') {
|
||||
// When status goes to backlog, reset execution progress to idle
|
||||
// This ensures the planning/coding animation stops when task is stopped
|
||||
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
|
||||
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
|
||||
// When starting a task and no phase is set yet, default to planning
|
||||
// This prevents the "no active phase" UI state during startup race condition
|
||||
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
|
||||
}
|
||||
// Track status transition for debugging flip-flop issues
|
||||
const previousStatus = t.status;
|
||||
const statusChanged = previousStatus !== status;
|
||||
|
||||
return { ...t, status, executionProgress, updatedAt: new Date() };
|
||||
});
|
||||
if (status === 'backlog') {
|
||||
// When status goes to backlog, reset execution progress to idle
|
||||
// This ensures the planning/coding animation stops when task is stopped
|
||||
executionProgress = { phase: 'idle' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
|
||||
} else if (status === 'in_progress' && !t.executionProgress?.phase) {
|
||||
// When starting a task and no phase is set yet, default to planning
|
||||
// This prevents the "no active phase" UI state during startup race condition
|
||||
executionProgress = { phase: 'planning' as ExecutionPhase, phaseProgress: 0, overallProgress: 0 };
|
||||
}
|
||||
|
||||
debugLog('[updateTaskStatus] AFTER set():', {
|
||||
taskId,
|
||||
allInProgress: updatedTasks.filter((t: Task) => t.status === 'in_progress' && !t.metadata?.archivedAt).map(t => t.id)
|
||||
});
|
||||
// Log status transitions to help diagnose flip-flop issues
|
||||
debugLog('[updateTaskStatus] Status transition:', {
|
||||
taskId,
|
||||
previousStatus,
|
||||
newStatus: status,
|
||||
statusChanged,
|
||||
currentPhase: t.executionProgress?.phase,
|
||||
newPhase: executionProgress?.phase
|
||||
});
|
||||
|
||||
return { tasks: updatedTasks };
|
||||
return { ...t, status, reviewReason, executionProgress, updatedAt: new Date() };
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
// Notify listeners after state update (schedule after current tick)
|
||||
@@ -723,17 +732,7 @@ export async function persistTaskStatus(
|
||||
}
|
||||
|
||||
// Only update local state after backend confirms success
|
||||
debugLog(`[persistTaskStatus] BEFORE store.updateTaskStatus:`, {
|
||||
taskId,
|
||||
newStatus: status,
|
||||
currentStoreStatus: store.tasks.find(t => t.id === taskId)?.status
|
||||
});
|
||||
store.updateTaskStatus(taskId, status);
|
||||
debugLog(`[persistTaskStatus] AFTER store.updateTaskStatus:`, {
|
||||
taskId,
|
||||
updatedStoreStatus: store.tasks.find(t => t.id === taskId)?.status,
|
||||
allInProgress: store.tasks.filter(t => t.status === 'in_progress' && !t.metadata?.archivedAt).map(t => t.id)
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error persisting task status:', error);
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UI_SCALE_STEP = 5;
|
||||
// ============================================
|
||||
|
||||
export const DEFAULT_APP_SETTINGS = {
|
||||
theme: 'system' as const,
|
||||
theme: 'dark' as const,
|
||||
colorTheme: 'default' as const,
|
||||
defaultModel: 'opus',
|
||||
agentFramework: 'auto-claude',
|
||||
|
||||
@@ -388,6 +388,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// GitHub PR Review operations
|
||||
GITHUB_PR_LIST: 'github:pr:list',
|
||||
GITHUB_PR_LIST_MORE: 'github:pr:listMore', // Load more PRs (pagination)
|
||||
GITHUB_PR_GET: 'github:pr:get',
|
||||
GITHUB_PR_GET_DIFF: 'github:pr:getDiff',
|
||||
GITHUB_PR_REVIEW: 'github:pr:review',
|
||||
@@ -502,6 +503,7 @@ export const IPC_CHANNELS = {
|
||||
|
||||
// Git operations
|
||||
GIT_GET_BRANCHES: 'git:getBranches',
|
||||
GIT_GET_BRANCHES_WITH_INFO: 'git:getBranchesWithInfo',
|
||||
GIT_GET_CURRENT_BRANCH: 'git:getCurrentBranch',
|
||||
GIT_DETECT_MAIN_BRANCH: 'git:detectMainBranch',
|
||||
GIT_CHECK_STATUS: 'git:checkStatus',
|
||||
|
||||
@@ -243,6 +243,12 @@
|
||||
"selectedCount": "{{count}} selected",
|
||||
"noResultsFound": "No results found",
|
||||
"reset": "Reset",
|
||||
"sort": {
|
||||
"label": "Sort",
|
||||
"newest": "Newest",
|
||||
"oldest": "Oldest",
|
||||
"largest": "Largest"
|
||||
},
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "open",
|
||||
"selectPRToView": "Select a pull request to view details",
|
||||
@@ -361,6 +367,8 @@
|
||||
"branchUpdateFailed": "Failed to update branch",
|
||||
"allPRsLoaded": "All PRs loaded",
|
||||
"maxPRsShown": "Showing first 100 PRs",
|
||||
"loadMore": "Load More",
|
||||
"loadingMore": "Loading...",
|
||||
"workflowsAwaitingApproval": "{{count}} Workflow Awaiting Approval",
|
||||
"workflowsAwaitingApproval_plural": "{{count}} Workflows Awaiting Approval",
|
||||
"blockedByWorkflows": "Blocked",
|
||||
@@ -478,8 +486,7 @@
|
||||
"clickToOpenSettings": "Click to open Settings →",
|
||||
"sessionShort": "5-hour session usage",
|
||||
"weeklyShort": "7-day weekly usage",
|
||||
"swap": "Swap",
|
||||
"account": "Account"
|
||||
"swap": "Swap"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Manual Code Entry (Fallback)",
|
||||
@@ -631,6 +638,16 @@
|
||||
"goToSettings": "Go to Settings"
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
"branchGroups": {
|
||||
"local": "Local Branches",
|
||||
"remote": "Remote Branches"
|
||||
},
|
||||
"branchType": {
|
||||
"local": "Local",
|
||||
"remote": "Remote"
|
||||
}
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Elapsed",
|
||||
"lastActivity": "Last activity",
|
||||
|
||||
@@ -344,7 +344,15 @@
|
||||
"description": "GitHub issues sync",
|
||||
"integrationTitle": "GitHub Integration",
|
||||
"integrationDescription": "Connect to GitHub for issue tracking",
|
||||
"syncDescription": "Sync with GitHub Issues"
|
||||
"syncDescription": "Sync with GitHub Issues",
|
||||
"defaultBranch": {
|
||||
"label": "Default Branch",
|
||||
"description": "Base branch for creating task worktrees",
|
||||
"autoDetect": "Auto-detect (main/master)",
|
||||
"searchPlaceholder": "Search branches...",
|
||||
"noBranchesFound": "No branches found",
|
||||
"selectedBranchHelp": "All new tasks will branch from {{branch}}"
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
"title": "GitLab",
|
||||
@@ -415,6 +423,22 @@
|
||||
"integrations": {
|
||||
"title": "Integrations",
|
||||
"description": "Manage Claude accounts and API keys",
|
||||
"github": {
|
||||
"defaultBranch": {
|
||||
"label": "Default Branch",
|
||||
"description": "Base branch for creating task worktrees",
|
||||
"autoDetect": "Auto-detect (main/master)",
|
||||
"searchPlaceholder": "Search branches...",
|
||||
"noBranchesFound": "No branches found",
|
||||
"selectedBranchHelp": "All new tasks will branch from {{branch}}"
|
||||
},
|
||||
"excludedCIChecks": {
|
||||
"label": "Excluded CI Checks",
|
||||
"description": "CI checks listed here will be ignored during PR reviews. Use this for broken or stuck checks that never complete. Enter check names separated by commas.",
|
||||
"placeholder": "license/cla, flaky-e2e-test, deprecated-scan",
|
||||
"warningCount": "{{count}} check(s) will be ignored. PRs may be approved even if these checks fail or remain pending."
|
||||
}
|
||||
},
|
||||
"claudeAccounts": "Claude Accounts",
|
||||
"claudeAccountsDescription": "Add multiple Claude subscriptions to automatically switch between them when you hit rate limits.",
|
||||
"claudeAccountsWarning": "When authenticating, ensure you're logged into the correct Claude account in your browser. Each profile should use a different subscription.",
|
||||
|
||||
@@ -174,7 +174,9 @@
|
||||
},
|
||||
"metadata": {
|
||||
"severity": "severity",
|
||||
"pullRequest": "Pull Request"
|
||||
"pullRequest": "Pull Request",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"images": {
|
||||
"removeImageAriaLabel": "Remove image {{filename}}",
|
||||
|
||||
@@ -243,6 +243,12 @@
|
||||
"selectedCount": "{{count}} sélectionné(s)",
|
||||
"noResultsFound": "Aucun résultat trouvé",
|
||||
"reset": "Réinitialiser",
|
||||
"sort": {
|
||||
"label": "Trier",
|
||||
"newest": "Plus récent",
|
||||
"oldest": "Plus ancien",
|
||||
"largest": "Plus grand"
|
||||
},
|
||||
"pullRequests": "Pull Requests",
|
||||
"open": "ouvert",
|
||||
"selectPRToView": "Sélectionnez une pull request pour voir les détails",
|
||||
@@ -370,6 +376,8 @@
|
||||
"branchUpdateFailed": "Échec de la mise à jour de la branche",
|
||||
"allPRsLoaded": "Tous les PRs chargés",
|
||||
"maxPRsShown": "Affichage des 100 premières PRs",
|
||||
"loadMore": "Charger plus",
|
||||
"loadingMore": "Chargement...",
|
||||
"workflowsAwaitingApproval": "{{count}} workflow en attente d'approbation",
|
||||
"workflowsAwaitingApproval_plural": "{{count}} workflows en attente d'approbation",
|
||||
"blockedByWorkflows": "Bloqué",
|
||||
@@ -478,8 +486,7 @@
|
||||
"clickToOpenSettings": "Cliquez pour ouvrir les Paramètres →",
|
||||
"sessionShort": "Utilisation session 5 heures",
|
||||
"weeklyShort": "Utilisation hebdomadaire 7 jours",
|
||||
"swap": "Changer",
|
||||
"account": "Compte"
|
||||
"swap": "Changer"
|
||||
},
|
||||
"oauth": {
|
||||
"enterCode": "Saisie manuelle du code (secours)",
|
||||
@@ -631,6 +638,16 @@
|
||||
"goToSettings": "Aller aux paramètres"
|
||||
}
|
||||
},
|
||||
"git": {
|
||||
"branchGroups": {
|
||||
"local": "Branches Locales",
|
||||
"remote": "Branches Distantes"
|
||||
},
|
||||
"branchType": {
|
||||
"local": "Locale",
|
||||
"remote": "Distante"
|
||||
}
|
||||
},
|
||||
"roadmapProgress": {
|
||||
"elapsedTime": "Écoulé",
|
||||
"lastActivity": "Dernière activité",
|
||||
|
||||
@@ -344,7 +344,15 @@
|
||||
"description": "Synchronisation issues GitHub",
|
||||
"integrationTitle": "Intégration GitHub",
|
||||
"integrationDescription": "Se connecter à GitHub pour le suivi des issues",
|
||||
"syncDescription": "Synchroniser avec GitHub Issues"
|
||||
"syncDescription": "Synchroniser avec GitHub Issues",
|
||||
"defaultBranch": {
|
||||
"label": "Branche par défaut",
|
||||
"description": "Branche de base pour créer les worktrees de tâches",
|
||||
"autoDetect": "Détection automatique (main/master)",
|
||||
"searchPlaceholder": "Rechercher des branches...",
|
||||
"noBranchesFound": "Aucune branche trouvée",
|
||||
"selectedBranchHelp": "Toutes les nouvelles tâches partiront de {{branch}}"
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
"title": "GitLab",
|
||||
@@ -415,6 +423,22 @@
|
||||
"integrations": {
|
||||
"title": "Intégrations",
|
||||
"description": "Gérer les comptes Claude et les clés API",
|
||||
"github": {
|
||||
"defaultBranch": {
|
||||
"label": "Branche par défaut",
|
||||
"description": "Branche de base pour créer les worktrees de tâches",
|
||||
"autoDetect": "Détection automatique (main/master)",
|
||||
"searchPlaceholder": "Rechercher des branches...",
|
||||
"noBranchesFound": "Aucune branche trouvée",
|
||||
"selectedBranchHelp": "Toutes les nouvelles tâches partiront de {{branch}}"
|
||||
},
|
||||
"excludedCIChecks": {
|
||||
"label": "Vérifications CI exclues",
|
||||
"description": "Les vérifications CI listées ici seront ignorées lors des revues de PR. Utilisez ceci pour les vérifications cassées ou bloquées qui ne se terminent jamais. Entrez les noms des vérifications séparés par des virgules.",
|
||||
"placeholder": "license/cla, flaky-e2e-test, deprecated-scan",
|
||||
"warningCount": "{{count}} vérification(s) sera(ont) ignorée(s). Les PR peuvent être approuvées même si ces vérifications échouent ou restent en attente."
|
||||
}
|
||||
},
|
||||
"claudeAccounts": "Comptes Claude",
|
||||
"claudeAccountsDescription": "Ajoutez plusieurs abonnements Claude pour basculer automatiquement entre eux quand vous atteignez les limites.",
|
||||
"claudeAccountsWarning": "Lors de l'authentification, assurez-vous d'être connecté au bon compte Claude dans votre navigateur. Chaque profil doit utiliser un abonnement différent.",
|
||||
|
||||
@@ -173,7 +173,9 @@
|
||||
},
|
||||
"metadata": {
|
||||
"severity": "sévérité",
|
||||
"pullRequest": "Pull Request"
|
||||
"pullRequest": "Pull Request",
|
||||
"showMore": "Afficher plus",
|
||||
"showLess": "Afficher moins"
|
||||
},
|
||||
"images": {
|
||||
"removeImageAriaLabel": "Supprimer l'image {{filename}}",
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
export { taskMachine } from './task-machine';
|
||||
export type { TaskContext, TaskEvent } from './task-machine';
|
||||
export type { TaskContext, TaskEvent } from './task-machine';
|
||||
export {
|
||||
TASK_STATE_NAMES,
|
||||
XSTATE_SETTLED_STATES,
|
||||
XSTATE_TO_PHASE,
|
||||
mapStateToLegacy,
|
||||
} from './task-state-utils';
|
||||
export type { TaskStateName } from './task-state-utils';
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Shared XState task state utilities.
|
||||
*
|
||||
* Provides type-safe state names, phase mappings, and legacy status conversion
|
||||
* derived from the task machine definition. Used by task-state-manager and
|
||||
* agent-events-handlers to avoid duplicate constants.
|
||||
*/
|
||||
import type { TaskStatus, ReviewReason, ExecutionPhase } from '../types';
|
||||
|
||||
/**
|
||||
* All XState task state names.
|
||||
*
|
||||
* IMPORTANT: These must match the state keys in task-machine.ts.
|
||||
* If you add/remove a state in the machine, update this array.
|
||||
*/
|
||||
export const TASK_STATE_NAMES = [
|
||||
'backlog', 'planning', 'plan_review', 'coding',
|
||||
'qa_review', 'qa_fixing', 'human_review', 'error',
|
||||
'creating_pr', 'pr_created', 'done'
|
||||
] as const;
|
||||
|
||||
export type TaskStateName = typeof TASK_STATE_NAMES[number];
|
||||
|
||||
/**
|
||||
* XState states where the task has "settled" — the state machine has determined
|
||||
* the task's final or review status. Execution-progress events from the agent
|
||||
* process should NOT overwrite these states, as XState is the source of truth.
|
||||
*
|
||||
* Note: `error` is included because stale execution-progress events (e.g.,
|
||||
* phase='failed') may arrive after XState has already transitioned to error.
|
||||
* When a user resumes from error (USER_RESUMED), XState transitions synchronously
|
||||
* to `coding` before the new agent process emits events, so the guard no longer
|
||||
* blocks — new execution-progress events flow through normally.
|
||||
*/
|
||||
export const XSTATE_SETTLED_STATES: ReadonlySet<string> = new Set<TaskStateName>([
|
||||
'plan_review', 'human_review', 'error', 'creating_pr', 'pr_created', 'done'
|
||||
]);
|
||||
|
||||
/** Maps XState states to execution phases. */
|
||||
export const XSTATE_TO_PHASE: Record<TaskStateName, ExecutionPhase> & Record<string, ExecutionPhase | undefined> = {
|
||||
'backlog': 'idle',
|
||||
'planning': 'planning',
|
||||
'plan_review': 'planning',
|
||||
'coding': 'coding',
|
||||
'qa_review': 'qa_review',
|
||||
'qa_fixing': 'qa_fixing',
|
||||
'human_review': 'complete',
|
||||
'error': 'failed',
|
||||
'creating_pr': 'complete',
|
||||
'pr_created': 'complete',
|
||||
'done': 'complete'
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert XState state to legacy status/reviewReason pair.
|
||||
*
|
||||
* When reviewReason is provided (from XState context), it's used for the
|
||||
* human_review state. Otherwise defaults to 'completed' (used by re-stamp
|
||||
* callers that don't have access to the XState context).
|
||||
*/
|
||||
export function mapStateToLegacy(
|
||||
state: string,
|
||||
reviewReason?: ReviewReason
|
||||
): { status: TaskStatus; reviewReason?: ReviewReason } {
|
||||
switch (state) {
|
||||
case 'backlog':
|
||||
return { status: 'backlog' };
|
||||
case 'planning':
|
||||
case 'coding':
|
||||
return { status: 'in_progress' };
|
||||
case 'plan_review':
|
||||
return { status: 'human_review', reviewReason: 'plan_review' };
|
||||
case 'qa_review':
|
||||
case 'qa_fixing':
|
||||
return { status: 'ai_review' };
|
||||
case 'human_review':
|
||||
return { status: 'human_review', reviewReason: reviewReason ?? 'completed' };
|
||||
case 'error':
|
||||
return { status: 'human_review', reviewReason: 'errors' };
|
||||
case 'creating_pr':
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
case 'pr_created':
|
||||
return { status: 'pr_created' };
|
||||
case 'done':
|
||||
return { status: 'done' };
|
||||
default:
|
||||
return { status: 'backlog' };
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,16 @@ export interface ClaudeProfile {
|
||||
* This is NOT persisted, it's computed dynamically on each getSettings() call.
|
||||
*/
|
||||
isAuthenticated?: boolean;
|
||||
/**
|
||||
* Subscription type from OAuth credentials (e.g., "max" for Claude Max subscription).
|
||||
* Used to display "Max" vs "Pro" in the UI. Populated from Keychain credentials.
|
||||
*/
|
||||
subscriptionType?: string;
|
||||
/**
|
||||
* Rate limit tier from OAuth credentials (e.g., "default_claude_max_20x").
|
||||
* Indicates the user's rate limit tier level. Populated from Keychain credentials.
|
||||
*/
|
||||
rateLimitTier?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -140,6 +140,34 @@ import type {
|
||||
} from './integrations';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile';
|
||||
|
||||
// ============================================
|
||||
// Branch Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Branch type indicator for distinguishing local from remote branches
|
||||
*/
|
||||
export type GitBranchType = 'local' | 'remote';
|
||||
|
||||
/**
|
||||
* Structured branch information for UI display with type indicators
|
||||
* Used in branch selection dropdowns to distinguish local vs remote branches
|
||||
*/
|
||||
export interface GitBranchDetail {
|
||||
/** The branch name (e.g., 'main', 'origin/main') */
|
||||
name: string;
|
||||
/** Whether this is a local or remote branch */
|
||||
type: GitBranchType;
|
||||
/** Display name for UI (e.g., 'main' for local, 'origin/main' for remote) */
|
||||
displayName: string;
|
||||
/** Whether this is the currently checked out branch */
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Electron API
|
||||
// ============================================
|
||||
|
||||
// Electron API exposed via contextBridge
|
||||
// Tab state interface (persisted in main process)
|
||||
export interface TabState {
|
||||
@@ -210,7 +238,7 @@ export interface ElectronAPI {
|
||||
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
|
||||
destroyTerminal: (id: string) => Promise<IPCResult>;
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => Promise<IPCResult<{ success: boolean }>>;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
|
||||
setTerminalTitle: (id: string, title: string) => void;
|
||||
@@ -787,7 +815,10 @@ export interface ElectronAPI {
|
||||
readFile: (filePath: string) => Promise<IPCResult<string>>;
|
||||
|
||||
// Git operations
|
||||
/** @deprecated Will return GitBranchDetail[] in future - see getGitBranchesWithInfo */
|
||||
getGitBranches: (projectPath: string) => Promise<IPCResult<string[]>>;
|
||||
/** Get branches with structured type information (local vs remote) */
|
||||
getGitBranchesWithInfo: (projectPath: string) => Promise<IPCResult<GitBranchDetail[]>>;
|
||||
getCurrentGitBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
|
||||
detectMainBranch: (projectPath: string) => Promise<IPCResult<string | null>>;
|
||||
checkGitStatus: (projectPath: string) => Promise<IPCResult<GitStatus>>;
|
||||
@@ -885,9 +916,18 @@ export interface ElectronAPI {
|
||||
queue: import('../../preload/api/queue-api').QueueAPI;
|
||||
}
|
||||
|
||||
/** Platform information exposed via contextBridge for platform-specific behavior */
|
||||
export interface PlatformInfo {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
isLinux: boolean;
|
||||
isUnix: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
DEBUG: boolean;
|
||||
platform?: PlatformInfo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,7 @@ export interface ProjectEnvConfig {
|
||||
githubRepo?: string; // Format: owner/repo
|
||||
githubAutoSync?: boolean; // Auto-sync issues on project load
|
||||
githubAuthMethod?: 'oauth' | 'pat'; // How the token was obtained
|
||||
githubExcludedCIChecks?: string[]; // CI check names to exclude from blocking (e.g., stuck/broken checks)
|
||||
|
||||
// GitLab Integration
|
||||
gitlabEnabled: boolean;
|
||||
|
||||
@@ -237,6 +237,7 @@ export interface TaskMetadata {
|
||||
baseBranch?: string; // Override base branch for this task's worktree
|
||||
prUrl?: string; // GitHub PR URL if task has been submitted as a PR
|
||||
useWorktree?: boolean; // If false, use direct mode (no worktree isolation) - default is true for safety
|
||||
useLocalBranch?: boolean; // If true, use the local branch directly instead of preferring origin/branch (preserves gitignored files)
|
||||
|
||||
// Archive status
|
||||
archivedAt?: string; // ISO date when task was archived
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user