mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-02 15:23:37 +00:00
feat: add concurrent agents with dependency system and delightful UI
Major feature implementation for parallel agent execution with dependency-aware scheduling and an engaging multi-agent UI experience. Backend Changes: - Add parallel_orchestrator.py for concurrent feature processing - Add api/dependency_resolver.py with cycle detection (Kahn's algorithm + DFS) - Add atomic feature_claim_next() with retry limit and exponential backoff - Fix circular dependency check arguments in 4 locations - Add AgentTracker class for parsing agent output and emitting updates - Add browser isolation with --isolated flag for Playwright MCP - Extend WebSocket protocol with agent_update messages and log attribution - Add WSAgentUpdateMessage schema with agent states and mascot names - Fix WSProgressMessage to include in_progress field New UI Components: - AgentMissionControl: Dashboard showing active agents with collapsible activity - AgentCard: Individual agent status with avatar and thought bubble - AgentAvatar: SVG mascots (Spark, Fizz, Octo, Hoot, Buzz) with animations - ActivityFeed: Recent activity stream with stable keys (no flickering) - CelebrationOverlay: Confetti animation with click/Escape dismiss - DependencyGraph: Interactive node graph visualization with dagre layout - DependencyBadge: Visual indicator for feature dependencies - ViewToggle: Switch between Kanban and Graph views - KeyboardShortcutsHelp: Help overlay accessible via ? key UI/UX Improvements: - Celebration queue system to handle rapid success messages - Accessibility attributes on AgentAvatar (role, aria-label, aria-live) - Collapsible Recent Activity section with persisted preference - Agent count display in header - Keyboard shortcut G to toggle Kanban/Graph view - Real-time thought bubbles and state animations Bug Fixes: - Fix circular dependency validation (swapped source/target arguments) - Add MAX_CLAIM_RETRIES=10 to prevent stack overflow under contention - Fix THOUGHT_PATTERNS to match actual [Tool: name] format - Fix ActivityFeed key prop to prevent re-renders on new items - Add featureId/agentIndex to log messages for proper attribution Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,23 +32,35 @@ Use the feature_create_bulk tool to add all features at once:
|
|||||||
Use the feature_create_bulk tool with features=[
|
Use the feature_create_bulk tool with features=[
|
||||||
{
|
{
|
||||||
"category": "functional",
|
"category": "functional",
|
||||||
"name": "Brief feature name",
|
"name": "User can create an account",
|
||||||
"description": "Brief description of the feature and what this test verifies",
|
"description": "Basic user registration functionality",
|
||||||
"steps": [
|
"steps": [
|
||||||
"Step 1: Navigate to relevant page",
|
"Step 1: Navigate to registration page",
|
||||||
"Step 2: Perform action",
|
"Step 2: Fill in required fields",
|
||||||
"Step 3: Verify expected result"
|
"Step 3: Submit form and verify account created"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "style",
|
"category": "functional",
|
||||||
"name": "Brief feature name",
|
"name": "User can log in",
|
||||||
"description": "Brief description of UI/UX requirement",
|
"description": "Authentication with existing credentials",
|
||||||
"steps": [
|
"steps": [
|
||||||
"Step 1: Navigate to page",
|
"Step 1: Navigate to login page",
|
||||||
"Step 2: Take screenshot",
|
"Step 2: Enter credentials",
|
||||||
"Step 3: Verify visual requirements"
|
"Step 3: Verify successful login and redirect"
|
||||||
]
|
],
|
||||||
|
"depends_on_indices": [0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "functional",
|
||||||
|
"name": "User can view dashboard",
|
||||||
|
"description": "Protected dashboard requires authentication",
|
||||||
|
"steps": [
|
||||||
|
"Step 1: Log in as user",
|
||||||
|
"Step 2: Navigate to dashboard",
|
||||||
|
"Step 3: Verify personalized content displays"
|
||||||
|
],
|
||||||
|
"depends_on_indices": [1]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -57,6 +69,7 @@ Use the feature_create_bulk tool with features=[
|
|||||||
- IDs and priorities are assigned automatically based on order
|
- IDs and priorities are assigned automatically based on order
|
||||||
- All features start with `passes: false` by default
|
- All features start with `passes: false` by default
|
||||||
- You can create features in batches if there are many (e.g., 50 at a time)
|
- You can create features in batches if there are many (e.g., 50 at a time)
|
||||||
|
- Use `depends_on_indices` to specify dependencies (see FEATURE DEPENDENCIES section below)
|
||||||
|
|
||||||
**Requirements for features:**
|
**Requirements for features:**
|
||||||
|
|
||||||
@@ -75,6 +88,86 @@ Use the feature_create_bulk tool with features=[
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## FEATURE DEPENDENCIES
|
||||||
|
|
||||||
|
Dependencies enable **parallel execution** of independent features. When you specify dependencies correctly, multiple agents can work on unrelated features simultaneously, dramatically speeding up development.
|
||||||
|
|
||||||
|
### Why Dependencies Matter
|
||||||
|
|
||||||
|
1. **Parallel Execution**: Features without dependencies can run in parallel
|
||||||
|
2. **Logical Ordering**: Ensures features are built in the right order
|
||||||
|
3. **Blocking Prevention**: An agent won't start a feature until its dependencies pass
|
||||||
|
|
||||||
|
### How to Determine Dependencies
|
||||||
|
|
||||||
|
Ask yourself: "What MUST be working before this feature can be tested?"
|
||||||
|
|
||||||
|
| Dependency Type | Example |
|
||||||
|
|-----------------|---------|
|
||||||
|
| **Data dependencies** | "Edit item" depends on "Create item" |
|
||||||
|
| **Auth dependencies** | "View dashboard" depends on "User can log in" |
|
||||||
|
| **Navigation dependencies** | "Modal close works" depends on "Modal opens" |
|
||||||
|
| **UI dependencies** | "Filter results" depends on "Display results list" |
|
||||||
|
| **API dependencies** | "Fetch user data" depends on "API authentication" |
|
||||||
|
|
||||||
|
### Using `depends_on_indices`
|
||||||
|
|
||||||
|
Since feature IDs aren't assigned until after creation, use **array indices** (0-based) to reference dependencies:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"features": [
|
||||||
|
{ "name": "Create account", ... }, // Index 0
|
||||||
|
{ "name": "Login", "depends_on_indices": [0] }, // Index 1, depends on 0
|
||||||
|
{ "name": "View profile", "depends_on_indices": [1] }, // Index 2, depends on 1
|
||||||
|
{ "name": "Edit profile", "depends_on_indices": [2] } // Index 3, depends on 2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules for Dependencies
|
||||||
|
|
||||||
|
1. **Can only depend on EARLIER features**: Index must be less than current feature's position
|
||||||
|
2. **No circular dependencies**: A cannot depend on B if B depends on A
|
||||||
|
3. **Maximum 20 dependencies** per feature
|
||||||
|
4. **Foundation features have NO dependencies**: First features in each category typically have none
|
||||||
|
5. **Don't over-depend**: Only add dependencies that are truly required for testing
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Start with foundation features** (index 0-10): Core setup, basic navigation, authentication
|
||||||
|
2. **Group related features together**: Keep CRUD operations adjacent
|
||||||
|
3. **Chain complex flows**: Registration → Login → Dashboard → Settings
|
||||||
|
4. **Keep dependencies shallow**: Prefer 1-2 dependencies over deep chains
|
||||||
|
5. **Skip dependencies for independent features**: Visual tests often have no dependencies
|
||||||
|
|
||||||
|
### Example: Todo App Feature Chain
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
// Foundation (no dependencies)
|
||||||
|
{ "name": "App loads without errors", "category": "functional" },
|
||||||
|
{ "name": "Navigation bar displays", "category": "style" },
|
||||||
|
|
||||||
|
// Auth chain
|
||||||
|
{ "name": "User can register", "depends_on_indices": [0] },
|
||||||
|
{ "name": "User can login", "depends_on_indices": [2] },
|
||||||
|
{ "name": "User can logout", "depends_on_indices": [3] },
|
||||||
|
|
||||||
|
// Todo CRUD (depends on auth)
|
||||||
|
{ "name": "User can create todo", "depends_on_indices": [3] },
|
||||||
|
{ "name": "User can view todos", "depends_on_indices": [5] },
|
||||||
|
{ "name": "User can edit todo", "depends_on_indices": [5] },
|
||||||
|
{ "name": "User can delete todo", "depends_on_indices": [5] },
|
||||||
|
|
||||||
|
// Advanced features (multiple dependencies)
|
||||||
|
{ "name": "User can filter todos", "depends_on_indices": [6] },
|
||||||
|
{ "name": "User can search todos", "depends_on_indices": [6] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## MANDATORY TEST CATEGORIES
|
## MANDATORY TEST CATEGORIES
|
||||||
|
|
||||||
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.
|
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,9 +1,13 @@
|
|||||||
# Agent-generated output directories
|
# Agent-generated output directories
|
||||||
generations/
|
generations/
|
||||||
|
automaker/
|
||||||
|
|
||||||
nul
|
nul
|
||||||
issues/
|
issues/
|
||||||
|
|
||||||
|
# Browser profiles for parallel agent execution
|
||||||
|
.browser-profiles/
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
16
agent.py
16
agent.py
@@ -19,8 +19,8 @@ from claude_agent_sdk import ClaudeSDKClient
|
|||||||
# Fix Windows console encoding for Unicode characters (emoji, etc.)
|
# Fix Windows console encoding for Unicode characters (emoji, etc.)
|
||||||
# Without this, print() crashes when Claude outputs emoji like ✅
|
# Without this, print() crashes when Claude outputs emoji like ✅
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||||
|
|
||||||
from client import create_client
|
from client import create_client
|
||||||
from progress import has_features, print_progress_summary, print_session_header
|
from progress import has_features, print_progress_summary, print_session_header
|
||||||
@@ -29,6 +29,7 @@ from prompts import (
|
|||||||
get_coding_prompt,
|
get_coding_prompt,
|
||||||
get_coding_prompt_yolo,
|
get_coding_prompt_yolo,
|
||||||
get_initializer_prompt,
|
get_initializer_prompt,
|
||||||
|
get_single_feature_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
@@ -114,6 +115,7 @@ async def run_autonomous_agent(
|
|||||||
model: str,
|
model: str,
|
||||||
max_iterations: Optional[int] = None,
|
max_iterations: Optional[int] = None,
|
||||||
yolo_mode: bool = False,
|
yolo_mode: bool = False,
|
||||||
|
feature_id: Optional[int] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Run the autonomous agent loop.
|
Run the autonomous agent loop.
|
||||||
@@ -123,6 +125,7 @@ async def run_autonomous_agent(
|
|||||||
model: Claude model to use
|
model: Claude model to use
|
||||||
max_iterations: Maximum number of iterations (None for unlimited)
|
max_iterations: Maximum number of iterations (None for unlimited)
|
||||||
yolo_mode: If True, skip browser testing and use YOLO prompt
|
yolo_mode: If True, skip browser testing and use YOLO prompt
|
||||||
|
feature_id: If set, work only on this specific feature (used by parallel orchestrator)
|
||||||
"""
|
"""
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" AUTONOMOUS CODING AGENT DEMO")
|
print(" AUTONOMOUS CODING AGENT DEMO")
|
||||||
@@ -133,6 +136,8 @@ async def run_autonomous_agent(
|
|||||||
print("Mode: YOLO (testing disabled)")
|
print("Mode: YOLO (testing disabled)")
|
||||||
else:
|
else:
|
||||||
print("Mode: Standard (full testing)")
|
print("Mode: Standard (full testing)")
|
||||||
|
if feature_id:
|
||||||
|
print(f"Single-feature mode: Feature #{feature_id}")
|
||||||
if max_iterations:
|
if max_iterations:
|
||||||
print(f"Max iterations: {max_iterations}")
|
print(f"Max iterations: {max_iterations}")
|
||||||
else:
|
else:
|
||||||
@@ -178,13 +183,18 @@ async def run_autonomous_agent(
|
|||||||
print_session_header(iteration, is_first_run)
|
print_session_header(iteration, is_first_run)
|
||||||
|
|
||||||
# Create client (fresh context)
|
# Create client (fresh context)
|
||||||
client = create_client(project_dir, model, yolo_mode=yolo_mode)
|
# In single-feature mode, pass agent_id for browser isolation
|
||||||
|
agent_id = f"feature-{feature_id}" if feature_id else None
|
||||||
|
client = create_client(project_dir, model, yolo_mode=yolo_mode, agent_id=agent_id)
|
||||||
|
|
||||||
# Choose prompt based on session type
|
# Choose prompt based on session type
|
||||||
# Pass project_dir to enable project-specific prompts
|
# Pass project_dir to enable project-specific prompts
|
||||||
if is_first_run:
|
if is_first_run:
|
||||||
prompt = get_initializer_prompt(project_dir)
|
prompt = get_initializer_prompt(project_dir)
|
||||||
is_first_run = False # Only use initializer once
|
is_first_run = False # Only use initializer once
|
||||||
|
elif feature_id:
|
||||||
|
# Single-feature mode (used by parallel orchestrator)
|
||||||
|
prompt = get_single_feature_prompt(feature_id, project_dir, yolo_mode)
|
||||||
else:
|
else:
|
||||||
# Use YOLO prompt if in YOLO mode
|
# Use YOLO prompt if in YOLO mode
|
||||||
if yolo_mode:
|
if yolo_mode:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ SQLite database schema for feature storage using SQLAlchemy.
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine
|
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine, text
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
@@ -29,6 +29,9 @@ class Feature(Base):
|
|||||||
steps = Column(JSON, nullable=False) # Stored as JSON array
|
steps = Column(JSON, nullable=False) # Stored as JSON array
|
||||||
passes = Column(Boolean, nullable=False, default=False, index=True)
|
passes = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
in_progress = Column(Boolean, nullable=False, default=False, index=True)
|
in_progress = Column(Boolean, nullable=False, default=False, index=True)
|
||||||
|
# Dependencies: list of feature IDs that must be completed before this feature
|
||||||
|
# NULL/empty = no dependencies (backwards compatible)
|
||||||
|
dependencies = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert feature to dictionary for JSON serialization."""
|
"""Convert feature to dictionary for JSON serialization."""
|
||||||
@@ -42,8 +45,18 @@ class Feature(Base):
|
|||||||
# Handle legacy NULL values gracefully - treat as False
|
# Handle legacy NULL values gracefully - treat as False
|
||||||
"passes": self.passes if self.passes is not None else False,
|
"passes": self.passes if self.passes is not None else False,
|
||||||
"in_progress": self.in_progress if self.in_progress is not None else False,
|
"in_progress": self.in_progress if self.in_progress is not None else False,
|
||||||
|
# Dependencies: NULL/empty treated as empty list for backwards compat
|
||||||
|
"dependencies": self.dependencies if self.dependencies else [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_dependencies_safe(self) -> list[int]:
|
||||||
|
"""Safely extract dependencies, handling NULL and malformed data."""
|
||||||
|
if self.dependencies is None:
|
||||||
|
return []
|
||||||
|
if isinstance(self.dependencies, list):
|
||||||
|
return [d for d in self.dependencies if isinstance(d, int)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_database_path(project_dir: Path) -> Path:
|
def get_database_path(project_dir: Path) -> Path:
|
||||||
"""Return the path to the SQLite database for a project."""
|
"""Return the path to the SQLite database for a project."""
|
||||||
@@ -61,8 +74,6 @@ def get_database_url(project_dir: Path) -> str:
|
|||||||
|
|
||||||
def _migrate_add_in_progress_column(engine) -> None:
|
def _migrate_add_in_progress_column(engine) -> None:
|
||||||
"""Add in_progress column to existing databases that don't have it."""
|
"""Add in_progress column to existing databases that don't have it."""
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
# Check if column exists
|
# Check if column exists
|
||||||
result = conn.execute(text("PRAGMA table_info(features)"))
|
result = conn.execute(text("PRAGMA table_info(features)"))
|
||||||
@@ -76,8 +87,6 @@ def _migrate_add_in_progress_column(engine) -> None:
|
|||||||
|
|
||||||
def _migrate_fix_null_boolean_fields(engine) -> None:
|
def _migrate_fix_null_boolean_fields(engine) -> None:
|
||||||
"""Fix NULL values in passes and in_progress columns."""
|
"""Fix NULL values in passes and in_progress columns."""
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
# Fix NULL passes values
|
# Fix NULL passes values
|
||||||
conn.execute(text("UPDATE features SET passes = 0 WHERE passes IS NULL"))
|
conn.execute(text("UPDATE features SET passes = 0 WHERE passes IS NULL"))
|
||||||
@@ -86,6 +95,23 @@ def _migrate_fix_null_boolean_fields(engine) -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_dependencies_column(engine) -> None:
|
||||||
|
"""Add dependencies column to existing databases that don't have it.
|
||||||
|
|
||||||
|
Uses NULL default for backwards compatibility - existing features
|
||||||
|
without dependencies will have NULL which is treated as empty list.
|
||||||
|
"""
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check if column exists
|
||||||
|
result = conn.execute(text("PRAGMA table_info(features)"))
|
||||||
|
columns = [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
if "dependencies" not in columns:
|
||||||
|
# Use TEXT for SQLite JSON storage, NULL default for backwards compat
|
||||||
|
conn.execute(text("ALTER TABLE features ADD COLUMN dependencies TEXT DEFAULT NULL"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def create_database(project_dir: Path) -> tuple:
|
def create_database(project_dir: Path) -> tuple:
|
||||||
"""
|
"""
|
||||||
Create database and return engine + session maker.
|
Create database and return engine + session maker.
|
||||||
@@ -97,12 +123,22 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
Tuple of (engine, SessionLocal)
|
Tuple of (engine, SessionLocal)
|
||||||
"""
|
"""
|
||||||
db_url = get_database_url(project_dir)
|
db_url = get_database_url(project_dir)
|
||||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
engine = create_engine(db_url, connect_args={
|
||||||
|
"check_same_thread": False,
|
||||||
|
"timeout": 30 # Wait up to 30s for locks
|
||||||
|
})
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Enable WAL mode for better concurrent read/write performance
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("PRAGMA journal_mode=WAL"))
|
||||||
|
conn.execute(text("PRAGMA busy_timeout=30000"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# Migrate existing databases
|
# Migrate existing databases
|
||||||
_migrate_add_in_progress_column(engine)
|
_migrate_add_in_progress_column(engine)
|
||||||
_migrate_fix_null_boolean_fields(engine)
|
_migrate_fix_null_boolean_fields(engine)
|
||||||
|
_migrate_add_dependencies_column(engine)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
return engine, SessionLocal
|
return engine, SessionLocal
|
||||||
|
|||||||
341
api/dependency_resolver.py
Normal file
341
api/dependency_resolver.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
"""
|
||||||
|
Dependency Resolver
|
||||||
|
===================
|
||||||
|
|
||||||
|
Provides dependency resolution using Kahn's algorithm for topological sorting.
|
||||||
|
Includes cycle detection, validation, and helper functions for dependency management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
# Security: Prevent DoS via excessive dependencies
|
||||||
|
MAX_DEPENDENCIES_PER_FEATURE = 20
|
||||||
|
MAX_DEPENDENCY_DEPTH = 50 # Prevent stack overflow in cycle detection
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyResult(TypedDict):
|
||||||
|
"""Result from dependency resolution."""
|
||||||
|
|
||||||
|
ordered_features: list[dict]
|
||||||
|
circular_dependencies: list[list[int]]
|
||||||
|
blocked_features: dict[int, list[int]] # feature_id -> [blocking_ids]
|
||||||
|
missing_dependencies: dict[int, list[int]] # feature_id -> [missing_ids]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dependencies(features: list[dict]) -> DependencyResult:
|
||||||
|
"""Topological sort using Kahn's algorithm with priority-aware ordering.
|
||||||
|
|
||||||
|
Returns ordered features respecting dependencies, plus metadata about
|
||||||
|
cycles, blocked features, and missing dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of feature dicts with id, priority, passes, and dependencies fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DependencyResult with ordered_features, circular_dependencies,
|
||||||
|
blocked_features, and missing_dependencies
|
||||||
|
"""
|
||||||
|
feature_map = {f["id"]: f for f in features}
|
||||||
|
in_degree = {f["id"]: 0 for f in features}
|
||||||
|
adjacency: dict[int, list[int]] = {f["id"]: [] for f in features}
|
||||||
|
blocked: dict[int, list[int]] = {}
|
||||||
|
missing: dict[int, list[int]] = {}
|
||||||
|
|
||||||
|
# Build graph
|
||||||
|
for feature in features:
|
||||||
|
deps = feature.get("dependencies") or []
|
||||||
|
for dep_id in deps:
|
||||||
|
if dep_id not in feature_map:
|
||||||
|
missing.setdefault(feature["id"], []).append(dep_id)
|
||||||
|
else:
|
||||||
|
adjacency[dep_id].append(feature["id"])
|
||||||
|
in_degree[feature["id"]] += 1
|
||||||
|
# Track blocked features
|
||||||
|
dep = feature_map[dep_id]
|
||||||
|
if not dep.get("passes"):
|
||||||
|
blocked.setdefault(feature["id"], []).append(dep_id)
|
||||||
|
|
||||||
|
# Kahn's algorithm with priority-aware selection
|
||||||
|
queue = [f for f in features if in_degree[f["id"]] == 0]
|
||||||
|
queue.sort(key=lambda f: (f.get("priority", 999), f["id"]))
|
||||||
|
ordered: list[dict] = []
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
ordered.append(current)
|
||||||
|
for dependent_id in adjacency[current["id"]]:
|
||||||
|
in_degree[dependent_id] -= 1
|
||||||
|
if in_degree[dependent_id] == 0:
|
||||||
|
queue.append(feature_map[dependent_id])
|
||||||
|
queue.sort(key=lambda f: (f.get("priority", 999), f["id"]))
|
||||||
|
|
||||||
|
# Detect cycles (features not in ordered = part of cycle)
|
||||||
|
cycles: list[list[int]] = []
|
||||||
|
if len(ordered) < len(features):
|
||||||
|
remaining = [f for f in features if f not in ordered]
|
||||||
|
cycles = _detect_cycles(remaining, feature_map)
|
||||||
|
ordered.extend(remaining) # Add cyclic features at end
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ordered_features": ordered,
|
||||||
|
"circular_dependencies": cycles,
|
||||||
|
"blocked_features": blocked,
|
||||||
|
"missing_dependencies": missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def are_dependencies_satisfied(feature: dict, all_features: list[dict]) -> bool:
|
||||||
|
"""Check if all dependencies have passes=True.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature: Feature dict to check
|
||||||
|
all_features: List of all feature dicts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if all dependencies are satisfied (or no dependencies)
|
||||||
|
"""
|
||||||
|
deps = feature.get("dependencies") or []
|
||||||
|
if not deps:
|
||||||
|
return True
|
||||||
|
passing_ids = {f["id"] for f in all_features if f.get("passes")}
|
||||||
|
return all(dep_id in passing_ids for dep_id in deps)
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocking_dependencies(feature: dict, all_features: list[dict]) -> list[int]:
|
||||||
|
"""Get list of incomplete dependency IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature: Feature dict to check
|
||||||
|
all_features: List of all feature dicts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of feature IDs that are blocking this feature
|
||||||
|
"""
|
||||||
|
deps = feature.get("dependencies") or []
|
||||||
|
passing_ids = {f["id"] for f in all_features if f.get("passes")}
|
||||||
|
return [dep_id for dep_id in deps if dep_id not in passing_ids]
|
||||||
|
|
||||||
|
|
||||||
|
def would_create_circular_dependency(
|
||||||
|
features: list[dict], source_id: int, target_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""Check if adding a dependency from target to source would create a cycle.
|
||||||
|
|
||||||
|
Uses DFS with visited set for efficient cycle detection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of all feature dicts
|
||||||
|
source_id: The feature that would gain the dependency
|
||||||
|
target_id: The feature that would become a dependency
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if adding the dependency would create a cycle
|
||||||
|
"""
|
||||||
|
if source_id == target_id:
|
||||||
|
return True # Self-reference is a cycle
|
||||||
|
|
||||||
|
feature_map = {f["id"]: f for f in features}
|
||||||
|
source = feature_map.get(source_id)
|
||||||
|
if not source:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if target already depends on source (direct or indirect)
|
||||||
|
target = feature_map.get(target_id)
|
||||||
|
if not target:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# DFS from target to see if we can reach source
|
||||||
|
visited: set[int] = set()
|
||||||
|
|
||||||
|
def can_reach(current_id: int, depth: int = 0) -> bool:
|
||||||
|
# Security: Prevent stack overflow with depth limit
|
||||||
|
if depth > MAX_DEPENDENCY_DEPTH:
|
||||||
|
return True # Assume cycle if too deep (fail-safe)
|
||||||
|
if current_id == source_id:
|
||||||
|
return True
|
||||||
|
if current_id in visited:
|
||||||
|
return False
|
||||||
|
visited.add(current_id)
|
||||||
|
|
||||||
|
current = feature_map.get(current_id)
|
||||||
|
if not current:
|
||||||
|
return False
|
||||||
|
|
||||||
|
deps = current.get("dependencies") or []
|
||||||
|
for dep_id in deps:
|
||||||
|
if can_reach(dep_id, depth + 1):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return can_reach(target_id)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dependencies(
|
||||||
|
feature_id: int, dependency_ids: list[int], all_feature_ids: set[int]
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Validate dependency list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: ID of the feature being validated
|
||||||
|
dependency_ids: List of proposed dependency IDs
|
||||||
|
all_feature_ids: Set of all valid feature IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
# Security: Check limits
|
||||||
|
if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE:
|
||||||
|
return False, f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed"
|
||||||
|
|
||||||
|
# Check self-reference
|
||||||
|
if feature_id in dependency_ids:
|
||||||
|
return False, "A feature cannot depend on itself"
|
||||||
|
|
||||||
|
# Check all dependencies exist
|
||||||
|
missing = [d for d in dependency_ids if d not in all_feature_ids]
|
||||||
|
if missing:
|
||||||
|
return False, f"Dependencies not found: {missing}"
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
if len(dependency_ids) != len(set(dependency_ids)):
|
||||||
|
return False, "Duplicate dependencies not allowed"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]:
|
||||||
|
"""Detect cycles using DFS with recursion tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of features to check for cycles
|
||||||
|
feature_map: Map of feature_id -> feature dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of cycles, where each cycle is a list of feature IDs
|
||||||
|
"""
|
||||||
|
cycles: list[list[int]] = []
|
||||||
|
visited: set[int] = set()
|
||||||
|
rec_stack: set[int] = set()
|
||||||
|
path: list[int] = []
|
||||||
|
|
||||||
|
def dfs(fid: int) -> bool:
|
||||||
|
visited.add(fid)
|
||||||
|
rec_stack.add(fid)
|
||||||
|
path.append(fid)
|
||||||
|
|
||||||
|
feature = feature_map.get(fid)
|
||||||
|
if feature:
|
||||||
|
for dep_id in feature.get("dependencies") or []:
|
||||||
|
if dep_id not in visited:
|
||||||
|
if dfs(dep_id):
|
||||||
|
return True
|
||||||
|
elif dep_id in rec_stack:
|
||||||
|
cycle_start = path.index(dep_id)
|
||||||
|
cycles.append(path[cycle_start:])
|
||||||
|
return True
|
||||||
|
|
||||||
|
path.pop()
|
||||||
|
rec_stack.remove(fid)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for f in features:
|
||||||
|
if f["id"] not in visited:
|
||||||
|
dfs(f["id"])
|
||||||
|
|
||||||
|
return cycles
|
||||||
|
|
||||||
|
|
||||||
|
def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
|
||||||
|
"""Get features that are ready to be worked on.
|
||||||
|
|
||||||
|
A feature is ready if:
|
||||||
|
- It is not passing
|
||||||
|
- It is not in progress
|
||||||
|
- All its dependencies are satisfied
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of all feature dicts
|
||||||
|
limit: Maximum number of features to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ready features, sorted by priority
|
||||||
|
"""
|
||||||
|
passing_ids = {f["id"] for f in features if f.get("passes")}
|
||||||
|
|
||||||
|
ready = []
|
||||||
|
for f in features:
|
||||||
|
if f.get("passes") or f.get("in_progress"):
|
||||||
|
continue
|
||||||
|
deps = f.get("dependencies") or []
|
||||||
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
|
ready.append(f)
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
ready.sort(key=lambda f: (f.get("priority", 999), f["id"]))
|
||||||
|
|
||||||
|
return ready[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def get_blocked_features(features: list[dict]) -> list[dict]:
|
||||||
|
"""Get features that are blocked by unmet dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of all feature dicts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of blocked features with 'blocked_by' field added
|
||||||
|
"""
|
||||||
|
passing_ids = {f["id"] for f in features if f.get("passes")}
|
||||||
|
|
||||||
|
blocked = []
|
||||||
|
for f in features:
|
||||||
|
if f.get("passes"):
|
||||||
|
continue
|
||||||
|
deps = f.get("dependencies") or []
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
if blocking:
|
||||||
|
blocked.append({**f, "blocked_by": blocking})
|
||||||
|
|
||||||
|
return blocked
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph_data(features: list[dict]) -> dict:
|
||||||
|
"""Build graph data structure for visualization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of all feature dicts
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'nodes' and 'edges' for graph visualization
|
||||||
|
"""
|
||||||
|
passing_ids = {f["id"] for f in features if f.get("passes")}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
|
||||||
|
for f in features:
|
||||||
|
deps = f.get("dependencies") or []
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
|
||||||
|
if f.get("passes"):
|
||||||
|
status = "done"
|
||||||
|
elif blocking:
|
||||||
|
status = "blocked"
|
||||||
|
elif f.get("in_progress"):
|
||||||
|
status = "in_progress"
|
||||||
|
else:
|
||||||
|
status = "pending"
|
||||||
|
|
||||||
|
nodes.append({
|
||||||
|
"id": f["id"],
|
||||||
|
"name": f["name"],
|
||||||
|
"category": f["category"],
|
||||||
|
"status": status,
|
||||||
|
"priority": f.get("priority", 999),
|
||||||
|
"dependencies": deps,
|
||||||
|
})
|
||||||
|
|
||||||
|
for dep_id in deps:
|
||||||
|
edges.append({"source": dep_id, "target": f["id"]})
|
||||||
|
|
||||||
|
return {"nodes": nodes, "edges": edges}
|
||||||
@@ -19,6 +19,12 @@ Example Usage:
|
|||||||
|
|
||||||
# YOLO mode: rapid prototyping without browser testing
|
# YOLO mode: rapid prototyping without browser testing
|
||||||
python autonomous_agent_demo.py --project-dir my-app --yolo
|
python autonomous_agent_demo.py --project-dir my-app --yolo
|
||||||
|
|
||||||
|
# Parallel execution with 3 concurrent agents (default)
|
||||||
|
python autonomous_agent_demo.py --project-dir my-app --parallel
|
||||||
|
|
||||||
|
# Parallel execution with 5 concurrent agents
|
||||||
|
python autonomous_agent_demo.py --project-dir my-app --parallel 5
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -91,6 +97,24 @@ Authentication:
|
|||||||
help="Enable YOLO mode: rapid prototyping without browser testing",
|
help="Enable YOLO mode: rapid prototyping without browser testing",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--parallel",
|
||||||
|
"-p",
|
||||||
|
type=int,
|
||||||
|
nargs="?",
|
||||||
|
const=3,
|
||||||
|
default=None,
|
||||||
|
metavar="N",
|
||||||
|
help="Enable parallel execution with N concurrent agents (default: 3, max: 5)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--feature-id",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Work on a specific feature ID only (used by parallel orchestrator)",
|
||||||
|
)
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
@@ -123,15 +147,30 @@ def main() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the agent (MCP server handles feature database)
|
if args.parallel is not None:
|
||||||
asyncio.run(
|
# Parallel execution mode
|
||||||
run_autonomous_agent(
|
from parallel_orchestrator import run_parallel_orchestrator
|
||||||
project_dir=project_dir,
|
|
||||||
model=args.model,
|
print(f"Running in parallel mode with {args.parallel} concurrent agents")
|
||||||
max_iterations=args.max_iterations,
|
asyncio.run(
|
||||||
yolo_mode=args.yolo,
|
run_parallel_orchestrator(
|
||||||
|
project_dir=project_dir,
|
||||||
|
max_concurrency=args.parallel,
|
||||||
|
model=args.model,
|
||||||
|
yolo_mode=args.yolo,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Standard single-agent mode (MCP server handles feature database)
|
||||||
|
asyncio.run(
|
||||||
|
run_autonomous_agent(
|
||||||
|
project_dir=project_dir,
|
||||||
|
model=args.model,
|
||||||
|
max_iterations=args.max_iterations,
|
||||||
|
yolo_mode=args.yolo,
|
||||||
|
feature_id=args.feature_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\nInterrupted by user")
|
print("\n\nInterrupted by user")
|
||||||
print("To resume, run the same command again")
|
print("To resume, run the same command again")
|
||||||
|
|||||||
31
client.py
31
client.py
@@ -52,13 +52,25 @@ def get_playwright_headless() -> bool:
|
|||||||
|
|
||||||
# Feature MCP tools for feature/test management
|
# Feature MCP tools for feature/test management
|
||||||
FEATURE_MCP_TOOLS = [
|
FEATURE_MCP_TOOLS = [
|
||||||
|
# Core feature operations
|
||||||
"mcp__features__feature_get_stats",
|
"mcp__features__feature_get_stats",
|
||||||
"mcp__features__feature_get_next",
|
"mcp__features__feature_get_next",
|
||||||
|
"mcp__features__feature_claim_next", # Atomic get+claim for parallel execution
|
||||||
"mcp__features__feature_get_for_regression",
|
"mcp__features__feature_get_for_regression",
|
||||||
"mcp__features__feature_mark_in_progress",
|
"mcp__features__feature_mark_in_progress",
|
||||||
"mcp__features__feature_mark_passing",
|
"mcp__features__feature_mark_passing",
|
||||||
"mcp__features__feature_skip",
|
"mcp__features__feature_skip",
|
||||||
"mcp__features__feature_create_bulk",
|
"mcp__features__feature_create_bulk",
|
||||||
|
"mcp__features__feature_create",
|
||||||
|
"mcp__features__feature_clear_in_progress",
|
||||||
|
# Dependency management
|
||||||
|
"mcp__features__feature_add_dependency",
|
||||||
|
"mcp__features__feature_remove_dependency",
|
||||||
|
"mcp__features__feature_set_dependencies",
|
||||||
|
# Parallel execution support
|
||||||
|
"mcp__features__feature_get_ready",
|
||||||
|
"mcp__features__feature_get_blocked",
|
||||||
|
"mcp__features__feature_get_graph",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Playwright MCP tools for browser automation
|
# Playwright MCP tools for browser automation
|
||||||
@@ -107,7 +119,12 @@ BUILTIN_TOOLS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
|
def create_client(
|
||||||
|
project_dir: Path,
|
||||||
|
model: str,
|
||||||
|
yolo_mode: bool = False,
|
||||||
|
agent_id: str | None = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Create a Claude Agent SDK client with multi-layered security.
|
Create a Claude Agent SDK client with multi-layered security.
|
||||||
|
|
||||||
@@ -115,6 +132,8 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
|
|||||||
project_dir: Directory for the project
|
project_dir: Directory for the project
|
||||||
model: Claude model to use
|
model: Claude model to use
|
||||||
yolo_mode: If True, skip Playwright MCP server for rapid prototyping
|
yolo_mode: If True, skip Playwright MCP server for rapid prototyping
|
||||||
|
agent_id: Optional unique identifier for browser isolation in parallel mode.
|
||||||
|
When provided, each agent gets its own browser profile.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured ClaudeSDKClient (from claude_agent_sdk)
|
Configured ClaudeSDKClient (from claude_agent_sdk)
|
||||||
@@ -211,6 +230,16 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
|
|||||||
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"]
|
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"]
|
||||||
if get_playwright_headless():
|
if get_playwright_headless():
|
||||||
playwright_args.append("--headless")
|
playwright_args.append("--headless")
|
||||||
|
|
||||||
|
# Browser isolation for parallel execution
|
||||||
|
# Each agent gets its own isolated browser context to prevent tab conflicts
|
||||||
|
if agent_id:
|
||||||
|
# Use --isolated for ephemeral browser context
|
||||||
|
# This creates a fresh, isolated context without persistent state
|
||||||
|
# Note: --isolated and --user-data-dir are mutually exclusive
|
||||||
|
playwright_args.append("--isolated")
|
||||||
|
print(f" - Browser isolation enabled for agent: {agent_id}")
|
||||||
|
|
||||||
mcp_servers["playwright"] = {
|
mcp_servers["playwright"] = {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": playwright_args,
|
"args": playwright_args,
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time as _time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.sql.expression import func
|
from sqlalchemy.sql.expression import func
|
||||||
|
|
||||||
# Add parent directory to path so we can import from api module
|
# Add parent directory to path so we can import from api module
|
||||||
@@ -35,6 +37,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||||||
|
|
||||||
from api.database import Feature, create_database
|
from api.database import Feature, create_database
|
||||||
from api.migration import migrate_json_to_sqlite
|
from api.migration import migrate_json_to_sqlite
|
||||||
|
from api.dependency_resolver import (
|
||||||
|
would_create_circular_dependency,
|
||||||
|
are_dependencies_satisfied,
|
||||||
|
get_blocking_dependencies,
|
||||||
|
MAX_DEPENDENCIES_PER_FEATURE,
|
||||||
|
)
|
||||||
|
|
||||||
# Configuration from environment
|
# Configuration from environment
|
||||||
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()
|
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()
|
||||||
@@ -148,32 +156,192 @@ def feature_get_stats() -> str:
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def feature_get_next() -> str:
|
def feature_get_next() -> str:
|
||||||
"""Get the highest-priority pending feature to work on.
|
"""Get the highest-priority pending feature that has all dependencies satisfied.
|
||||||
|
|
||||||
Returns the feature with the lowest priority number that has passes=false.
|
Returns the feature with the lowest priority number that:
|
||||||
Use this at the start of each coding session to determine what to implement next.
|
1. Has passes=false and in_progress=false
|
||||||
|
2. Has all dependency features already passing (or no dependencies)
|
||||||
|
3. All dependency IDs actually exist (orphaned dependencies are ignored)
|
||||||
|
|
||||||
|
For backwards compatibility: if all pending features are blocked by dependencies,
|
||||||
|
falls back to returning the first pending feature (same as before dependencies).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
|
JSON with feature details (id, priority, category, name, description, steps, passes,
|
||||||
or error message if all features are passing.
|
in_progress, dependencies) or error message if all features are passing.
|
||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
feature = (
|
all_features = session.query(Feature).all()
|
||||||
session.query(Feature)
|
all_feature_ids = {f.id for f in all_features}
|
||||||
.filter(Feature.passes == False)
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
.order_by(Feature.priority.asc(), Feature.id.asc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if feature is None:
|
# Get pending, non-in-progress features
|
||||||
|
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
||||||
|
pending.sort(key=lambda f: (f.priority, f.id))
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
if any(f.in_progress for f in all_features if not f.passes):
|
||||||
|
return json.dumps({"error": "All pending features are in progress by other agents"})
|
||||||
return json.dumps({"error": "All features are passing! No more work to do."})
|
return json.dumps({"error": "All features are passing! No more work to do."})
|
||||||
|
|
||||||
return json.dumps(feature.to_dict(), indent=2)
|
# Find first feature with satisfied dependencies
|
||||||
|
for feature in pending:
|
||||||
|
deps = feature.dependencies or []
|
||||||
|
# Filter out orphaned dependencies (IDs that no longer exist)
|
||||||
|
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||||
|
if all(dep_id in passing_ids for dep_id in valid_deps):
|
||||||
|
return json.dumps(feature.to_dict(), indent=2)
|
||||||
|
|
||||||
|
# All pending features are blocked by unmet dependencies
|
||||||
|
# Return error with details about what's blocking progress
|
||||||
|
blocking_info = []
|
||||||
|
for feature in pending[:3]: # Show first 3 blocked features
|
||||||
|
deps = feature.dependencies or []
|
||||||
|
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||||
|
orphaned = [d for d in deps if d not in all_feature_ids]
|
||||||
|
unmet = [d for d in valid_deps if d not in passing_ids]
|
||||||
|
info = f"#{feature.id} '{feature.name}'"
|
||||||
|
if unmet:
|
||||||
|
info += f" blocked by: {unmet}"
|
||||||
|
if orphaned:
|
||||||
|
info += f" (orphaned deps ignored: {orphaned})"
|
||||||
|
blocking_info.append(info)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"error": "All pending features are blocked by unmet dependencies",
|
||||||
|
"blocked_features": len(pending),
|
||||||
|
"examples": blocking_info,
|
||||||
|
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
|
||||||
|
}, indent=2)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Maximum retry attempts for feature claiming under contention
|
||||||
|
MAX_CLAIM_RETRIES = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _feature_claim_next_internal(attempt: int = 0) -> str:
|
||||||
|
"""Internal implementation of feature claiming with retry tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
attempt: Current retry attempt (0-indexed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with claimed feature details, or error message if no feature available.
|
||||||
|
"""
|
||||||
|
if attempt >= MAX_CLAIM_RETRIES:
|
||||||
|
return json.dumps({
|
||||||
|
"error": "Failed to claim feature after maximum retries",
|
||||||
|
"hint": "High contention detected - try again or reduce parallel agents"
|
||||||
|
})
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Use a lock to prevent concurrent claims within this process
|
||||||
|
with _priority_lock:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
all_feature_ids = {f.id for f in all_features}
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
# Get pending, non-in-progress features
|
||||||
|
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
||||||
|
pending.sort(key=lambda f: (f.priority, f.id))
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
if any(f.in_progress for f in all_features if not f.passes):
|
||||||
|
return json.dumps({"error": "All pending features are in progress by other agents"})
|
||||||
|
return json.dumps({"error": "All features are passing! No more work to do."})
|
||||||
|
|
||||||
|
# Find first feature with satisfied dependencies
|
||||||
|
candidate_id = None
|
||||||
|
for feature in pending:
|
||||||
|
deps = feature.dependencies or []
|
||||||
|
# Filter out orphaned dependencies (IDs that no longer exist)
|
||||||
|
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||||
|
if all(dep_id in passing_ids for dep_id in valid_deps):
|
||||||
|
candidate_id = feature.id
|
||||||
|
break
|
||||||
|
|
||||||
|
if candidate_id is None:
|
||||||
|
# All pending features are blocked by unmet dependencies
|
||||||
|
blocking_info = []
|
||||||
|
for feature in pending[:3]:
|
||||||
|
deps = feature.dependencies or []
|
||||||
|
valid_deps = [d for d in deps if d in all_feature_ids]
|
||||||
|
orphaned = [d for d in deps if d not in all_feature_ids]
|
||||||
|
unmet = [d for d in valid_deps if d not in passing_ids]
|
||||||
|
info = f"#{feature.id} '{feature.name}'"
|
||||||
|
if unmet:
|
||||||
|
info += f" blocked by: {unmet}"
|
||||||
|
if orphaned:
|
||||||
|
info += f" (orphaned deps ignored: {orphaned})"
|
||||||
|
blocking_info.append(info)
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"error": "All pending features are blocked by unmet dependencies",
|
||||||
|
"blocked_features": len(pending),
|
||||||
|
"examples": blocking_info,
|
||||||
|
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
|
||||||
|
}, indent=2)
|
||||||
|
|
||||||
|
# Atomic claim: UPDATE only if still claimable
|
||||||
|
# This prevents race conditions even across processes
|
||||||
|
result = session.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE features
|
||||||
|
SET in_progress = 1
|
||||||
|
WHERE id = :feature_id
|
||||||
|
AND in_progress = 0
|
||||||
|
AND passes = 0
|
||||||
|
"""),
|
||||||
|
{"feature_id": candidate_id}
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Check if we actually claimed it
|
||||||
|
if result.rowcount == 0:
|
||||||
|
# Another process claimed it first - retry with backoff
|
||||||
|
session.close()
|
||||||
|
# Exponential backoff: 0.1s, 0.2s, 0.4s, ... up to 1.0s
|
||||||
|
backoff = min(0.1 * (2 ** attempt), 1.0)
|
||||||
|
_time.sleep(backoff)
|
||||||
|
return _feature_claim_next_internal(attempt + 1)
|
||||||
|
|
||||||
|
# Fetch the claimed feature
|
||||||
|
session.expire_all() # Clear cache to get fresh data
|
||||||
|
claimed_feature = session.query(Feature).filter(Feature.id == candidate_id).first()
|
||||||
|
return json.dumps(claimed_feature.to_dict(), indent=2)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
return json.dumps({"error": f"Failed to claim feature: {str(e)}"})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_claim_next() -> str:
|
||||||
|
"""Atomically get and claim the next available feature.
|
||||||
|
|
||||||
|
This combines feature_get_next() and feature_mark_in_progress() in a single
|
||||||
|
atomic operation, preventing race conditions where two agents could claim
|
||||||
|
the same feature.
|
||||||
|
|
||||||
|
Returns the feature with the lowest priority number that:
|
||||||
|
1. Has passes=false and in_progress=false
|
||||||
|
2. Has all dependency features already passing (or no dependencies)
|
||||||
|
3. All dependency IDs actually exist (orphaned dependencies are ignored)
|
||||||
|
|
||||||
|
On success, the feature's in_progress flag is set to True.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with claimed feature details, or error message if no feature available.
|
||||||
|
"""
|
||||||
|
return _feature_claim_next_internal(attempt=0)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def feature_get_for_regression(
|
def feature_get_for_regression(
|
||||||
limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
|
limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
|
||||||
@@ -382,9 +550,13 @@ def feature_create_bulk(
|
|||||||
- name (str): Feature name
|
- name (str): Feature name
|
||||||
- description (str): Detailed description
|
- description (str): Detailed description
|
||||||
- steps (list[str]): Implementation/test steps
|
- steps (list[str]): Implementation/test steps
|
||||||
|
- depends_on_indices (list[int], optional): Array indices (0-based) of
|
||||||
|
features in THIS batch that this feature depends on. Use this instead
|
||||||
|
of 'dependencies' since IDs aren't known until after creation.
|
||||||
|
Example: [0, 2] means this feature depends on features at index 0 and 2.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with: created (int) - number of features created
|
JSON with: created (int) - number of features created, with_dependencies (int)
|
||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
try:
|
||||||
@@ -394,7 +566,7 @@ def feature_create_bulk(
|
|||||||
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
|
||||||
start_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
start_priority = (max_priority_result[0] + 1) if max_priority_result else 1
|
||||||
|
|
||||||
created_count = 0
|
# First pass: validate all features and their index-based dependencies
|
||||||
for i, feature_data in enumerate(features):
|
for i, feature_data in enumerate(features):
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
|
if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
|
||||||
@@ -402,6 +574,33 @@ def feature_create_bulk(
|
|||||||
"error": f"Feature at index {i} missing required fields (category, name, description, steps)"
|
"error": f"Feature at index {i} missing required fields (category, name, description, steps)"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Validate depends_on_indices
|
||||||
|
indices = feature_data.get("depends_on_indices", [])
|
||||||
|
if indices:
|
||||||
|
# Check max dependencies
|
||||||
|
if len(indices) > MAX_DEPENDENCIES_PER_FEATURE:
|
||||||
|
return json.dumps({
|
||||||
|
"error": f"Feature at index {i} has {len(indices)} dependencies, max is {MAX_DEPENDENCIES_PER_FEATURE}"
|
||||||
|
})
|
||||||
|
# Check for duplicates
|
||||||
|
if len(indices) != len(set(indices)):
|
||||||
|
return json.dumps({
|
||||||
|
"error": f"Feature at index {i} has duplicate dependencies"
|
||||||
|
})
|
||||||
|
# Check for forward references (can only depend on earlier features)
|
||||||
|
for idx in indices:
|
||||||
|
if not isinstance(idx, int) or idx < 0:
|
||||||
|
return json.dumps({
|
||||||
|
"error": f"Feature at index {i} has invalid dependency index: {idx}"
|
||||||
|
})
|
||||||
|
if idx >= i:
|
||||||
|
return json.dumps({
|
||||||
|
"error": f"Feature at index {i} cannot depend on feature at index {idx} (forward reference not allowed)"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Second pass: create all features
|
||||||
|
created_features: list[Feature] = []
|
||||||
|
for i, feature_data in enumerate(features):
|
||||||
db_feature = Feature(
|
db_feature = Feature(
|
||||||
priority=start_priority + i,
|
priority=start_priority + i,
|
||||||
category=feature_data["category"],
|
category=feature_data["category"],
|
||||||
@@ -412,11 +611,27 @@ def feature_create_bulk(
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
)
|
)
|
||||||
session.add(db_feature)
|
session.add(db_feature)
|
||||||
created_count += 1
|
created_features.append(db_feature)
|
||||||
|
|
||||||
|
# Flush to get IDs assigned
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
# Third pass: resolve index-based dependencies to actual IDs
|
||||||
|
deps_count = 0
|
||||||
|
for i, feature_data in enumerate(features):
|
||||||
|
indices = feature_data.get("depends_on_indices", [])
|
||||||
|
if indices:
|
||||||
|
# Convert indices to actual feature IDs
|
||||||
|
dep_ids = [created_features[idx].id for idx in indices]
|
||||||
|
created_features[i].dependencies = sorted(dep_ids)
|
||||||
|
deps_count += 1
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return json.dumps({"created": created_count}, indent=2)
|
return json.dumps({
|
||||||
|
"created": len(created_features),
|
||||||
|
"with_dependencies": deps_count
|
||||||
|
}, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return json.dumps({"error": str(e)})
|
return json.dumps({"error": str(e)})
|
||||||
@@ -479,5 +694,298 @@ def feature_create(
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_add_dependency(
|
||||||
|
feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")],
|
||||||
|
dependency_id: Annotated[int, Field(ge=1, description="ID of the dependency feature")]
|
||||||
|
) -> str:
|
||||||
|
"""Add a dependency relationship between features.
|
||||||
|
|
||||||
|
The dependency_id feature must be completed before feature_id can be started.
|
||||||
|
Validates: self-reference, existence, circular dependencies, max limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The ID of the feature that will depend on another feature
|
||||||
|
dependency_id: The ID of the feature that must be completed first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success status and updated dependencies list, or error message
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Security: Self-reference check
|
||||||
|
if feature_id == dependency_id:
|
||||||
|
return json.dumps({"error": "A feature cannot depend on itself"})
|
||||||
|
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
dependency = session.query(Feature).filter(Feature.id == dependency_id).first()
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
||||||
|
if not dependency:
|
||||||
|
return json.dumps({"error": f"Dependency feature {dependency_id} not found"})
|
||||||
|
|
||||||
|
current_deps = feature.dependencies or []
|
||||||
|
|
||||||
|
# Security: Max dependencies limit
|
||||||
|
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
|
||||||
|
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed per feature"})
|
||||||
|
|
||||||
|
# Check if already exists
|
||||||
|
if dependency_id in current_deps:
|
||||||
|
return json.dumps({"error": "Dependency already exists"})
|
||||||
|
|
||||||
|
# Security: Circular dependency check
|
||||||
|
# would_create_circular_dependency(features, source_id, target_id)
|
||||||
|
# source_id = feature gaining the dependency, target_id = feature being depended upon
|
||||||
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
||||||
|
if would_create_circular_dependency(all_features, feature_id, dependency_id):
|
||||||
|
return json.dumps({"error": "Cannot add: would create circular dependency"})
|
||||||
|
|
||||||
|
# Add dependency
|
||||||
|
current_deps.append(dependency_id)
|
||||||
|
feature.dependencies = sorted(current_deps)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
"dependencies": feature.dependencies
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_remove_dependency(
|
||||||
|
feature_id: Annotated[int, Field(ge=1, description="Feature to remove dependency from")],
|
||||||
|
dependency_id: Annotated[int, Field(ge=1, description="ID of dependency to remove")]
|
||||||
|
) -> str:
|
||||||
|
"""Remove a dependency from a feature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The ID of the feature to remove a dependency from
|
||||||
|
dependency_id: The ID of the dependency to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success status and updated dependencies list, or error message
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if not feature:
|
||||||
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
||||||
|
|
||||||
|
current_deps = feature.dependencies or []
|
||||||
|
if dependency_id not in current_deps:
|
||||||
|
return json.dumps({"error": "Dependency does not exist"})
|
||||||
|
|
||||||
|
current_deps.remove(dependency_id)
|
||||||
|
feature.dependencies = current_deps if current_deps else None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
"dependencies": feature.dependencies or []
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_get_ready(
|
||||||
|
limit: Annotated[int, Field(default=10, ge=1, le=50, description="Max features to return")] = 10
|
||||||
|
) -> str:
|
||||||
|
"""Get all features ready to start (dependencies satisfied, not in progress).
|
||||||
|
|
||||||
|
Useful for parallel execution - returns multiple features that can run simultaneously.
|
||||||
|
A feature is ready if it is not passing, not in progress, and all dependencies are passing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of features to return (1-50, default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with: features (list), count (int), total_ready (int)
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
ready = []
|
||||||
|
for f in all_features:
|
||||||
|
if f.passes or f.in_progress:
|
||||||
|
continue
|
||||||
|
deps = f.dependencies or []
|
||||||
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
|
ready.append(f.to_dict())
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
ready.sort(key=lambda f: (f["priority"], f["id"]))
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"features": ready[:limit],
|
||||||
|
"count": len(ready[:limit]),
|
||||||
|
"total_ready": len(ready)
|
||||||
|
}, indent=2)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_get_blocked() -> str:
|
||||||
|
"""Get all features that are blocked by unmet dependencies.
|
||||||
|
|
||||||
|
Returns features that have dependencies which are not yet passing.
|
||||||
|
Each feature includes a 'blocked_by' field listing the blocking feature IDs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with: features (list with blocked_by field), count (int)
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
blocked = []
|
||||||
|
for f in all_features:
|
||||||
|
if f.passes:
|
||||||
|
continue
|
||||||
|
deps = f.dependencies or []
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
if blocking:
|
||||||
|
blocked.append({
|
||||||
|
**f.to_dict(),
|
||||||
|
"blocked_by": blocking
|
||||||
|
})
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"features": blocked,
|
||||||
|
"count": len(blocked)
|
||||||
|
}, indent=2)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_get_graph() -> str:
|
||||||
|
"""Get dependency graph data for visualization.
|
||||||
|
|
||||||
|
Returns nodes (features) and edges (dependencies) for rendering a graph.
|
||||||
|
Each node includes status: 'pending', 'in_progress', 'done', or 'blocked'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with: nodes (list), edges (list of {source, target})
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
|
||||||
|
for f in all_features:
|
||||||
|
deps = f.dependencies or []
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
|
||||||
|
if f.passes:
|
||||||
|
status = "done"
|
||||||
|
elif blocking:
|
||||||
|
status = "blocked"
|
||||||
|
elif f.in_progress:
|
||||||
|
status = "in_progress"
|
||||||
|
else:
|
||||||
|
status = "pending"
|
||||||
|
|
||||||
|
nodes.append({
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.name,
|
||||||
|
"category": f.category,
|
||||||
|
"status": status,
|
||||||
|
"priority": f.priority,
|
||||||
|
"dependencies": deps
|
||||||
|
})
|
||||||
|
|
||||||
|
for dep_id in deps:
|
||||||
|
edges.append({"source": dep_id, "target": f.id})
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"nodes": nodes,
|
||||||
|
"edges": edges
|
||||||
|
}, indent=2)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def feature_set_dependencies(
|
||||||
|
feature_id: Annotated[int, Field(ge=1, description="Feature to set dependencies for")],
|
||||||
|
dependency_ids: Annotated[list[int], Field(description="List of dependency feature IDs")]
|
||||||
|
) -> str:
|
||||||
|
"""Set all dependencies for a feature at once, replacing any existing dependencies.
|
||||||
|
|
||||||
|
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The ID of the feature to set dependencies for
|
||||||
|
dependency_ids: List of feature IDs that must be completed first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with success status and updated dependencies list, or error message
|
||||||
|
"""
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
# Security: Self-reference check
|
||||||
|
if feature_id in dependency_ids:
|
||||||
|
return json.dumps({"error": "A feature cannot depend on itself"})
|
||||||
|
|
||||||
|
# Security: Max dependencies limit
|
||||||
|
if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE:
|
||||||
|
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed"})
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
if len(dependency_ids) != len(set(dependency_ids)):
|
||||||
|
return json.dumps({"error": "Duplicate dependencies not allowed"})
|
||||||
|
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if not feature:
|
||||||
|
return json.dumps({"error": f"Feature {feature_id} not found"})
|
||||||
|
|
||||||
|
# Validate all dependencies exist
|
||||||
|
all_feature_ids = {f.id for f in session.query(Feature).all()}
|
||||||
|
missing = [d for d in dependency_ids if d not in all_feature_ids]
|
||||||
|
if missing:
|
||||||
|
return json.dumps({"error": f"Dependencies not found: {missing}"})
|
||||||
|
|
||||||
|
# Check for circular dependencies
|
||||||
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
||||||
|
# Temporarily update the feature's dependencies for cycle check
|
||||||
|
test_features = []
|
||||||
|
for f in all_features:
|
||||||
|
if f["id"] == feature_id:
|
||||||
|
test_features.append({**f, "dependencies": dependency_ids})
|
||||||
|
else:
|
||||||
|
test_features.append(f)
|
||||||
|
|
||||||
|
for dep_id in dependency_ids:
|
||||||
|
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
||||||
|
if would_create_circular_dependency(test_features, feature_id, dep_id):
|
||||||
|
return json.dumps({"error": f"Cannot add dependency {dep_id}: would create circular dependency"})
|
||||||
|
|
||||||
|
# Set dependencies
|
||||||
|
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"success": True,
|
||||||
|
"feature_id": feature_id,
|
||||||
|
"dependencies": feature.dependencies or []
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
mcp.run()
|
mcp.run()
|
||||||
|
|||||||
504
parallel_orchestrator.py
Normal file
504
parallel_orchestrator.py
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
"""
|
||||||
|
Parallel Orchestrator
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Coordinates parallel execution of independent features using multiple agent processes.
|
||||||
|
Uses dependency-aware scheduling to ensure features are only started when their
|
||||||
|
dependencies are satisfied.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python parallel_orchestrator.py --project-dir my-app --max-concurrency 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
|
from api.database import Feature, create_database
|
||||||
|
from api.dependency_resolver import are_dependencies_satisfied
|
||||||
|
|
||||||
|
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
|
||||||
|
AUTOCODER_ROOT = Path(__file__).parent.resolve()
|
||||||
|
|
||||||
|
# Performance: Limit parallel agents to prevent memory exhaustion
|
||||||
|
MAX_PARALLEL_AGENTS = 5
|
||||||
|
DEFAULT_CONCURRENCY = 3
|
||||||
|
POLL_INTERVAL = 5 # seconds between checking for ready features
|
||||||
|
MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
|
||||||
|
|
||||||
|
|
||||||
|
class ParallelOrchestrator:
|
||||||
|
"""Orchestrates parallel execution of independent features."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
project_dir: Path,
|
||||||
|
max_concurrency: int = DEFAULT_CONCURRENCY,
|
||||||
|
model: str = None,
|
||||||
|
yolo_mode: bool = False,
|
||||||
|
on_output: Callable[[int, str], None] = None,
|
||||||
|
on_status: Callable[[int, str], None] = None,
|
||||||
|
):
|
||||||
|
"""Initialize the orchestrator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Path to the project directory
|
||||||
|
max_concurrency: Maximum number of concurrent agents (1-5)
|
||||||
|
model: Claude model to use (or None for default)
|
||||||
|
yolo_mode: Whether to run in YOLO mode (skip browser testing)
|
||||||
|
on_output: Callback for agent output (feature_id, line)
|
||||||
|
on_status: Callback for agent status changes (feature_id, status)
|
||||||
|
"""
|
||||||
|
self.project_dir = project_dir
|
||||||
|
self.max_concurrency = min(max(max_concurrency, 1), MAX_PARALLEL_AGENTS)
|
||||||
|
self.model = model
|
||||||
|
self.yolo_mode = yolo_mode
|
||||||
|
self.on_output = on_output
|
||||||
|
self.on_status = on_status
|
||||||
|
|
||||||
|
# Thread-safe state
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.running_agents: dict[int, subprocess.Popen] = {}
|
||||||
|
self.abort_events: dict[int, threading.Event] = {}
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
# Track feature failures to prevent infinite retry loops
|
||||||
|
self._failure_counts: dict[int, int] = {}
|
||||||
|
|
||||||
|
# Database session for this orchestrator
|
||||||
|
self._engine, self._session_maker = create_database(project_dir)
|
||||||
|
|
||||||
|
def get_session(self):
|
||||||
|
"""Get a new database session."""
|
||||||
|
return self._session_maker()
|
||||||
|
|
||||||
|
def get_resumable_features(self) -> list[dict]:
|
||||||
|
"""Get features that were left in_progress from a previous session.
|
||||||
|
|
||||||
|
These are features where in_progress=True but passes=False, and they're
|
||||||
|
not currently being worked on by this orchestrator. This handles the case
|
||||||
|
where a previous session was interrupted before completing the feature.
|
||||||
|
"""
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
# Find features that are in_progress but not complete
|
||||||
|
stale = session.query(Feature).filter(
|
||||||
|
Feature.in_progress == True,
|
||||||
|
Feature.passes == False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
resumable = []
|
||||||
|
for f in stale:
|
||||||
|
# Skip if already running in this orchestrator instance
|
||||||
|
with self._lock:
|
||||||
|
if f.id in self.running_agents:
|
||||||
|
continue
|
||||||
|
# Skip if feature has failed too many times
|
||||||
|
if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
|
||||||
|
continue
|
||||||
|
resumable.append(f.to_dict())
|
||||||
|
|
||||||
|
# Sort by priority (highest priority first)
|
||||||
|
resumable.sort(key=lambda f: (f["priority"], f["id"]))
|
||||||
|
return resumable
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_ready_features(self) -> list[dict]:
|
||||||
|
"""Get features with satisfied dependencies, not already running."""
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
all_dicts = [f.to_dict() for f in all_features]
|
||||||
|
|
||||||
|
ready = []
|
||||||
|
for f in all_features:
|
||||||
|
if f.passes or f.in_progress:
|
||||||
|
continue
|
||||||
|
# Skip if already running in this orchestrator
|
||||||
|
with self._lock:
|
||||||
|
if f.id in self.running_agents:
|
||||||
|
continue
|
||||||
|
# Skip if feature has failed too many times
|
||||||
|
if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
|
||||||
|
continue
|
||||||
|
# Check dependencies
|
||||||
|
if are_dependencies_satisfied(f.to_dict(), all_dicts):
|
||||||
|
ready.append(f.to_dict())
|
||||||
|
|
||||||
|
# Sort by priority
|
||||||
|
ready.sort(key=lambda f: (f["priority"], f["id"]))
|
||||||
|
return ready
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_all_complete(self) -> bool:
|
||||||
|
"""Check if all features are complete."""
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
pending = session.query(Feature).filter(Feature.passes == False).count()
|
||||||
|
return pending == 0
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, str]:
|
||||||
|
"""Start a single feature agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: ID of the feature to start
|
||||||
|
resume: If True, resume a feature that's already in_progress from a previous session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if feature_id in self.running_agents:
|
||||||
|
return False, "Feature already running"
|
||||||
|
if len(self.running_agents) >= self.max_concurrency:
|
||||||
|
return False, "At max concurrency"
|
||||||
|
|
||||||
|
# Mark as in_progress in database (or verify it's resumable)
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if not feature:
|
||||||
|
return False, "Feature not found"
|
||||||
|
if feature.passes:
|
||||||
|
return False, "Feature already complete"
|
||||||
|
|
||||||
|
if resume:
|
||||||
|
# Resuming: feature should already be in_progress
|
||||||
|
if not feature.in_progress:
|
||||||
|
return False, "Feature not in progress, cannot resume"
|
||||||
|
else:
|
||||||
|
# Starting fresh: feature should not be in_progress
|
||||||
|
if feature.in_progress:
|
||||||
|
return False, "Feature already in progress"
|
||||||
|
feature.in_progress = True
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# Create abort event
|
||||||
|
abort_event = threading.Event()
|
||||||
|
|
||||||
|
# Start subprocess for this feature
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-u", # Force unbuffered stdout/stderr
|
||||||
|
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
|
||||||
|
"--project-dir", str(self.project_dir),
|
||||||
|
"--max-iterations", "1", # Single feature mode
|
||||||
|
"--feature-id", str(feature_id), # Work on this specific feature only
|
||||||
|
]
|
||||||
|
if self.model:
|
||||||
|
cmd.extend(["--model", self.model])
|
||||||
|
if self.yolo_mode:
|
||||||
|
cmd.append("--yolo")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
cwd=str(AUTOCODER_ROOT), # Run from autocoder root for proper imports
|
||||||
|
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Reset in_progress on failure
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if feature:
|
||||||
|
feature.in_progress = False
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
return False, f"Failed to start agent: {e}"
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self.running_agents[feature_id] = proc
|
||||||
|
self.abort_events[feature_id] = abort_event
|
||||||
|
|
||||||
|
# Start output reader thread
|
||||||
|
threading.Thread(
|
||||||
|
target=self._read_output,
|
||||||
|
args=(feature_id, proc, abort_event),
|
||||||
|
daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
if self.on_status:
|
||||||
|
self.on_status(feature_id, "running")
|
||||||
|
|
||||||
|
print(f"Started agent for feature #{feature_id}", flush=True)
|
||||||
|
return True, f"Started feature {feature_id}"
|
||||||
|
|
||||||
|
def _read_output(self, feature_id: int, proc: subprocess.Popen, abort: threading.Event):
|
||||||
|
"""Read output from subprocess and emit events."""
|
||||||
|
try:
|
||||||
|
for line in proc.stdout:
|
||||||
|
if abort.is_set():
|
||||||
|
break
|
||||||
|
line = line.rstrip()
|
||||||
|
if self.on_output:
|
||||||
|
self.on_output(feature_id, line)
|
||||||
|
else:
|
||||||
|
print(f"[Feature #{feature_id}] {line}", flush=True)
|
||||||
|
proc.wait()
|
||||||
|
finally:
|
||||||
|
self._on_feature_complete(feature_id, proc.returncode)
|
||||||
|
|
||||||
|
def _on_feature_complete(self, feature_id: int, return_code: int):
|
||||||
|
"""Handle feature completion.
|
||||||
|
|
||||||
|
ALWAYS clears in_progress when agent exits, regardless of success/failure.
|
||||||
|
This prevents features from getting stuck if an agent crashes or is killed.
|
||||||
|
The agent marks features as passing BEFORE clearing in_progress, so this
|
||||||
|
is safe - we won't accidentally clear a feature that's being worked on.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self.running_agents.pop(feature_id, None)
|
||||||
|
self.abort_events.pop(feature_id, None)
|
||||||
|
|
||||||
|
# ALWAYS clear in_progress when agent exits to prevent stuck features
|
||||||
|
# The agent marks features as passing before clearing in_progress,
|
||||||
|
# so if in_progress is still True here, the feature didn't complete successfully
|
||||||
|
session = self.get_session()
|
||||||
|
try:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if feature and feature.in_progress and not feature.passes:
|
||||||
|
feature.in_progress = False
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
# Track failures to prevent infinite retry loops
|
||||||
|
if return_code != 0:
|
||||||
|
with self._lock:
|
||||||
|
self._failure_counts[feature_id] = self._failure_counts.get(feature_id, 0) + 1
|
||||||
|
failure_count = self._failure_counts[feature_id]
|
||||||
|
if failure_count >= MAX_FEATURE_RETRIES:
|
||||||
|
print(f"Feature #{feature_id} has failed {failure_count} times, will not retry", flush=True)
|
||||||
|
|
||||||
|
status = "completed" if return_code == 0 else "failed"
|
||||||
|
if self.on_status:
|
||||||
|
self.on_status(feature_id, status)
|
||||||
|
print(f"Feature #{feature_id} {status}", flush=True)
|
||||||
|
|
||||||
|
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
|
||||||
|
"""Stop a running feature agent."""
|
||||||
|
with self._lock:
|
||||||
|
if feature_id not in self.running_agents:
|
||||||
|
return False, "Feature not running"
|
||||||
|
|
||||||
|
abort = self.abort_events.get(feature_id)
|
||||||
|
proc = self.running_agents.get(feature_id)
|
||||||
|
|
||||||
|
if abort:
|
||||||
|
abort.set()
|
||||||
|
if proc:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
return True, f"Stopped feature {feature_id}"
|
||||||
|
|
||||||
|
def stop_all(self) -> None:
|
||||||
|
"""Stop all running feature agents."""
|
||||||
|
self.is_running = False
|
||||||
|
with self._lock:
|
||||||
|
feature_ids = list(self.running_agents.keys())
|
||||||
|
|
||||||
|
for fid in feature_ids:
|
||||||
|
self.stop_feature(fid)
|
||||||
|
|
||||||
|
async def run_loop(self):
|
||||||
|
"""Main orchestration loop."""
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
print(f"Starting parallel orchestrator with max_concurrency={self.max_concurrency}", flush=True)
|
||||||
|
print(f"Project: {self.project_dir}", flush=True)
|
||||||
|
print(flush=True)
|
||||||
|
|
||||||
|
# Check for features to resume from previous session
|
||||||
|
resumable = self.get_resumable_features()
|
||||||
|
if resumable:
|
||||||
|
print(f"Found {len(resumable)} feature(s) to resume from previous session:", flush=True)
|
||||||
|
for f in resumable:
|
||||||
|
print(f" - Feature #{f['id']}: {f['name']}", flush=True)
|
||||||
|
print(flush=True)
|
||||||
|
|
||||||
|
while self.is_running:
|
||||||
|
try:
|
||||||
|
# Check if all complete
|
||||||
|
if self.get_all_complete():
|
||||||
|
print("\nAll features complete!", flush=True)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check capacity
|
||||||
|
with self._lock:
|
||||||
|
current = len(self.running_agents)
|
||||||
|
if current >= self.max_concurrency:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Priority 1: Resume features from previous session
|
||||||
|
resumable = self.get_resumable_features()
|
||||||
|
if resumable:
|
||||||
|
slots = self.max_concurrency - current
|
||||||
|
for feature in resumable[:slots]:
|
||||||
|
print(f"Resuming feature #{feature['id']}: {feature['name']}", flush=True)
|
||||||
|
self.start_feature(feature["id"], resume=True)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Priority 2: Start new ready features
|
||||||
|
ready = self.get_ready_features()
|
||||||
|
if not ready:
|
||||||
|
# Wait for running features to complete
|
||||||
|
if current > 0:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# No ready features and nothing running - might be blocked
|
||||||
|
print("No ready features available. All remaining features may be blocked by dependencies.", flush=True)
|
||||||
|
await asyncio.sleep(POLL_INTERVAL * 2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start features up to capacity
|
||||||
|
slots = self.max_concurrency - current
|
||||||
|
for feature in ready[:slots]:
|
||||||
|
print(f"Starting feature #{feature['id']}: {feature['name']}", flush=True)
|
||||||
|
self.start_feature(feature["id"])
|
||||||
|
|
||||||
|
await asyncio.sleep(2) # Brief pause between starts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Orchestrator error: {e}", flush=True)
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
# Wait for remaining agents to complete
|
||||||
|
print("Waiting for running agents to complete...", flush=True)
|
||||||
|
while True:
|
||||||
|
with self._lock:
|
||||||
|
if not self.running_agents:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
print("Orchestrator finished.", flush=True)
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get current orchestrator status."""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"running_features": list(self.running_agents.keys()),
|
||||||
|
"count": len(self.running_agents),
|
||||||
|
"max_concurrency": self.max_concurrency,
|
||||||
|
"is_running": self.is_running,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_parallel_orchestrator(
|
||||||
|
project_dir: Path,
|
||||||
|
max_concurrency: int = DEFAULT_CONCURRENCY,
|
||||||
|
model: str = None,
|
||||||
|
yolo_mode: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Run the parallel orchestrator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_dir: Path to the project directory
|
||||||
|
max_concurrency: Maximum number of concurrent agents
|
||||||
|
model: Claude model to use
|
||||||
|
yolo_mode: Whether to run in YOLO mode
|
||||||
|
"""
|
||||||
|
orchestrator = ParallelOrchestrator(
|
||||||
|
project_dir=project_dir,
|
||||||
|
max_concurrency=max_concurrency,
|
||||||
|
model=model,
|
||||||
|
yolo_mode=yolo_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await orchestrator.run_loop()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nInterrupted by user. Stopping agents...", flush=True)
|
||||||
|
orchestrator.stop_all()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for parallel orchestration."""
|
||||||
|
import argparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from registry import DEFAULT_MODEL, get_project_path
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Parallel Feature Orchestrator - Run multiple agent instances",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--project-dir",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="Project directory path (absolute) or registered project name",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-concurrency",
|
||||||
|
"-p",
|
||||||
|
type=int,
|
||||||
|
default=DEFAULT_CONCURRENCY,
|
||||||
|
help=f"Maximum concurrent agents (1-{MAX_PARALLEL_AGENTS}, default: {DEFAULT_CONCURRENCY})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--model",
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_MODEL,
|
||||||
|
help=f"Claude model to use (default: {DEFAULT_MODEL})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--yolo",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Enable YOLO mode: rapid prototyping without browser testing",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve project directory
|
||||||
|
project_dir_input = args.project_dir
|
||||||
|
project_dir = Path(project_dir_input)
|
||||||
|
|
||||||
|
if project_dir.is_absolute():
|
||||||
|
if not project_dir.exists():
|
||||||
|
print(f"Error: Project directory does not exist: {project_dir}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
registered_path = get_project_path(project_dir_input)
|
||||||
|
if registered_path:
|
||||||
|
project_dir = registered_path
|
||||||
|
else:
|
||||||
|
print(f"Error: Project '{project_dir_input}' not found in registry", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(run_parallel_orchestrator(
|
||||||
|
project_dir=project_dir,
|
||||||
|
max_concurrency=args.max_concurrency,
|
||||||
|
model=args.model,
|
||||||
|
yolo_mode=args.yolo,
|
||||||
|
))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nInterrupted by user", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
50
prompts.py
50
prompts.py
@@ -79,6 +79,56 @@ def get_coding_prompt_yolo(project_dir: Path | None = None) -> str:
|
|||||||
return load_prompt("coding_prompt_yolo", project_dir)
|
return load_prompt("coding_prompt_yolo", project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_single_feature_prompt(feature_id: int, project_dir: Path | None = None, yolo_mode: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Load the coding prompt with single-feature focus instructions prepended.
|
||||||
|
|
||||||
|
When the parallel orchestrator assigns a specific feature to an agent,
|
||||||
|
this prompt ensures the agent works ONLY on that feature.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_id: The specific feature ID to work on
|
||||||
|
project_dir: Optional project directory for project-specific prompts
|
||||||
|
yolo_mode: If True, use the YOLO prompt variant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The prompt with single-feature instructions prepended
|
||||||
|
"""
|
||||||
|
# Get the base prompt
|
||||||
|
if yolo_mode:
|
||||||
|
base_prompt = get_coding_prompt_yolo(project_dir)
|
||||||
|
else:
|
||||||
|
base_prompt = get_coding_prompt(project_dir)
|
||||||
|
|
||||||
|
# Prepend single-feature instructions
|
||||||
|
single_feature_header = f"""## SINGLE FEATURE MODE
|
||||||
|
|
||||||
|
**CRITICAL: You are assigned to work on Feature #{feature_id} ONLY.**
|
||||||
|
|
||||||
|
This session is part of a parallel execution where multiple agents work on different features simultaneously. You MUST:
|
||||||
|
|
||||||
|
1. **Skip the `feature_get_next` step** - Your feature is already assigned: #{feature_id}
|
||||||
|
2. **Immediately mark feature #{feature_id} as in-progress** using `feature_mark_in_progress`
|
||||||
|
3. **Focus ONLY on implementing and testing feature #{feature_id}**
|
||||||
|
4. **Do NOT work on any other features** - other agents are handling them
|
||||||
|
|
||||||
|
When you complete feature #{feature_id}:
|
||||||
|
- Mark it as passing with `feature_mark_passing`
|
||||||
|
- Commit your changes
|
||||||
|
- End the session
|
||||||
|
|
||||||
|
If you cannot complete feature #{feature_id} due to a blocker:
|
||||||
|
- Use `feature_skip` to move it to the end of the queue
|
||||||
|
- Document the blocker in claude-progress.txt
|
||||||
|
- End the session
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
return single_feature_header + base_prompt
|
||||||
|
|
||||||
|
|
||||||
def get_app_spec(project_dir: Path) -> str:
|
def get_app_spec(project_dir: Path) -> str:
|
||||||
"""
|
"""
|
||||||
Load the app spec from the project.
|
Load the app spec from the project.
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ async def get_agent_status(project_name: str):
|
|||||||
started_at=manager.started_at,
|
started_at=manager.started_at,
|
||||||
yolo_mode=manager.yolo_mode,
|
yolo_mode=manager.yolo_mode,
|
||||||
model=manager.model,
|
model=manager.model,
|
||||||
|
parallel_mode=manager.parallel_mode,
|
||||||
|
max_concurrency=manager.max_concurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,8 +102,15 @@ async def start_agent(
|
|||||||
default_yolo, default_model = _get_settings_defaults()
|
default_yolo, default_model = _get_settings_defaults()
|
||||||
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
||||||
model = request.model if request.model else default_model
|
model = request.model if request.model else default_model
|
||||||
|
parallel_mode = request.parallel_mode or False
|
||||||
|
max_concurrency = request.max_concurrency
|
||||||
|
|
||||||
success, message = await manager.start(yolo_mode=yolo_mode, model=model)
|
success, message = await manager.start(
|
||||||
|
yolo_mode=yolo_mode,
|
||||||
|
model=model,
|
||||||
|
parallel_mode=parallel_mode,
|
||||||
|
max_concurrency=max_concurrency,
|
||||||
|
)
|
||||||
|
|
||||||
return AgentActionResponse(
|
return AgentActionResponse(
|
||||||
success=success,
|
success=success,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
|
DependencyGraphNode,
|
||||||
|
DependencyGraphResponse,
|
||||||
|
DependencyUpdate,
|
||||||
FeatureBulkCreate,
|
FeatureBulkCreate,
|
||||||
FeatureBulkCreateResponse,
|
FeatureBulkCreateResponse,
|
||||||
FeatureCreate,
|
FeatureCreate,
|
||||||
@@ -72,11 +75,27 @@ def get_db_session(project_dir: Path):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def feature_to_response(f) -> FeatureResponse:
|
def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureResponse:
|
||||||
"""Convert a Feature model to a FeatureResponse.
|
"""Convert a Feature model to a FeatureResponse.
|
||||||
|
|
||||||
Handles legacy NULL values in boolean fields by treating them as False.
|
Handles legacy NULL values in boolean fields by treating them as False.
|
||||||
|
Computes blocked status if passing_ids is provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f: Feature model instance
|
||||||
|
passing_ids: Optional set of feature IDs that are passing (for computing blocked status)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FeatureResponse with computed blocked status
|
||||||
"""
|
"""
|
||||||
|
deps = f.dependencies or []
|
||||||
|
if passing_ids is None:
|
||||||
|
blocking = []
|
||||||
|
blocked = False
|
||||||
|
else:
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
blocked = len(blocking) > 0
|
||||||
|
|
||||||
return FeatureResponse(
|
return FeatureResponse(
|
||||||
id=f.id,
|
id=f.id,
|
||||||
priority=f.priority,
|
priority=f.priority,
|
||||||
@@ -84,9 +103,12 @@ def feature_to_response(f) -> FeatureResponse:
|
|||||||
name=f.name,
|
name=f.name,
|
||||||
description=f.description,
|
description=f.description,
|
||||||
steps=f.steps if isinstance(f.steps, list) else [],
|
steps=f.steps if isinstance(f.steps, list) else [],
|
||||||
|
dependencies=deps,
|
||||||
# Handle legacy NULL values gracefully - treat as False
|
# Handle legacy NULL values gracefully - treat as False
|
||||||
passes=f.passes if f.passes is not None else False,
|
passes=f.passes if f.passes is not None else False,
|
||||||
in_progress=f.in_progress if f.in_progress is not None else False,
|
in_progress=f.in_progress if f.in_progress is not None else False,
|
||||||
|
blocked=blocked,
|
||||||
|
blocking_dependencies=blocking,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,12 +141,15 @@ async def list_features(project_name: str):
|
|||||||
with get_db_session(project_dir) as session:
|
with get_db_session(project_dir) as session:
|
||||||
all_features = session.query(Feature).order_by(Feature.priority).all()
|
all_features = session.query(Feature).order_by(Feature.priority).all()
|
||||||
|
|
||||||
|
# Compute passing IDs for blocked status calculation
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
pending = []
|
pending = []
|
||||||
in_progress = []
|
in_progress = []
|
||||||
done = []
|
done = []
|
||||||
|
|
||||||
for f in all_features:
|
for f in all_features:
|
||||||
feature_response = feature_to_response(f)
|
feature_response = feature_to_response(f, passing_ids)
|
||||||
if f.passes:
|
if f.passes:
|
||||||
done.append(feature_response)
|
done.append(feature_response)
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -174,6 +199,7 @@ async def create_feature(project_name: str, feature: FeatureCreate):
|
|||||||
name=feature.name,
|
name=feature.name,
|
||||||
description=feature.description,
|
description=feature.description,
|
||||||
steps=feature.steps,
|
steps=feature.steps,
|
||||||
|
dependencies=feature.dependencies if feature.dependencies else None,
|
||||||
passes=False,
|
passes=False,
|
||||||
in_progress=False,
|
in_progress=False,
|
||||||
)
|
)
|
||||||
@@ -190,6 +216,167 @@ async def create_feature(project_name: str, feature: FeatureCreate):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to create feature")
|
raise HTTPException(status_code=500, detail="Failed to create feature")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Static path endpoints - MUST be declared before /{feature_id} routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bulk", response_model=FeatureBulkCreateResponse)
|
||||||
|
async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
|
||||||
|
"""
|
||||||
|
Create multiple features at once.
|
||||||
|
|
||||||
|
Features are assigned sequential priorities starting from:
|
||||||
|
- starting_priority if specified (must be >= 1)
|
||||||
|
- max(existing priorities) + 1 if not specified
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
- Expanding a project with new features via AI
|
||||||
|
- Importing features from external sources
|
||||||
|
- Batch operations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"created": N, "features": [...]}
|
||||||
|
"""
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
if not bulk.features:
|
||||||
|
return FeatureBulkCreateResponse(created=0, features=[])
|
||||||
|
|
||||||
|
# Validate starting_priority if provided
|
||||||
|
if bulk.starting_priority is not None and bulk.starting_priority < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="starting_priority must be >= 1")
|
||||||
|
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
# Determine starting priority with row-level lock to prevent race conditions
|
||||||
|
if bulk.starting_priority is not None:
|
||||||
|
current_priority = bulk.starting_priority
|
||||||
|
else:
|
||||||
|
# Lock the max priority row to prevent concurrent inserts from getting same priority
|
||||||
|
max_priority_feature = (
|
||||||
|
session.query(Feature)
|
||||||
|
.order_by(Feature.priority.desc())
|
||||||
|
.with_for_update()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
|
||||||
|
|
||||||
|
created_ids = []
|
||||||
|
|
||||||
|
for feature_data in bulk.features:
|
||||||
|
db_feature = Feature(
|
||||||
|
priority=current_priority,
|
||||||
|
category=feature_data.category,
|
||||||
|
name=feature_data.name,
|
||||||
|
description=feature_data.description,
|
||||||
|
steps=feature_data.steps,
|
||||||
|
dependencies=feature_data.dependencies if feature_data.dependencies else None,
|
||||||
|
passes=False,
|
||||||
|
in_progress=False,
|
||||||
|
)
|
||||||
|
session.add(db_feature)
|
||||||
|
session.flush() # Flush to get the ID immediately
|
||||||
|
created_ids.append(db_feature.id)
|
||||||
|
current_priority += 1
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Query created features by their IDs (avoids relying on priority range)
|
||||||
|
created_features = []
|
||||||
|
for db_feature in session.query(Feature).filter(
|
||||||
|
Feature.id.in_(created_ids)
|
||||||
|
).order_by(Feature.priority).all():
|
||||||
|
created_features.append(feature_to_response(db_feature))
|
||||||
|
|
||||||
|
return FeatureBulkCreateResponse(
|
||||||
|
created=len(created_features),
|
||||||
|
features=created_features
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to bulk create features")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to bulk create features")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/graph", response_model=DependencyGraphResponse)
|
||||||
|
async def get_dependency_graph(project_name: str):
|
||||||
|
"""Return dependency graph data for visualization.
|
||||||
|
|
||||||
|
Returns nodes (features) and edges (dependencies) suitable for
|
||||||
|
rendering with React Flow or similar graph libraries.
|
||||||
|
"""
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
db_file = project_dir / "features.db"
|
||||||
|
if not db_file.exists():
|
||||||
|
return DependencyGraphResponse(nodes=[], edges=[])
|
||||||
|
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
|
||||||
|
for f in all_features:
|
||||||
|
deps = f.dependencies or []
|
||||||
|
blocking = [d for d in deps if d not in passing_ids]
|
||||||
|
|
||||||
|
if f.passes:
|
||||||
|
status = "done"
|
||||||
|
elif blocking:
|
||||||
|
status = "blocked"
|
||||||
|
elif f.in_progress:
|
||||||
|
status = "in_progress"
|
||||||
|
else:
|
||||||
|
status = "pending"
|
||||||
|
|
||||||
|
nodes.append(DependencyGraphNode(
|
||||||
|
id=f.id,
|
||||||
|
name=f.name,
|
||||||
|
category=f.category,
|
||||||
|
status=status,
|
||||||
|
priority=f.priority,
|
||||||
|
dependencies=deps
|
||||||
|
))
|
||||||
|
|
||||||
|
for dep_id in deps:
|
||||||
|
edges.append({"source": dep_id, "target": f.id})
|
||||||
|
|
||||||
|
return DependencyGraphResponse(nodes=nodes, edges=edges)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get dependency graph")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get dependency graph")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Parameterized path endpoints - /{feature_id} routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{feature_id}", response_model=FeatureResponse)
|
@router.get("/{feature_id}", response_model=FeatureResponse)
|
||||||
async def get_feature(project_name: str, feature_id: int):
|
async def get_feature(project_name: str, feature_id: int):
|
||||||
"""Get details of a specific feature."""
|
"""Get details of a specific feature."""
|
||||||
@@ -268,11 +455,17 @@ async def update_feature(project_name: str, feature_id: int, update: FeatureUpda
|
|||||||
feature.steps = update.steps
|
feature.steps = update.steps
|
||||||
if update.priority is not None:
|
if update.priority is not None:
|
||||||
feature.priority = update.priority
|
feature.priority = update.priority
|
||||||
|
if update.dependencies is not None:
|
||||||
|
feature.dependencies = update.dependencies if update.dependencies else None
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(feature)
|
session.refresh(feature)
|
||||||
|
|
||||||
return feature_to_response(feature)
|
# Compute passing IDs for response
|
||||||
|
all_features = session.query(Feature).all()
|
||||||
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
|
return feature_to_response(feature, passing_ids)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -282,7 +475,12 @@ async def update_feature(project_name: str, feature_id: int, update: FeatureUpda
|
|||||||
|
|
||||||
@router.delete("/{feature_id}")
|
@router.delete("/{feature_id}")
|
||||||
async def delete_feature(project_name: str, feature_id: int):
|
async def delete_feature(project_name: str, feature_id: int):
|
||||||
"""Delete a feature."""
|
"""Delete a feature and clean up references in other features' dependencies.
|
||||||
|
|
||||||
|
When a feature is deleted, any other features that depend on it will have
|
||||||
|
that dependency removed from their dependencies list. This prevents orphaned
|
||||||
|
dependencies that would permanently block features.
|
||||||
|
"""
|
||||||
project_name = validate_project_name(project_name)
|
project_name = validate_project_name(project_name)
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
@@ -301,10 +499,24 @@ async def delete_feature(project_name: str, feature_id: int):
|
|||||||
if not feature:
|
if not feature:
|
||||||
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
|
|
||||||
|
# Clean up dependency references in other features
|
||||||
|
# This prevents orphaned dependencies that would block features forever
|
||||||
|
affected_features = []
|
||||||
|
for f in session.query(Feature).all():
|
||||||
|
if f.dependencies and feature_id in f.dependencies:
|
||||||
|
# Remove the deleted feature from this feature's dependencies
|
||||||
|
deps = [d for d in f.dependencies if d != feature_id]
|
||||||
|
f.dependencies = deps if deps else None
|
||||||
|
affected_features.append(f.id)
|
||||||
|
|
||||||
session.delete(feature)
|
session.delete(feature)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
return {"success": True, "message": f"Feature {feature_id} deleted"}
|
message = f"Feature {feature_id} deleted"
|
||||||
|
if affected_features:
|
||||||
|
message += f". Removed from dependencies of features: {affected_features}"
|
||||||
|
|
||||||
|
return {"success": True, "message": message, "affected_features": affected_features}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -352,22 +564,123 @@ async def skip_feature(project_name: str, feature_id: int):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk", response_model=FeatureBulkCreateResponse)
|
# ============================================================================
|
||||||
async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
|
# Dependency Management Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dependency_resolver():
|
||||||
|
"""Lazy import of dependency resolver."""
|
||||||
|
import sys
|
||||||
|
root = Path(__file__).parent.parent.parent
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
from api.dependency_resolver import would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE
|
||||||
|
return would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{feature_id}/dependencies/{dep_id}")
|
||||||
|
async def add_dependency(project_name: str, feature_id: int, dep_id: int):
|
||||||
|
"""Add a dependency relationship between features.
|
||||||
|
|
||||||
|
The dep_id feature must be completed before feature_id can be started.
|
||||||
|
Validates: self-reference, existence, circular dependencies, max limit.
|
||||||
"""
|
"""
|
||||||
Create multiple features at once.
|
project_name = validate_project_name(project_name)
|
||||||
|
|
||||||
Features are assigned sequential priorities starting from:
|
# Security: Self-reference check
|
||||||
- starting_priority if specified (must be >= 1)
|
if feature_id == dep_id:
|
||||||
- max(existing priorities) + 1 if not specified
|
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
|
||||||
|
|
||||||
This is useful for:
|
project_dir = _get_project_path(project_name)
|
||||||
- Expanding a project with new features via AI
|
|
||||||
- Importing features from external sources
|
|
||||||
- Batch operations
|
|
||||||
|
|
||||||
Returns:
|
if not project_dir:
|
||||||
{"created": N, "features": [...]}
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE = _get_dependency_resolver()
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
dependency = session.query(Feature).filter(Feature.id == dep_id).first()
|
||||||
|
|
||||||
|
if not feature:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
|
if not dependency:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Dependency {dep_id} not found")
|
||||||
|
|
||||||
|
current_deps = feature.dependencies or []
|
||||||
|
|
||||||
|
# Security: Limit check
|
||||||
|
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed")
|
||||||
|
|
||||||
|
if dep_id in current_deps:
|
||||||
|
raise HTTPException(status_code=400, detail="Dependency already exists")
|
||||||
|
|
||||||
|
# Security: Circular dependency check
|
||||||
|
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
||||||
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
||||||
|
if would_create_circular_dependency(all_features, feature_id, dep_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Would create circular dependency")
|
||||||
|
|
||||||
|
current_deps.append(dep_id)
|
||||||
|
feature.dependencies = sorted(current_deps)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to add dependency")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to add dependency")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{feature_id}/dependencies/{dep_id}")
|
||||||
|
async def remove_dependency(project_name: str, feature_id: int, dep_id: int):
|
||||||
|
"""Remove a dependency from a feature."""
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_dir:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
|
||||||
|
|
||||||
|
if not project_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with get_db_session(project_dir) as session:
|
||||||
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
|
if not feature:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
|
|
||||||
|
current_deps = feature.dependencies or []
|
||||||
|
if dep_id not in current_deps:
|
||||||
|
raise HTTPException(status_code=400, detail="Dependency does not exist")
|
||||||
|
|
||||||
|
current_deps.remove(dep_id)
|
||||||
|
feature.dependencies = current_deps if current_deps else None
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to remove dependency")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to remove dependency")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{feature_id}/dependencies")
|
||||||
|
async def set_dependencies(project_name: str, feature_id: int, update: DependencyUpdate):
|
||||||
|
"""Set all dependencies for a feature at once, replacing any existing.
|
||||||
|
|
||||||
|
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
|
||||||
"""
|
"""
|
||||||
project_name = validate_project_name(project_name)
|
project_name = validate_project_name(project_name)
|
||||||
project_dir = _get_project_path(project_name)
|
project_dir = _get_project_path(project_name)
|
||||||
@@ -378,62 +691,56 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
|
|||||||
if not project_dir.exists():
|
if not project_dir.exists():
|
||||||
raise HTTPException(status_code=404, detail="Project directory not found")
|
raise HTTPException(status_code=404, detail="Project directory not found")
|
||||||
|
|
||||||
if not bulk.features:
|
dependency_ids = update.dependency_ids
|
||||||
return FeatureBulkCreateResponse(created=0, features=[])
|
|
||||||
|
|
||||||
# Validate starting_priority if provided
|
# Security: Self-reference check
|
||||||
if bulk.starting_priority is not None and bulk.starting_priority < 1:
|
if feature_id in dependency_ids:
|
||||||
raise HTTPException(status_code=400, detail="starting_priority must be >= 1")
|
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
|
||||||
|
|
||||||
|
# Check for duplicates
|
||||||
|
if len(dependency_ids) != len(set(dependency_ids)):
|
||||||
|
raise HTTPException(status_code=400, detail="Duplicate dependencies not allowed")
|
||||||
|
|
||||||
|
would_create_circular_dependency, _ = _get_dependency_resolver()
|
||||||
_, Feature = _get_db_classes()
|
_, Feature = _get_db_classes()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with get_db_session(project_dir) as session:
|
with get_db_session(project_dir) as session:
|
||||||
# Determine starting priority with row-level lock to prevent race conditions
|
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||||
if bulk.starting_priority is not None:
|
if not feature:
|
||||||
current_priority = bulk.starting_priority
|
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||||
else:
|
|
||||||
# Lock the max priority row to prevent concurrent inserts from getting same priority
|
|
||||||
max_priority_feature = (
|
|
||||||
session.query(Feature)
|
|
||||||
.order_by(Feature.priority.desc())
|
|
||||||
.with_for_update()
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
|
|
||||||
|
|
||||||
created_ids = []
|
# Validate all dependencies exist
|
||||||
|
all_feature_ids = {f.id for f in session.query(Feature).all()}
|
||||||
|
missing = [d for d in dependency_ids if d not in all_feature_ids]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Dependencies not found: {missing}")
|
||||||
|
|
||||||
for feature_data in bulk.features:
|
# Check for circular dependencies
|
||||||
db_feature = Feature(
|
all_features = [f.to_dict() for f in session.query(Feature).all()]
|
||||||
priority=current_priority,
|
# Temporarily update the feature's dependencies for cycle check
|
||||||
category=feature_data.category,
|
test_features = []
|
||||||
name=feature_data.name,
|
for f in all_features:
|
||||||
description=feature_data.description,
|
if f["id"] == feature_id:
|
||||||
steps=feature_data.steps,
|
test_features.append({**f, "dependencies": dependency_ids})
|
||||||
passes=False,
|
else:
|
||||||
in_progress=False,
|
test_features.append(f)
|
||||||
)
|
|
||||||
session.add(db_feature)
|
|
||||||
session.flush() # Flush to get the ID immediately
|
|
||||||
created_ids.append(db_feature.id)
|
|
||||||
current_priority += 1
|
|
||||||
|
|
||||||
|
for dep_id in dependency_ids:
|
||||||
|
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
|
||||||
|
if would_create_circular_dependency(test_features, feature_id, dep_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot add dependency {dep_id}: would create circular dependency"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set dependencies
|
||||||
|
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Query created features by their IDs (avoids relying on priority range)
|
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
|
||||||
created_features = []
|
|
||||||
for db_feature in session.query(Feature).filter(
|
|
||||||
Feature.id.in_(created_ids)
|
|
||||||
).order_by(Feature.priority).all():
|
|
||||||
created_features.append(feature_to_response(db_feature))
|
|
||||||
|
|
||||||
return FeatureBulkCreateResponse(
|
|
||||||
created=len(created_features),
|
|
||||||
features=created_features
|
|
||||||
)
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to bulk create features")
|
logger.exception("Failed to set dependencies")
|
||||||
raise HTTPException(status_code=500, detail="Failed to bulk create features")
|
raise HTTPException(status_code=500, detail="Failed to set dependencies")
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class FeatureBase(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
steps: list[str]
|
steps: list[str]
|
||||||
|
dependencies: list[int] = Field(default_factory=list) # Optional dependencies
|
||||||
|
|
||||||
|
|
||||||
class FeatureCreate(FeatureBase):
|
class FeatureCreate(FeatureBase):
|
||||||
@@ -94,6 +95,7 @@ class FeatureUpdate(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
steps: list[str] | None = None
|
steps: list[str] | None = None
|
||||||
priority: int | None = None
|
priority: int | None = None
|
||||||
|
dependencies: list[int] | None = None # Optional - can update dependencies
|
||||||
|
|
||||||
|
|
||||||
class FeatureResponse(FeatureBase):
|
class FeatureResponse(FeatureBase):
|
||||||
@@ -102,6 +104,8 @@ class FeatureResponse(FeatureBase):
|
|||||||
priority: int
|
priority: int
|
||||||
passes: bool
|
passes: bool
|
||||||
in_progress: bool
|
in_progress: bool
|
||||||
|
blocked: bool = False # Computed: has unmet dependencies
|
||||||
|
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -126,6 +130,37 @@ class FeatureBulkCreateResponse(BaseModel):
|
|||||||
features: list[FeatureResponse]
|
features: list[FeatureResponse]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Dependency Graph Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class DependencyGraphNode(BaseModel):
|
||||||
|
"""Minimal node for graph visualization (no description exposed for security)."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
status: Literal["pending", "in_progress", "done", "blocked"]
|
||||||
|
priority: int
|
||||||
|
dependencies: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyGraphEdge(BaseModel):
|
||||||
|
"""Edge in the dependency graph."""
|
||||||
|
source: int
|
||||||
|
target: int
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyGraphResponse(BaseModel):
|
||||||
|
"""Response for dependency graph visualization."""
|
||||||
|
nodes: list[DependencyGraphNode]
|
||||||
|
edges: list[DependencyGraphEdge]
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyUpdate(BaseModel):
|
||||||
|
"""Request schema for updating a feature's dependencies."""
|
||||||
|
dependency_ids: list[int] = Field(..., max_length=20) # Security: limit
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Agent Schemas
|
# Agent Schemas
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -134,6 +169,8 @@ class AgentStartRequest(BaseModel):
|
|||||||
"""Request schema for starting the agent."""
|
"""Request schema for starting the agent."""
|
||||||
yolo_mode: bool | None = None # None means use global settings
|
yolo_mode: bool | None = None # None means use global settings
|
||||||
model: str | None = None # None means use global settings
|
model: str | None = None # None means use global settings
|
||||||
|
parallel_mode: bool | None = None # Enable parallel execution
|
||||||
|
max_concurrency: int | None = None # Max concurrent agents (1-5)
|
||||||
|
|
||||||
@field_validator('model')
|
@field_validator('model')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -143,6 +180,14 @@ class AgentStartRequest(BaseModel):
|
|||||||
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@field_validator('max_concurrency')
|
||||||
|
@classmethod
|
||||||
|
def validate_concurrency(cls, v: int | None) -> int | None:
|
||||||
|
"""Validate max_concurrency is between 1 and 5."""
|
||||||
|
if v is not None and (v < 1 or v > 5):
|
||||||
|
raise ValueError("max_concurrency must be between 1 and 5")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AgentStatus(BaseModel):
|
class AgentStatus(BaseModel):
|
||||||
"""Current agent status."""
|
"""Current agent status."""
|
||||||
@@ -151,6 +196,8 @@ class AgentStatus(BaseModel):
|
|||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
model: str | None = None # Model being used by running agent
|
model: str | None = None # Model being used by running agent
|
||||||
|
parallel_mode: bool = False
|
||||||
|
max_concurrency: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentActionResponse(BaseModel):
|
class AgentActionResponse(BaseModel):
|
||||||
@@ -180,6 +227,7 @@ class WSProgressMessage(BaseModel):
|
|||||||
"""WebSocket message for progress updates."""
|
"""WebSocket message for progress updates."""
|
||||||
type: Literal["progress"] = "progress"
|
type: Literal["progress"] = "progress"
|
||||||
passing: int
|
passing: int
|
||||||
|
in_progress: int
|
||||||
total: int
|
total: int
|
||||||
percentage: float
|
percentage: float
|
||||||
|
|
||||||
@@ -196,6 +244,8 @@ class WSLogMessage(BaseModel):
|
|||||||
type: Literal["log"] = "log"
|
type: Literal["log"] = "log"
|
||||||
line: str
|
line: str
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
featureId: int | None = None
|
||||||
|
agentIndex: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class WSAgentStatusMessage(BaseModel):
|
class WSAgentStatusMessage(BaseModel):
|
||||||
@@ -204,6 +254,25 @@ class WSAgentStatusMessage(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# Agent state for multi-agent tracking
|
||||||
|
AgentState = Literal["idle", "thinking", "working", "testing", "success", "error", "struggling"]
|
||||||
|
|
||||||
|
# Agent mascot names assigned by index
|
||||||
|
AGENT_MASCOTS = ["Spark", "Fizz", "Octo", "Hoot", "Buzz"]
|
||||||
|
|
||||||
|
|
||||||
|
class WSAgentUpdateMessage(BaseModel):
|
||||||
|
"""WebSocket message for multi-agent status updates."""
|
||||||
|
type: Literal["agent_update"] = "agent_update"
|
||||||
|
agentIndex: int
|
||||||
|
agentName: str # One of AGENT_MASCOTS
|
||||||
|
featureId: int
|
||||||
|
featureName: str
|
||||||
|
state: AgentState
|
||||||
|
thought: str | None = None
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Spec Chat Schemas
|
# Spec Chat Schemas
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ class AgentProcessManager:
|
|||||||
self._output_task: asyncio.Task | None = None
|
self._output_task: asyncio.Task | None = None
|
||||||
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
||||||
self.model: str | None = None # Model being used
|
self.model: str | None = None # Model being used
|
||||||
|
self.parallel_mode: bool = False # Parallel execution mode
|
||||||
|
self.max_concurrency: int | None = None # Max concurrent agents
|
||||||
|
|
||||||
# Support multiple callbacks (for multiple WebSocket clients)
|
# Support multiple callbacks (for multiple WebSocket clients)
|
||||||
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
||||||
@@ -241,13 +243,21 @@ class AgentProcessManager:
|
|||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
|
||||||
async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]:
|
async def start(
|
||||||
|
self,
|
||||||
|
yolo_mode: bool = False,
|
||||||
|
model: str | None = None,
|
||||||
|
parallel_mode: bool = False,
|
||||||
|
max_concurrency: int | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Start the agent as a subprocess.
|
Start the agent as a subprocess.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
yolo_mode: If True, run in YOLO mode (no browser testing)
|
yolo_mode: If True, run in YOLO mode (no browser testing)
|
||||||
model: Model to use (e.g., claude-opus-4-5-20251101)
|
model: Model to use (e.g., claude-opus-4-5-20251101)
|
||||||
|
parallel_mode: If True, run multiple features in parallel
|
||||||
|
max_concurrency: Max concurrent agents (default 3 if parallel enabled)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
@@ -261,6 +271,8 @@ class AgentProcessManager:
|
|||||||
# Store for status queries
|
# Store for status queries
|
||||||
self.yolo_mode = yolo_mode
|
self.yolo_mode = yolo_mode
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.parallel_mode = parallel_mode
|
||||||
|
self.max_concurrency = max_concurrency
|
||||||
|
|
||||||
# Build command - pass absolute path to project directory
|
# Build command - pass absolute path to project directory
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -278,6 +290,11 @@ class AgentProcessManager:
|
|||||||
if yolo_mode:
|
if yolo_mode:
|
||||||
cmd.append("--yolo")
|
cmd.append("--yolo")
|
||||||
|
|
||||||
|
# Add --parallel flag if parallel mode is enabled
|
||||||
|
if parallel_mode:
|
||||||
|
cmd.append("--parallel")
|
||||||
|
cmd.append(str(max_concurrency or 3)) # Default to 3 concurrent agents
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Start subprocess with piped stdout/stderr
|
# Start subprocess with piped stdout/stderr
|
||||||
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
|
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
|
||||||
@@ -340,6 +357,8 @@ class AgentProcessManager:
|
|||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.yolo_mode = False # Reset YOLO mode
|
self.yolo_mode = False # Reset YOLO mode
|
||||||
self.model = None # Reset model
|
self.model = None # Reset model
|
||||||
|
self.parallel_mode = False # Reset parallel mode
|
||||||
|
self.max_concurrency = None # Reset concurrency
|
||||||
|
|
||||||
return True, "Agent stopped"
|
return True, "Agent stopped"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -422,6 +441,8 @@ class AgentProcessManager:
|
|||||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
"yolo_mode": self.yolo_mode,
|
"yolo_mode": self.yolo_mode,
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
|
"parallel_mode": self.parallel_mode,
|
||||||
|
"max_concurrency": self.max_concurrency,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from typing import Set
|
|||||||
|
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from .schemas import AGENT_MASCOTS
|
||||||
from .services.dev_server_manager import get_devserver_manager
|
from .services.dev_server_manager import get_devserver_manager
|
||||||
from .services.process_manager import get_manager
|
from .services.process_manager import get_manager
|
||||||
|
|
||||||
@@ -23,6 +24,177 @@ _count_passing_tests = None
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pattern to extract feature ID from parallel orchestrator output
|
||||||
|
FEATURE_ID_PATTERN = re.compile(r'\[Feature #(\d+)\]\s*(.*)')
|
||||||
|
|
||||||
|
# Patterns for detecting agent activity and thoughts
|
||||||
|
THOUGHT_PATTERNS = [
|
||||||
|
# Claude's tool usage patterns (actual format: [Tool: name])
|
||||||
|
(re.compile(r'\[Tool:\s*Read\]', re.I), 'thinking'),
|
||||||
|
(re.compile(r'\[Tool:\s*(?:Write|Edit|NotebookEdit)\]', re.I), 'working'),
|
||||||
|
(re.compile(r'\[Tool:\s*Bash\]', re.I), 'testing'),
|
||||||
|
(re.compile(r'\[Tool:\s*(?:Glob|Grep)\]', re.I), 'thinking'),
|
||||||
|
(re.compile(r'\[Tool:\s*(\w+)\]', re.I), 'working'), # Fallback for other tools
|
||||||
|
# Claude's internal thoughts
|
||||||
|
(re.compile(r'(?:Reading|Analyzing|Checking|Looking at|Examining)\s+(.+)', re.I), 'thinking'),
|
||||||
|
(re.compile(r'(?:Creating|Writing|Adding|Implementing|Building)\s+(.+)', re.I), 'working'),
|
||||||
|
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
|
||||||
|
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
|
||||||
|
# Test results
|
||||||
|
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
|
||||||
|
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentTracker:
|
||||||
|
"""Tracks active agents and their states for multi-agent mode."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# feature_id -> {name, state, last_thought, agent_index}
|
||||||
|
self.active_agents: dict[int, dict] = {}
|
||||||
|
self._next_agent_index = 0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def process_line(self, line: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Process an output line and return an agent_update message if relevant.
|
||||||
|
|
||||||
|
Returns None if no update should be emitted.
|
||||||
|
"""
|
||||||
|
# Check for feature-specific output
|
||||||
|
match = FEATURE_ID_PATTERN.match(line)
|
||||||
|
if not match:
|
||||||
|
# Also check for orchestrator status messages
|
||||||
|
if line.startswith("Started agent for feature #"):
|
||||||
|
try:
|
||||||
|
feature_id = int(re.search(r'#(\d+)', line).group(1))
|
||||||
|
return await self._handle_agent_start(feature_id, line)
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
pass
|
||||||
|
elif line.startswith("Feature #") and ("completed" in line or "failed" in line):
|
||||||
|
try:
|
||||||
|
feature_id = int(re.search(r'#(\d+)', line).group(1))
|
||||||
|
is_success = "completed" in line
|
||||||
|
return await self._handle_agent_complete(feature_id, is_success)
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
feature_id = int(match.group(1))
|
||||||
|
content = match.group(2)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
# Ensure agent is tracked
|
||||||
|
if feature_id not in self.active_agents:
|
||||||
|
agent_index = self._next_agent_index
|
||||||
|
self._next_agent_index += 1
|
||||||
|
self.active_agents[feature_id] = {
|
||||||
|
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
||||||
|
'agent_index': agent_index,
|
||||||
|
'state': 'thinking',
|
||||||
|
'feature_name': f'Feature #{feature_id}',
|
||||||
|
'last_thought': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = self.active_agents[feature_id]
|
||||||
|
|
||||||
|
# Detect state and thought from content
|
||||||
|
state = 'working'
|
||||||
|
thought = None
|
||||||
|
|
||||||
|
for pattern, detected_state in THOUGHT_PATTERNS:
|
||||||
|
m = pattern.search(content)
|
||||||
|
if m:
|
||||||
|
state = detected_state
|
||||||
|
thought = m.group(1) if m.lastindex else content[:100]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only emit update if state changed or we have a new thought
|
||||||
|
if state != agent['state'] or thought != agent['last_thought']:
|
||||||
|
agent['state'] = state
|
||||||
|
if thought:
|
||||||
|
agent['last_thought'] = thought
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'agent_update',
|
||||||
|
'agentIndex': agent['agent_index'],
|
||||||
|
'agentName': agent['name'],
|
||||||
|
'featureId': feature_id,
|
||||||
|
'featureName': agent['feature_name'],
|
||||||
|
'state': state,
|
||||||
|
'thought': thought,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_agent_info(self, feature_id: int) -> tuple[int | None, str | None]:
|
||||||
|
"""Get agent index and name for a feature ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (agentIndex, agentName) or (None, None) if not tracked.
|
||||||
|
"""
|
||||||
|
agent = self.active_agents.get(feature_id)
|
||||||
|
if agent:
|
||||||
|
return agent['agent_index'], agent['name']
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def _handle_agent_start(self, feature_id: int, line: str) -> dict | None:
|
||||||
|
"""Handle agent start message from orchestrator."""
|
||||||
|
async with self._lock:
|
||||||
|
agent_index = self._next_agent_index
|
||||||
|
self._next_agent_index += 1
|
||||||
|
|
||||||
|
# Try to extract feature name from line
|
||||||
|
feature_name = f'Feature #{feature_id}'
|
||||||
|
name_match = re.search(r'#\d+:\s*(.+)$', line)
|
||||||
|
if name_match:
|
||||||
|
feature_name = name_match.group(1)
|
||||||
|
|
||||||
|
self.active_agents[feature_id] = {
|
||||||
|
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
||||||
|
'agent_index': agent_index,
|
||||||
|
'state': 'thinking',
|
||||||
|
'feature_name': feature_name,
|
||||||
|
'last_thought': 'Starting work...',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'agent_update',
|
||||||
|
'agentIndex': agent_index,
|
||||||
|
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
|
||||||
|
'featureId': feature_id,
|
||||||
|
'featureName': feature_name,
|
||||||
|
'state': 'thinking',
|
||||||
|
'thought': 'Starting work...',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _handle_agent_complete(self, feature_id: int, is_success: bool) -> dict | None:
|
||||||
|
"""Handle agent completion message from orchestrator."""
|
||||||
|
async with self._lock:
|
||||||
|
if feature_id not in self.active_agents:
|
||||||
|
return None
|
||||||
|
|
||||||
|
agent = self.active_agents[feature_id]
|
||||||
|
state = 'success' if is_success else 'error'
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'type': 'agent_update',
|
||||||
|
'agentIndex': agent['agent_index'],
|
||||||
|
'agentName': agent['name'],
|
||||||
|
'featureId': feature_id,
|
||||||
|
'featureName': agent['feature_name'],
|
||||||
|
'state': state,
|
||||||
|
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove from active agents
|
||||||
|
del self.active_agents[feature_id]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_project_path(project_name: str) -> Path:
|
def _get_project_path(project_name: str) -> Path:
|
||||||
"""Get project path from registry."""
|
"""Get project path from registry."""
|
||||||
@@ -171,14 +343,38 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
# Get agent manager and register callbacks
|
# Get agent manager and register callbacks
|
||||||
agent_manager = get_manager(project_name, project_dir, ROOT_DIR)
|
agent_manager = get_manager(project_name, project_dir, ROOT_DIR)
|
||||||
|
|
||||||
|
# Create agent tracker for multi-agent mode
|
||||||
|
agent_tracker = AgentTracker()
|
||||||
|
|
||||||
async def on_output(line: str):
|
async def on_output(line: str):
|
||||||
"""Handle agent output - broadcast to this WebSocket."""
|
"""Handle agent output - broadcast to this WebSocket."""
|
||||||
try:
|
try:
|
||||||
await websocket.send_json({
|
# Extract feature ID from line if present
|
||||||
|
feature_id = None
|
||||||
|
agent_index = None
|
||||||
|
match = FEATURE_ID_PATTERN.match(line)
|
||||||
|
if match:
|
||||||
|
feature_id = int(match.group(1))
|
||||||
|
agent_index, _ = agent_tracker.get_agent_info(feature_id)
|
||||||
|
|
||||||
|
# Send the raw log line with optional feature/agent attribution
|
||||||
|
log_msg = {
|
||||||
"type": "log",
|
"type": "log",
|
||||||
"line": line,
|
"line": line,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
})
|
}
|
||||||
|
if feature_id is not None:
|
||||||
|
log_msg["featureId"] = feature_id
|
||||||
|
if agent_index is not None:
|
||||||
|
log_msg["agentIndex"] = agent_index
|
||||||
|
|
||||||
|
await websocket.send_json(log_msg)
|
||||||
|
|
||||||
|
# Check if this line indicates agent activity (parallel mode)
|
||||||
|
# and emit agent_update messages if so
|
||||||
|
agent_update = await agent_tracker.process_line(line)
|
||||||
|
if agent_update:
|
||||||
|
await websocket.send_json(agent_update)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Connection may be closed
|
pass # Connection may be closed
|
||||||
|
|
||||||
|
|||||||
264
ui/package-lock.json
generated
264
ui/package-lock.json
generated
@@ -15,8 +15,10 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@tailwindcss/vite": "^4.0.0-beta.4",
|
"@tailwindcss/vite": "^4.0.0-beta.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -2299,6 +2302,62 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/dagre": {
|
||||||
|
"version": "0.7.53",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
|
||||||
|
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2652,6 +2711,38 @@
|
|||||||
"addons/*"
|
"addons/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
|
||||||
|
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.74",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
|
||||||
|
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -2847,6 +2938,12 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -2912,6 +3009,121 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dagre": {
|
||||||
|
"version": "0.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
|
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphlib": "^2.1.8",
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -3370,6 +3582,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/graphlib": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -3824,6 +4045,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -4503,6 +4730,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -4608,6 +4844,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@tailwindcss/vite": "^4.0.0-beta.4",
|
"@tailwindcss/vite": "^4.0.0-beta.4",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
|
|||||||
133
ui/src/App.tsx
133
ui/src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient, useQuery } from '@tanstack/react-query'
|
||||||
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects'
|
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects'
|
||||||
import { useProjectWebSocket } from './hooks/useWebSocket'
|
import { useProjectWebSocket } from './hooks/useWebSocket'
|
||||||
import { useFeatureSound } from './hooks/useFeatureSound'
|
import { useFeatureSound } from './hooks/useFeatureSound'
|
||||||
@@ -13,16 +13,23 @@ import { AddFeatureForm } from './components/AddFeatureForm'
|
|||||||
import { FeatureModal } from './components/FeatureModal'
|
import { FeatureModal } from './components/FeatureModal'
|
||||||
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
|
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
|
||||||
import { AgentThought } from './components/AgentThought'
|
import { AgentThought } from './components/AgentThought'
|
||||||
|
import { AgentMissionControl } from './components/AgentMissionControl'
|
||||||
|
import { CelebrationOverlay } from './components/CelebrationOverlay'
|
||||||
import { AssistantFAB } from './components/AssistantFAB'
|
import { AssistantFAB } from './components/AssistantFAB'
|
||||||
import { AssistantPanel } from './components/AssistantPanel'
|
import { AssistantPanel } from './components/AssistantPanel'
|
||||||
import { ExpandProjectModal } from './components/ExpandProjectModal'
|
import { ExpandProjectModal } from './components/ExpandProjectModal'
|
||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
import { DevServerControl } from './components/DevServerControl'
|
import { DevServerControl } from './components/DevServerControl'
|
||||||
|
import { ViewToggle, type ViewMode } from './components/ViewToggle'
|
||||||
|
import { DependencyGraph } from './components/DependencyGraph'
|
||||||
|
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
|
||||||
|
import { getDependencyGraph } from './lib/api'
|
||||||
import { Loader2, Settings, Moon, Sun } from 'lucide-react'
|
import { Loader2, Settings, Moon, Sun } from 'lucide-react'
|
||||||
import type { Feature } from './lib/types'
|
import type { Feature } from './lib/types'
|
||||||
|
|
||||||
const STORAGE_KEY = 'autocoder-selected-project'
|
const STORAGE_KEY = 'autocoder-selected-project'
|
||||||
const DARK_MODE_KEY = 'autocoder-dark-mode'
|
const DARK_MODE_KEY = 'autocoder-dark-mode'
|
||||||
|
const VIEW_MODE_KEY = 'autocoder-view-mode'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Initialize selected project from localStorage
|
// Initialize selected project from localStorage
|
||||||
@@ -42,6 +49,7 @@ function App() {
|
|||||||
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
|
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
|
||||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
|
||||||
const [isSpecCreating, setIsSpecCreating] = useState(false)
|
const [isSpecCreating, setIsSpecCreating] = useState(false)
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +58,14 @@ function App() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(VIEW_MODE_KEY)
|
||||||
|
return (stored === 'graph' ? 'graph' : 'kanban') as ViewMode
|
||||||
|
} catch {
|
||||||
|
return 'kanban'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||||
@@ -58,6 +74,14 @@ function App() {
|
|||||||
useAgentStatus(selectedProject) // Keep polling for status updates
|
useAgentStatus(selectedProject) // Keep polling for status updates
|
||||||
const wsState = useProjectWebSocket(selectedProject)
|
const wsState = useProjectWebSocket(selectedProject)
|
||||||
|
|
||||||
|
// Fetch graph data when in graph view
|
||||||
|
const { data: graphData } = useQuery({
|
||||||
|
queryKey: ['dependencyGraph', selectedProject],
|
||||||
|
queryFn: () => getDependencyGraph(selectedProject!),
|
||||||
|
enabled: !!selectedProject && viewMode === 'graph',
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
|
})
|
||||||
|
|
||||||
// Apply dark mode class to document
|
// Apply dark mode class to document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@@ -72,6 +96,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [darkMode])
|
}, [darkMode])
|
||||||
|
|
||||||
|
// Persist view mode to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_MODE_KEY, viewMode)
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}, [viewMode])
|
||||||
|
|
||||||
// Play sounds when features move between columns
|
// Play sounds when features move between columns
|
||||||
useFeatureSound(features)
|
useFeatureSound(features)
|
||||||
|
|
||||||
@@ -154,9 +187,23 @@ function App() {
|
|||||||
setShowSettings(true)
|
setShowSettings(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// G : Toggle between Kanban and Graph view (when project selected)
|
||||||
|
if ((e.key === 'g' || e.key === 'G') && selectedProject) {
|
||||||
|
e.preventDefault()
|
||||||
|
setViewMode(prev => prev === 'kanban' ? 'graph' : 'kanban')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ? : Show keyboard shortcuts help
|
||||||
|
if (e.key === '?') {
|
||||||
|
e.preventDefault()
|
||||||
|
setShowKeyboardHelp(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Escape : Close modals
|
// Escape : Close modals
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (showExpandProject) {
|
if (showKeyboardHelp) {
|
||||||
|
setShowKeyboardHelp(false)
|
||||||
|
} else if (showExpandProject) {
|
||||||
setShowExpandProject(false)
|
setShowExpandProject(false)
|
||||||
} else if (showSettings) {
|
} else if (showSettings) {
|
||||||
setShowSettings(false)
|
setShowSettings(false)
|
||||||
@@ -174,7 +221,7 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating])
|
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode])
|
||||||
|
|
||||||
// Combine WebSocket progress with feature data
|
// Combine WebSocket progress with feature data
|
||||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||||
@@ -284,11 +331,21 @@ function App() {
|
|||||||
isConnected={wsState.isConnected}
|
isConnected={wsState.isConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Agent Thought - shows latest agent narrative */}
|
{/* Agent Mission Control - shows active agents in parallel mode */}
|
||||||
<AgentThought
|
{wsState.activeAgents.length > 0 && (
|
||||||
logs={wsState.logs}
|
<AgentMissionControl
|
||||||
agentStatus={wsState.agentStatus}
|
agents={wsState.activeAgents}
|
||||||
/>
|
recentActivity={wsState.recentActivity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent Thought - shows latest agent narrative (single agent mode) */}
|
||||||
|
{wsState.activeAgents.length === 0 && (
|
||||||
|
<AgentThought
|
||||||
|
logs={wsState.logs}
|
||||||
|
agentStatus={wsState.agentStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Initializing Features State - show when agent is running but no features yet */}
|
{/* Initializing Features State - show when agent is running but no features yet */}
|
||||||
{features &&
|
{features &&
|
||||||
@@ -307,13 +364,45 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Kanban Board */}
|
{/* View Toggle - only show when there are features */}
|
||||||
<KanbanBoard
|
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
|
||||||
features={features}
|
<div className="flex justify-center">
|
||||||
onFeatureClick={setSelectedFeature}
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
onAddFeature={() => setShowAddFeature(true)}
|
</div>
|
||||||
onExpandProject={() => setShowExpandProject(true)}
|
)}
|
||||||
/>
|
|
||||||
|
{/* Kanban Board or Dependency Graph based on view mode */}
|
||||||
|
{viewMode === 'kanban' ? (
|
||||||
|
<KanbanBoard
|
||||||
|
features={features}
|
||||||
|
onFeatureClick={setSelectedFeature}
|
||||||
|
onAddFeature={() => setShowAddFeature(true)}
|
||||||
|
onExpandProject={() => setShowExpandProject(true)}
|
||||||
|
activeAgents={wsState.activeAgents}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="neo-card overflow-hidden" style={{ height: '600px' }}>
|
||||||
|
{graphData ? (
|
||||||
|
<DependencyGraph
|
||||||
|
graphData={graphData}
|
||||||
|
onNodeClick={(nodeId) => {
|
||||||
|
// Find the feature and open the modal
|
||||||
|
const allFeatures = [
|
||||||
|
...(features?.pending ?? []),
|
||||||
|
...(features?.in_progress ?? []),
|
||||||
|
...(features?.done ?? [])
|
||||||
|
]
|
||||||
|
const feature = allFeatures.find(f => f.id === nodeId)
|
||||||
|
if (feature) setSelectedFeature(feature)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<Loader2 size={32} className="animate-spin text-neo-progress" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -383,6 +472,20 @@ function App() {
|
|||||||
{showSettings && (
|
{showSettings && (
|
||||||
<SettingsModal onClose={() => setShowSettings(false)} />
|
<SettingsModal onClose={() => setShowSettings(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Help */}
|
||||||
|
{showKeyboardHelp && (
|
||||||
|
<KeyboardShortcutsHelp onClose={() => setShowKeyboardHelp(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Celebration Overlay - shows when a feature is completed by an agent */}
|
||||||
|
{wsState.celebration && (
|
||||||
|
<CelebrationOverlay
|
||||||
|
agentName={wsState.celebration.agentName}
|
||||||
|
featureName={wsState.celebration.featureName}
|
||||||
|
onComplete={wsState.clearCelebration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
93
ui/src/components/ActivityFeed.tsx
Normal file
93
ui/src/components/ActivityFeed.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Activity } from 'lucide-react'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import type { AgentMascot } from '../lib/types'
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
agentName: string
|
||||||
|
thought: string
|
||||||
|
timestamp: string
|
||||||
|
featureId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityFeedProps {
|
||||||
|
activities: ActivityItem[]
|
||||||
|
maxItems?: number
|
||||||
|
showHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string): string {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffSec = Math.floor(diffMs / 1000)
|
||||||
|
|
||||||
|
if (diffSec < 5) return 'just now'
|
||||||
|
if (diffSec < 60) return `${diffSec}s ago`
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: ActivityFeedProps) {
|
||||||
|
const displayedActivities = activities.slice(0, maxItems)
|
||||||
|
|
||||||
|
if (displayedActivities.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{showHeader && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Activity size={14} className="text-neo-text-secondary" />
|
||||||
|
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
|
||||||
|
Recent Activity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{displayedActivities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={`${activity.featureId}-${activity.timestamp}-${activity.thought.slice(0, 20)}`}
|
||||||
|
className="flex items-start gap-2 py-1.5 px-2 rounded bg-[var(--color-neo-bg)] border border-neo-border/20"
|
||||||
|
>
|
||||||
|
<AgentAvatar
|
||||||
|
name={activity.agentName as AgentMascot}
|
||||||
|
state="working"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-bold" style={{
|
||||||
|
color: getMascotColor(activity.agentName as AgentMascot)
|
||||||
|
}}>
|
||||||
|
{activity.agentName}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-neo-text-muted">
|
||||||
|
#{activity.featureId}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-neo-text-muted ml-auto">
|
||||||
|
{formatTimestamp(activity.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neo-text-secondary truncate" title={activity.thought}>
|
||||||
|
{activity.thought}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMascotColor(name: AgentMascot): string {
|
||||||
|
const colors: Record<AgentMascot, string> = {
|
||||||
|
Spark: '#3B82F6',
|
||||||
|
Fizz: '#F97316',
|
||||||
|
Octo: '#8B5CF6',
|
||||||
|
Hoot: '#22C55E',
|
||||||
|
Buzz: '#EAB308',
|
||||||
|
}
|
||||||
|
return colors[name] || '#6B7280'
|
||||||
|
}
|
||||||
261
ui/src/components/AgentAvatar.tsx
Normal file
261
ui/src/components/AgentAvatar.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { type AgentMascot, type AgentState } from '../lib/types'
|
||||||
|
|
||||||
|
interface AgentAvatarProps {
|
||||||
|
name: AgentMascot
|
||||||
|
state: AgentState
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
showName?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_COLORS: Record<AgentMascot, { primary: string; secondary: string; accent: string }> = {
|
||||||
|
Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot
|
||||||
|
Fizz: { primary: '#F97316', secondary: '#FB923C', accent: '#FFEDD5' }, // Orange fox
|
||||||
|
Octo: { primary: '#8B5CF6', secondary: '#A78BFA', accent: '#EDE9FE' }, // Purple octopus
|
||||||
|
Hoot: { primary: '#22C55E', secondary: '#4ADE80', accent: '#DCFCE7' }, // Green owl
|
||||||
|
Buzz: { primary: '#EAB308', secondary: '#FACC15', accent: '#FEF9C3' }, // Yellow bee
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
sm: { svg: 32, font: 'text-xs' },
|
||||||
|
md: { svg: 48, font: 'text-sm' },
|
||||||
|
lg: { svg: 64, font: 'text-base' },
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG mascot definitions - simple cute characters
|
||||||
|
function SparkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Spark; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||||
|
{/* Robot body */}
|
||||||
|
<rect x="16" y="20" width="32" height="28" rx="4" fill={colors.primary} />
|
||||||
|
{/* Robot head */}
|
||||||
|
<rect x="12" y="8" width="40" height="24" rx="4" fill={colors.secondary} />
|
||||||
|
{/* Antenna */}
|
||||||
|
<circle cx="32" cy="4" r="4" fill={colors.primary} className="animate-pulse" />
|
||||||
|
<rect x="30" y="4" width="4" height="8" fill={colors.primary} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<circle cx="24" cy="18" r="4" fill="white" />
|
||||||
|
<circle cx="40" cy="18" r="4" fill="white" />
|
||||||
|
<circle cx="25" cy="18" r="2" fill={colors.primary} />
|
||||||
|
<circle cx="41" cy="18" r="2" fill={colors.primary} />
|
||||||
|
{/* Mouth */}
|
||||||
|
<rect x="26" y="24" width="12" height="2" rx="1" fill="white" />
|
||||||
|
{/* Arms */}
|
||||||
|
<rect x="6" y="24" width="8" height="4" rx="2" fill={colors.primary} />
|
||||||
|
<rect x="50" y="24" width="8" height="4" rx="2" fill={colors.primary} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FizzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Fizz; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||||
|
{/* Ears */}
|
||||||
|
<polygon points="12,12 20,28 4,28" fill={colors.primary} />
|
||||||
|
<polygon points="52,12 60,28 44,28" fill={colors.primary} />
|
||||||
|
<polygon points="14,14 18,26 8,26" fill={colors.accent} />
|
||||||
|
<polygon points="50,14 56,26 44,26" fill={colors.accent} />
|
||||||
|
{/* Head */}
|
||||||
|
<ellipse cx="32" cy="36" rx="24" ry="22" fill={colors.primary} />
|
||||||
|
{/* Face */}
|
||||||
|
<ellipse cx="32" cy="40" rx="18" ry="14" fill={colors.accent} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<ellipse cx="24" cy="32" rx="4" ry="5" fill="white" />
|
||||||
|
<ellipse cx="40" cy="32" rx="4" ry="5" fill="white" />
|
||||||
|
<circle cx="25" cy="33" r="2" fill="#1a1a1a" />
|
||||||
|
<circle cx="41" cy="33" r="2" fill="#1a1a1a" />
|
||||||
|
{/* Nose */}
|
||||||
|
<ellipse cx="32" cy="42" rx="4" ry="3" fill={colors.primary} />
|
||||||
|
{/* Whiskers */}
|
||||||
|
<line x1="8" y1="38" x2="18" y2="40" stroke={colors.primary} strokeWidth="2" />
|
||||||
|
<line x1="8" y1="44" x2="18" y2="44" stroke={colors.primary} strokeWidth="2" />
|
||||||
|
<line x1="46" y1="40" x2="56" y2="38" stroke={colors.primary} strokeWidth="2" />
|
||||||
|
<line x1="46" y1="44" x2="56" y2="44" stroke={colors.primary} strokeWidth="2" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OctoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Octo; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||||
|
{/* Tentacles */}
|
||||||
|
<path d="M12,48 Q8,56 12,60 Q16,64 20,58" fill={colors.secondary} />
|
||||||
|
<path d="M22,50 Q20,58 24,62" fill={colors.secondary} />
|
||||||
|
<path d="M32,52 Q32,60 36,62" fill={colors.secondary} />
|
||||||
|
<path d="M42,50 Q44,58 40,62" fill={colors.secondary} />
|
||||||
|
<path d="M52,48 Q56,56 52,60 Q48,64 44,58" fill={colors.secondary} />
|
||||||
|
{/* Head */}
|
||||||
|
<ellipse cx="32" cy="32" rx="22" ry="24" fill={colors.primary} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<ellipse cx="24" cy="28" rx="6" ry="8" fill="white" />
|
||||||
|
<ellipse cx="40" cy="28" rx="6" ry="8" fill="white" />
|
||||||
|
<ellipse cx="25" cy="30" rx="3" ry="4" fill={colors.primary} />
|
||||||
|
<ellipse cx="41" cy="30" rx="3" ry="4" fill={colors.primary} />
|
||||||
|
{/* Smile */}
|
||||||
|
<path d="M24,42 Q32,48 40,42" stroke={colors.accent} strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HootSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Hoot; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||||
|
{/* Ear tufts */}
|
||||||
|
<polygon points="14,8 22,24 6,20" fill={colors.primary} />
|
||||||
|
<polygon points="50,8 58,20 42,24" fill={colors.primary} />
|
||||||
|
{/* Body */}
|
||||||
|
<ellipse cx="32" cy="40" rx="20" ry="18" fill={colors.primary} />
|
||||||
|
{/* Head */}
|
||||||
|
<circle cx="32" cy="28" r="20" fill={colors.secondary} />
|
||||||
|
{/* Eye circles */}
|
||||||
|
<circle cx="24" cy="26" r="10" fill={colors.accent} />
|
||||||
|
<circle cx="40" cy="26" r="10" fill={colors.accent} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<circle cx="24" cy="26" r="6" fill="white" />
|
||||||
|
<circle cx="40" cy="26" r="6" fill="white" />
|
||||||
|
<circle cx="25" cy="27" r="3" fill="#1a1a1a" />
|
||||||
|
<circle cx="41" cy="27" r="3" fill="#1a1a1a" />
|
||||||
|
{/* Beak */}
|
||||||
|
<polygon points="32,32 28,40 36,40" fill="#F97316" />
|
||||||
|
{/* Belly */}
|
||||||
|
<ellipse cx="32" cy="46" rx="10" ry="8" fill={colors.accent} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||||
|
{/* Wings */}
|
||||||
|
<ellipse cx="14" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
|
||||||
|
<ellipse cx="50" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
|
||||||
|
{/* Body stripes */}
|
||||||
|
<ellipse cx="32" cy="36" rx="14" ry="20" fill={colors.primary} />
|
||||||
|
<ellipse cx="32" cy="30" rx="12" ry="6" fill="#1a1a1a" />
|
||||||
|
<ellipse cx="32" cy="44" rx="12" ry="6" fill="#1a1a1a" />
|
||||||
|
{/* Head */}
|
||||||
|
<circle cx="32" cy="16" r="12" fill={colors.primary} />
|
||||||
|
{/* Antennae */}
|
||||||
|
<line x1="26" y1="8" x2="22" y2="2" stroke="#1a1a1a" strokeWidth="2" />
|
||||||
|
<line x1="38" y1="8" x2="42" y2="2" stroke="#1a1a1a" strokeWidth="2" />
|
||||||
|
<circle cx="22" cy="2" r="2" fill="#1a1a1a" />
|
||||||
|
<circle cx="42" cy="2" r="2" fill="#1a1a1a" />
|
||||||
|
{/* Eyes */}
|
||||||
|
<circle cx="28" cy="14" r="4" fill="white" />
|
||||||
|
<circle cx="36" cy="14" r="4" fill="white" />
|
||||||
|
<circle cx="29" cy="15" r="2" fill="#1a1a1a" />
|
||||||
|
<circle cx="37" cy="15" r="2" fill="#1a1a1a" />
|
||||||
|
{/* Smile */}
|
||||||
|
<path d="M28,20 Q32,24 36,20" stroke="#1a1a1a" strokeWidth="1.5" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASCOT_SVGS: Record<AgentMascot, typeof SparkSVG> = {
|
||||||
|
Spark: SparkSVG,
|
||||||
|
Fizz: FizzSVG,
|
||||||
|
Octo: OctoSVG,
|
||||||
|
Hoot: HootSVG,
|
||||||
|
Buzz: BuzzSVG,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation classes based on state
|
||||||
|
function getStateAnimation(state: AgentState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle':
|
||||||
|
return 'animate-bounce-gentle'
|
||||||
|
case 'thinking':
|
||||||
|
return 'animate-thinking'
|
||||||
|
case 'working':
|
||||||
|
return 'animate-working'
|
||||||
|
case 'testing':
|
||||||
|
return 'animate-testing'
|
||||||
|
case 'success':
|
||||||
|
return 'animate-celebrate'
|
||||||
|
case 'error':
|
||||||
|
case 'struggling':
|
||||||
|
return 'animate-shake-gentle'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glow effect based on state
|
||||||
|
function getStateGlow(state: AgentState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'working':
|
||||||
|
return 'shadow-[0_0_12px_rgba(0,180,216,0.5)]'
|
||||||
|
case 'thinking':
|
||||||
|
return 'shadow-[0_0_8px_rgba(255,214,10,0.4)]'
|
||||||
|
case 'success':
|
||||||
|
return 'shadow-[0_0_16px_rgba(112,224,0,0.6)]'
|
||||||
|
case 'error':
|
||||||
|
case 'struggling':
|
||||||
|
return 'shadow-[0_0_12px_rgba(255,84,0,0.5)]'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get human-readable state description for accessibility
|
||||||
|
function getStateDescription(state: AgentState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle':
|
||||||
|
return 'waiting'
|
||||||
|
case 'thinking':
|
||||||
|
return 'analyzing'
|
||||||
|
case 'working':
|
||||||
|
return 'coding'
|
||||||
|
case 'testing':
|
||||||
|
return 'running tests'
|
||||||
|
case 'success':
|
||||||
|
return 'completed successfully'
|
||||||
|
case 'error':
|
||||||
|
return 'encountered an error'
|
||||||
|
case 'struggling':
|
||||||
|
return 'having difficulty'
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) {
|
||||||
|
const colors = AVATAR_COLORS[name]
|
||||||
|
const { svg: svgSize, font } = SIZES[size]
|
||||||
|
const SvgComponent = MASCOT_SVGS[name]
|
||||||
|
const stateDesc = getStateDescription(state)
|
||||||
|
const ariaLabel = `Agent ${name} is ${stateDesc}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-1"
|
||||||
|
role="status"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
rounded-full p-1 transition-all duration-300
|
||||||
|
${getStateAnimation(state)}
|
||||||
|
${getStateGlow(state)}
|
||||||
|
`}
|
||||||
|
style={{ backgroundColor: colors.accent }}
|
||||||
|
title={ariaLabel}
|
||||||
|
role="img"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<SvgComponent colors={colors} size={svgSize} />
|
||||||
|
</div>
|
||||||
|
{showName && (
|
||||||
|
<span className={`${font} font-bold text-neo-text`} style={{ color: colors.primary }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mascot name by index (cycles through available mascots)
|
||||||
|
export function getMascotName(index: number): AgentMascot {
|
||||||
|
const mascots: AgentMascot[] = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz']
|
||||||
|
return mascots[index % mascots.length]
|
||||||
|
}
|
||||||
99
ui/src/components/AgentCard.tsx
Normal file
99
ui/src/components/AgentCard.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import type { ActiveAgent } from '../lib/types'
|
||||||
|
|
||||||
|
interface AgentCardProps {
|
||||||
|
agent: ActiveAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a friendly state description
|
||||||
|
function getStateText(state: ActiveAgent['state']): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'idle':
|
||||||
|
return 'Waiting...'
|
||||||
|
case 'thinking':
|
||||||
|
return 'Thinking...'
|
||||||
|
case 'working':
|
||||||
|
return 'Coding...'
|
||||||
|
case 'testing':
|
||||||
|
return 'Testing...'
|
||||||
|
case 'success':
|
||||||
|
return 'Done!'
|
||||||
|
case 'error':
|
||||||
|
return 'Hit an issue'
|
||||||
|
case 'struggling':
|
||||||
|
return 'Retrying...'
|
||||||
|
default:
|
||||||
|
return 'Working...'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get state color
|
||||||
|
function getStateColor(state: ActiveAgent['state']): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'success':
|
||||||
|
return 'text-neo-done'
|
||||||
|
case 'error':
|
||||||
|
case 'struggling':
|
||||||
|
return 'text-neo-danger'
|
||||||
|
case 'working':
|
||||||
|
case 'testing':
|
||||||
|
return 'text-neo-progress'
|
||||||
|
case 'thinking':
|
||||||
|
return 'text-neo-pending'
|
||||||
|
default:
|
||||||
|
return 'text-neo-text-secondary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentCard({ agent }: AgentCardProps) {
|
||||||
|
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
neo-card p-3 min-w-[180px] max-w-[220px]
|
||||||
|
${isActive ? 'animate-pulse-neo' : ''}
|
||||||
|
transition-all duration-300
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header with avatar and name */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-display font-bold text-sm truncate">
|
||||||
|
{agent.agentName}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs ${getStateColor(agent.state)}`}>
|
||||||
|
{getStateText(agent.state)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature info */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-xs text-neo-text-secondary mb-0.5">
|
||||||
|
Feature #{agent.featureId}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium truncate" title={agent.featureName}>
|
||||||
|
{agent.featureName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thought bubble */}
|
||||||
|
{agent.thought && (
|
||||||
|
<div className="relative mt-2 pt-2 border-t-2 border-neo-border/30">
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<MessageCircle size={14} className="text-neo-progress shrink-0 mt-0.5" />
|
||||||
|
<p
|
||||||
|
className="text-xs text-neo-text-secondary line-clamp-2 italic"
|
||||||
|
title={agent.thought}
|
||||||
|
>
|
||||||
|
{agent.thought}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Play, Square, Loader2 } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { Play, Square, Loader2, GitBranch } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useStartAgent,
|
useStartAgent,
|
||||||
useStopAgent,
|
useStopAgent,
|
||||||
@@ -15,19 +16,57 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
|
|||||||
const { data: settings } = useSettings()
|
const { data: settings } = useSettings()
|
||||||
const yoloMode = settings?.yolo_mode ?? false
|
const yoloMode = settings?.yolo_mode ?? false
|
||||||
|
|
||||||
|
// Concurrency: 1 = single agent, 2-5 = parallel
|
||||||
|
const [concurrency, setConcurrency] = useState(3)
|
||||||
|
|
||||||
const startAgent = useStartAgent(projectName)
|
const startAgent = useStartAgent(projectName)
|
||||||
const stopAgent = useStopAgent(projectName)
|
const stopAgent = useStopAgent(projectName)
|
||||||
|
|
||||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
const isLoading = startAgent.isPending || stopAgent.isPending
|
||||||
|
const isRunning = status === 'running' || status === 'paused'
|
||||||
|
const isParallel = concurrency > 1
|
||||||
|
|
||||||
const handleStart = () => startAgent.mutate(yoloMode)
|
const handleStart = () => startAgent.mutate({
|
||||||
|
yoloMode,
|
||||||
|
parallelMode: isParallel,
|
||||||
|
maxConcurrency: isParallel ? concurrency : undefined,
|
||||||
|
})
|
||||||
const handleStop = () => stopAgent.mutate()
|
const handleStop = () => stopAgent.mutate()
|
||||||
|
|
||||||
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
|
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
|
||||||
const isStopped = status === 'stopped' || status === 'crashed'
|
const isStopped = status === 'stopped' || status === 'crashed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Concurrency slider - always visible when stopped */}
|
||||||
|
{isStopped && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={concurrency}
|
||||||
|
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
|
||||||
|
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
|
||||||
|
aria-label="Set number of concurrent agents"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold min-w-[1.5rem] text-center">
|
||||||
|
{concurrency}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show concurrency indicator when running with multiple agents */}
|
||||||
|
{isRunning && isParallel && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
|
||||||
|
<GitBranch size={14} />
|
||||||
|
<span>{concurrency}x</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
|
|||||||
121
ui/src/components/AgentMissionControl.tsx
Normal file
121
ui/src/components/AgentMissionControl.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { AgentCard } from './AgentCard'
|
||||||
|
import { ActivityFeed } from './ActivityFeed'
|
||||||
|
import type { ActiveAgent } from '../lib/types'
|
||||||
|
|
||||||
|
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
||||||
|
|
||||||
|
interface AgentMissionControlProps {
|
||||||
|
agents: ActiveAgent[]
|
||||||
|
recentActivity: Array<{
|
||||||
|
agentName: string
|
||||||
|
thought: string
|
||||||
|
timestamp: string
|
||||||
|
featureId: number
|
||||||
|
}>
|
||||||
|
isExpanded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentMissionControl({
|
||||||
|
agents,
|
||||||
|
recentActivity,
|
||||||
|
isExpanded: defaultExpanded = true,
|
||||||
|
}: AgentMissionControlProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||||
|
const [activityCollapsed, setActivityCollapsed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(ACTIVITY_COLLAPSED_KEY) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleActivityCollapsed = () => {
|
||||||
|
const newValue = !activityCollapsed
|
||||||
|
setActivityCollapsed(newValue)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ACTIVITY_COLLAPSED_KEY, String(newValue))
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if no agents
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="neo-card mb-6 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--color-neo-progress)] hover:brightness-105 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rocket size={20} className="text-neo-text-on-bright" />
|
||||||
|
<span className="font-display font-bold text-neo-text-on-bright uppercase tracking-wide">
|
||||||
|
Mission Control
|
||||||
|
</span>
|
||||||
|
<span className="neo-badge neo-badge-sm bg-white text-neo-text ml-2">
|
||||||
|
{agents.length} {agents.length === 1 ? 'agent' : 'agents'} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp size={20} className="text-neo-text-on-bright" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={20} className="text-neo-text-on-bright" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
transition-all duration-300 ease-out overflow-hidden
|
||||||
|
${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Agent Cards Row */}
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentCard key={`agent-${agent.agentIndex}`} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Collapsible Activity Feed */}
|
||||||
|
{recentActivity.length > 0 && (
|
||||||
|
<div className="mt-4 pt-4 border-t-2 border-neo-border/30">
|
||||||
|
<button
|
||||||
|
onClick={toggleActivityCollapsed}
|
||||||
|
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Activity size={14} className="text-neo-text-secondary" />
|
||||||
|
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
|
||||||
|
Recent Activity
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neo-muted">
|
||||||
|
({recentActivity.length})
|
||||||
|
</span>
|
||||||
|
{activityCollapsed ? (
|
||||||
|
<ChevronDown size={14} className="text-neo-text-secondary" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={14} className="text-neo-text-secondary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
transition-all duration-200 ease-out overflow-hidden
|
||||||
|
${activityCollapsed ? 'max-h-0 opacity-0' : 'max-h-[300px] opacity-100'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ActivityFeed activities={recentActivity} maxItems={5} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,14 +25,14 @@ function isAgentThought(line: string): boolean {
|
|||||||
|
|
||||||
// Skip JSON and very short lines
|
// Skip JSON and very short lines
|
||||||
if (/^[[{]/.test(trimmed)) return false
|
if (/^[[{]/.test(trimmed)) return false
|
||||||
if (trimmed.length < 15) return false
|
if (trimmed.length < 10) return false
|
||||||
|
|
||||||
// Skip lines that are just paths or technical output
|
// Skip lines that are just paths or technical output
|
||||||
if (/^[A-Za-z]:\\/.test(trimmed)) return false
|
if (/^[A-Za-z]:\\/.test(trimmed)) return false
|
||||||
if (/^\/[a-z]/.test(trimmed)) return false
|
if (/^\/[a-z]/.test(trimmed)) return false
|
||||||
|
|
||||||
// Keep narrative text (starts with capital, looks like a sentence)
|
// Keep narrative text (looks like a sentence, relaxed filter)
|
||||||
return /^[A-Z]/.test(trimmed) && trimmed.length > 20
|
return trimmed.length > 10
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
120
ui/src/components/CelebrationOverlay.tsx
Normal file
120
ui/src/components/CelebrationOverlay.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Sparkles, PartyPopper } from 'lucide-react'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
import type { AgentMascot } from '../lib/types'
|
||||||
|
|
||||||
|
interface CelebrationOverlayProps {
|
||||||
|
agentName: AgentMascot
|
||||||
|
featureName: string
|
||||||
|
onComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random confetti particles
|
||||||
|
function generateConfetti(count: number) {
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: Math.random() * 100,
|
||||||
|
delay: Math.random() * 0.5,
|
||||||
|
duration: 1 + Math.random() * 1,
|
||||||
|
color: ['#ff006e', '#ffd60a', '#70e000', '#00b4d8', '#8338ec'][Math.floor(Math.random() * 5)],
|
||||||
|
rotation: Math.random() * 360,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CelebrationOverlay({ agentName, featureName, onComplete }: CelebrationOverlayProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true)
|
||||||
|
const [confetti] = useState(() => generateConfetti(30))
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => onComplete?.(), 300) // Wait for fade animation
|
||||||
|
}, [onComplete])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-dismiss after 3 seconds
|
||||||
|
const timer = setTimeout(dismiss, 3000)
|
||||||
|
|
||||||
|
// Escape key to dismiss early
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [dismiss])
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed inset-0 z-50 flex items-center justify-center
|
||||||
|
pointer-events-none
|
||||||
|
transition-opacity duration-300
|
||||||
|
${isVisible ? 'opacity-100' : 'opacity-0'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Confetti particles */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
{confetti.map((particle) => (
|
||||||
|
<div
|
||||||
|
key={particle.id}
|
||||||
|
className="absolute w-3 h-3 animate-confetti"
|
||||||
|
style={{
|
||||||
|
left: `${particle.x}%`,
|
||||||
|
top: '-20px',
|
||||||
|
backgroundColor: particle.color,
|
||||||
|
animationDelay: `${particle.delay}s`,
|
||||||
|
animationDuration: `${particle.duration}s`,
|
||||||
|
transform: `rotate(${particle.rotation}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Celebration card - click to dismiss */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
className="neo-card p-6 bg-[var(--color-neo-done)] animate-bounce-in pointer-events-auto cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-neo-accent"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* Icons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles size={24} className="text-neo-pending animate-pulse" />
|
||||||
|
<PartyPopper size={28} className="text-neo-accent" />
|
||||||
|
<Sparkles size={24} className="text-neo-pending animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar celebrating */}
|
||||||
|
<AgentAvatar name={agentName} state="success" size="lg" />
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="font-display text-lg font-bold text-neo-text-on-bright mb-1">
|
||||||
|
Feature Complete!
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neo-text-on-bright/80 max-w-[200px] truncate">
|
||||||
|
{featureName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neo-text-on-bright/60 mt-2">
|
||||||
|
Great job, {agentName}!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dismiss hint */}
|
||||||
|
<p className="text-xs text-neo-text-on-bright/40 mt-1">
|
||||||
|
Click or press Esc to dismiss
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
ui/src/components/DependencyBadge.tsx
Normal file
121
ui/src/components/DependencyBadge.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { AlertTriangle, GitBranch, Check } from 'lucide-react'
|
||||||
|
import type { Feature } from '../lib/types'
|
||||||
|
|
||||||
|
interface DependencyBadgeProps {
|
||||||
|
feature: Feature
|
||||||
|
allFeatures?: Feature[]
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge component showing dependency status for a feature.
|
||||||
|
* Shows:
|
||||||
|
* - Blocked status with count of blocking dependencies
|
||||||
|
* - Dependency count for features with satisfied dependencies
|
||||||
|
* - Nothing if feature has no dependencies
|
||||||
|
*/
|
||||||
|
export function DependencyBadge({ feature, allFeatures = [], compact = false }: DependencyBadgeProps) {
|
||||||
|
const dependencies = feature.dependencies || []
|
||||||
|
|
||||||
|
if (dependencies.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use API-computed blocked status if available, otherwise compute locally
|
||||||
|
const isBlocked = feature.blocked ??
|
||||||
|
(feature.blocking_dependencies && feature.blocking_dependencies.length > 0) ??
|
||||||
|
false
|
||||||
|
|
||||||
|
const blockingCount = feature.blocking_dependencies?.length ?? 0
|
||||||
|
|
||||||
|
// Compute satisfied count from allFeatures if available
|
||||||
|
let satisfiedCount = dependencies.length - blockingCount
|
||||||
|
if (allFeatures.length > 0 && !feature.blocking_dependencies) {
|
||||||
|
const passingIds = new Set(allFeatures.filter(f => f.passes).map(f => f.id))
|
||||||
|
satisfiedCount = dependencies.filter(d => passingIds.has(d)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
// Compact view for card displays
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono
|
||||||
|
${isBlocked
|
||||||
|
? 'bg-neo-danger/20 text-neo-danger'
|
||||||
|
: 'bg-neo-neutral-200 text-neo-text-secondary'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title={isBlocked
|
||||||
|
? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}`
|
||||||
|
: `${satisfiedCount}/${dependencies.length} dependencies satisfied`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isBlocked ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{blockingCount}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitBranch size={12} />
|
||||||
|
<span>{satisfiedCount}/{dependencies.length}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full view with more details
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isBlocked ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-neo-danger">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span className="font-medium">
|
||||||
|
Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-neo-text-secondary">
|
||||||
|
<Check size={14} className="text-neo-done" />
|
||||||
|
<span>
|
||||||
|
All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small inline indicator for dependency status
|
||||||
|
*/
|
||||||
|
export function DependencyIndicator({ feature }: { feature: Feature }) {
|
||||||
|
const dependencies = feature.dependencies || []
|
||||||
|
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
|
||||||
|
|
||||||
|
if (dependencies.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBlocked) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-danger/20 text-neo-danger"
|
||||||
|
title={`Blocked by ${feature.blocking_dependencies?.length || 0} dependencies`}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-neutral-200 text-neo-text-secondary"
|
||||||
|
title={`${dependencies.length} dependencies (all satisfied)`}
|
||||||
|
>
|
||||||
|
<GitBranch size={12} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
289
ui/src/components/DependencyGraph.tsx
Normal file
289
ui/src/components/DependencyGraph.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
Position,
|
||||||
|
MarkerType,
|
||||||
|
ConnectionMode,
|
||||||
|
Handle,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import dagre from 'dagre'
|
||||||
|
import { CheckCircle2, Circle, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
|
import type { DependencyGraph as DependencyGraphData, GraphNode } from '../lib/types'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
// Node dimensions
|
||||||
|
const NODE_WIDTH = 220
|
||||||
|
const NODE_HEIGHT = 80
|
||||||
|
|
||||||
|
interface DependencyGraphProps {
|
||||||
|
graphData: DependencyGraphData
|
||||||
|
onNodeClick?: (nodeId: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom node component
|
||||||
|
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void } }) {
|
||||||
|
const statusColors = {
|
||||||
|
pending: 'bg-neo-pending border-neo-border',
|
||||||
|
in_progress: 'bg-neo-progress border-neo-border',
|
||||||
|
done: 'bg-neo-done border-neo-border',
|
||||||
|
blocked: 'bg-neo-danger/20 border-neo-danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusIcon = () => {
|
||||||
|
switch (data.status) {
|
||||||
|
case 'done':
|
||||||
|
return <CheckCircle2 size={16} className="text-neo-text-on-bright" />
|
||||||
|
case 'in_progress':
|
||||||
|
return <Loader2 size={16} className="text-neo-text-on-bright animate-spin" />
|
||||||
|
case 'blocked':
|
||||||
|
return <AlertTriangle size={16} className="text-neo-danger" />
|
||||||
|
default:
|
||||||
|
return <Circle size={16} className="text-neo-text-on-bright" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Handle type="target" position={Position.Left} className="!bg-neo-border !w-2 !h-2" />
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
px-4 py-3 rounded-lg border-2 cursor-pointer
|
||||||
|
transition-all hover:shadow-neo-md
|
||||||
|
${statusColors[data.status]}
|
||||||
|
`}
|
||||||
|
onClick={data.onClick}
|
||||||
|
style={{ minWidth: NODE_WIDTH - 20, maxWidth: NODE_WIDTH }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<StatusIcon />
|
||||||
|
<span className="text-xs font-mono text-neo-text-on-bright/70">
|
||||||
|
#{data.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-bold text-sm text-neo-text-on-bright truncate" title={data.name}>
|
||||||
|
{data.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neo-text-on-bright/70 truncate" title={data.category}>
|
||||||
|
{data.category}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} className="!bg-neo-border !w-2 !h-2" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
feature: FeatureNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout nodes using dagre
|
||||||
|
function getLayoutedElements(
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[],
|
||||||
|
direction: 'TB' | 'LR' = 'LR'
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
|
const dagreGraph = new dagre.graphlib.Graph()
|
||||||
|
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
||||||
|
|
||||||
|
const isHorizontal = direction === 'LR'
|
||||||
|
dagreGraph.setGraph({
|
||||||
|
rankdir: direction,
|
||||||
|
nodesep: 50,
|
||||||
|
ranksep: 100,
|
||||||
|
marginx: 50,
|
||||||
|
marginy: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||||
|
})
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
dagreGraph.setEdge(edge.source, edge.target)
|
||||||
|
})
|
||||||
|
|
||||||
|
dagre.layout(dagreGraph)
|
||||||
|
|
||||||
|
const layoutedNodes = nodes.map((node) => {
|
||||||
|
const nodeWithPosition = dagreGraph.node(node.id)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: nodeWithPosition.x - NODE_WIDTH / 2,
|
||||||
|
y: nodeWithPosition.y - NODE_HEIGHT / 2,
|
||||||
|
},
|
||||||
|
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
|
||||||
|
targetPosition: isHorizontal ? Position.Left : Position.Top,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { nodes: layoutedNodes, edges }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DependencyGraph({ graphData, onNodeClick }: DependencyGraphProps) {
|
||||||
|
const [direction, setDirection] = useState<'TB' | 'LR'>('LR')
|
||||||
|
|
||||||
|
// Convert graph data to React Flow format
|
||||||
|
const initialElements = useMemo(() => {
|
||||||
|
const nodes: Node[] = graphData.nodes.map((node) => ({
|
||||||
|
id: String(node.id),
|
||||||
|
type: 'feature',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
...node,
|
||||||
|
onClick: () => onNodeClick?.(node.id),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const edges: Edge[] = graphData.edges.map((edge, index) => ({
|
||||||
|
id: `e${edge.source}-${edge.target}-${index}`,
|
||||||
|
source: String(edge.source),
|
||||||
|
target: String(edge.target),
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: false,
|
||||||
|
style: { stroke: 'var(--color-neo-border)', strokeWidth: 2 },
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: 'var(--color-neo-border)',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return getLayoutedElements(nodes, edges, direction)
|
||||||
|
}, [graphData, direction, onNodeClick])
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes)
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges)
|
||||||
|
|
||||||
|
// Update layout when data or direction changes
|
||||||
|
useEffect(() => {
|
||||||
|
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
|
||||||
|
initialElements.nodes,
|
||||||
|
initialElements.edges,
|
||||||
|
direction
|
||||||
|
)
|
||||||
|
setNodes(layoutedNodes)
|
||||||
|
setEdges(layoutedEdges)
|
||||||
|
}, [graphData, direction, setNodes, setEdges, initialElements])
|
||||||
|
|
||||||
|
const onLayout = useCallback(
|
||||||
|
(newDirection: 'TB' | 'LR') => {
|
||||||
|
setDirection(newDirection)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Color nodes for minimap
|
||||||
|
const nodeColor = useCallback((node: Node) => {
|
||||||
|
const status = (node.data as unknown as GraphNode).status
|
||||||
|
switch (status) {
|
||||||
|
case 'done':
|
||||||
|
return 'var(--color-neo-done)'
|
||||||
|
case 'in_progress':
|
||||||
|
return 'var(--color-neo-progress)'
|
||||||
|
case 'blocked':
|
||||||
|
return 'var(--color-neo-danger)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-neo-pending)'
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (graphData.nodes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex items-center justify-center bg-neo-neutral-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-neo-text-secondary mb-2">No features to display</div>
|
||||||
|
<div className="text-sm text-neo-text-muted">
|
||||||
|
Create features to see the dependency graph
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full relative bg-neo-neutral-50">
|
||||||
|
{/* Layout toggle */}
|
||||||
|
<div className="absolute top-4 left-4 z-10 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onLayout('LR')}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
|
||||||
|
${direction === 'LR'
|
||||||
|
? 'bg-neo-accent text-white shadow-neo-sm'
|
||||||
|
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Horizontal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onLayout('TB')}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
|
||||||
|
${direction === 'TB'
|
||||||
|
? 'bg-neo-accent text-white shadow-neo-sm'
|
||||||
|
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Vertical
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute top-4 right-4 z-10 bg-white border-2 border-neo-border rounded-lg p-3 shadow-neo-sm">
|
||||||
|
<div className="text-xs font-bold mb-2">Status</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded bg-neo-pending border border-neo-border" />
|
||||||
|
<span>Pending</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded bg-neo-progress border border-neo-border" />
|
||||||
|
<span>In Progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded bg-neo-done border border-neo-border" />
|
||||||
|
<span>Done</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="w-3 h-3 rounded bg-neo-danger/20 border border-neo-danger" />
|
||||||
|
<span>Blocked</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
attributionPosition="bottom-left"
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={2}
|
||||||
|
>
|
||||||
|
<Background color="var(--color-neo-neutral-300)" gap={20} size={1} />
|
||||||
|
<Controls
|
||||||
|
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
|
||||||
|
showInteractive={false}
|
||||||
|
/>
|
||||||
|
<MiniMap
|
||||||
|
nodeColor={nodeColor}
|
||||||
|
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
|
||||||
|
maskColor="rgba(0, 0, 0, 0.1)"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
|
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature, ActiveAgent } from '../lib/types'
|
||||||
|
import { DependencyBadge } from './DependencyBadge'
|
||||||
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
|
|
||||||
interface FeatureCardProps {
|
interface FeatureCardProps {
|
||||||
feature: Feature
|
feature: Feature
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
isInProgress?: boolean
|
isInProgress?: boolean
|
||||||
|
allFeatures?: Feature[]
|
||||||
|
activeAgent?: ActiveAgent // Agent working on this feature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate consistent color for category using CSS variable references
|
// Generate consistent color for category using CSS variable references
|
||||||
@@ -28,26 +32,33 @@ function getCategoryColor(category: string): string {
|
|||||||
return colors[Math.abs(hash) % colors.length]
|
return colors[Math.abs(hash) % colors.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
|
export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], activeAgent }: FeatureCardProps) {
|
||||||
const categoryColor = getCategoryColor(feature.category)
|
const categoryColor = getCategoryColor(feature.category)
|
||||||
|
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
|
||||||
|
const hasActiveAgent = !!activeAgent
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
w-full text-left neo-card p-4 cursor-pointer
|
w-full text-left neo-card p-4 cursor-pointer relative
|
||||||
${isInProgress ? 'animate-pulse-neo' : ''}
|
${isInProgress ? 'animate-pulse-neo' : ''}
|
||||||
${feature.passes ? 'border-neo-done' : ''}
|
${feature.passes ? 'border-neo-done' : ''}
|
||||||
|
${isBlocked && !feature.passes ? 'border-neo-danger opacity-80' : ''}
|
||||||
|
${hasActiveAgent ? 'ring-2 ring-neo-progress ring-offset-2' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
<span
|
<div className="flex items-center gap-2">
|
||||||
className="neo-badge"
|
<span
|
||||||
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
|
className="neo-badge"
|
||||||
>
|
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
|
||||||
{feature.category}
|
>
|
||||||
</span>
|
{feature.category}
|
||||||
|
</span>
|
||||||
|
<DependencyBadge feature={feature} allFeatures={allFeatures} compact />
|
||||||
|
</div>
|
||||||
<span className="font-mono text-sm text-neo-text-secondary">
|
<span className="font-mono text-sm text-neo-text-secondary">
|
||||||
#{feature.priority}
|
#{feature.priority}
|
||||||
</span>
|
</span>
|
||||||
@@ -63,6 +74,26 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
|
|||||||
{feature.description}
|
{feature.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Agent working on this feature */}
|
||||||
|
{activeAgent && (
|
||||||
|
<div className="flex items-center gap-2 mb-3 py-2 px-2 rounded bg-[var(--color-neo-progress)]/10 border border-[var(--color-neo-progress)]/30">
|
||||||
|
<AgentAvatar name={activeAgent.agentName} state={activeAgent.state} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-bold text-neo-progress">
|
||||||
|
{activeAgent.agentName} is working on this!
|
||||||
|
</div>
|
||||||
|
{activeAgent.thought && (
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<MessageCircle size={10} className="text-neo-text-secondary shrink-0" />
|
||||||
|
<p className="text-[10px] text-neo-text-secondary truncate italic">
|
||||||
|
{activeAgent.thought}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{isInProgress ? (
|
{isInProgress ? (
|
||||||
@@ -75,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
|
|||||||
<CheckCircle2 size={16} className="text-neo-done" />
|
<CheckCircle2 size={16} className="text-neo-done" />
|
||||||
<span className="text-neo-done font-bold">Complete</span>
|
<span className="text-neo-done font-bold">Complete</span>
|
||||||
</>
|
</>
|
||||||
|
) : isBlocked ? (
|
||||||
|
<>
|
||||||
|
<Circle size={16} className="text-neo-danger" />
|
||||||
|
<span className="text-neo-danger">Blocked</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Circle size={16} className="text-neo-text-secondary" />
|
<Circle size={16} className="text-neo-text-secondary" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-react'
|
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
|
||||||
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
|
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
|
||||||
import { EditFeatureForm } from './EditFeatureForm'
|
import { EditFeatureForm } from './EditFeatureForm'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature } from '../lib/types'
|
||||||
|
|
||||||
@@ -37,6 +37,25 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
|
|
||||||
const skipFeature = useSkipFeature(projectName)
|
const skipFeature = useSkipFeature(projectName)
|
||||||
const deleteFeature = useDeleteFeature(projectName)
|
const deleteFeature = useDeleteFeature(projectName)
|
||||||
|
const { data: allFeatures } = useFeatures(projectName)
|
||||||
|
|
||||||
|
// Build a map of feature ID to feature for looking up dependency names
|
||||||
|
const featureMap = new Map<number, Feature>()
|
||||||
|
if (allFeatures) {
|
||||||
|
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
|
||||||
|
featureMap.set(f.id, f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dependency features
|
||||||
|
const dependencies = (feature.dependencies || [])
|
||||||
|
.map(id => featureMap.get(id))
|
||||||
|
.filter((f): f is Feature => f !== undefined)
|
||||||
|
|
||||||
|
// Get blocking dependencies (unmet dependencies)
|
||||||
|
const blockingDeps = (feature.blocking_dependencies || [])
|
||||||
|
.map(id => featureMap.get(id))
|
||||||
|
.filter((f): f is Feature => f !== undefined)
|
||||||
|
|
||||||
const handleSkip = async () => {
|
const handleSkip = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -145,6 +164,57 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Blocked By Warning */}
|
||||||
|
{blockingDeps.length > 0 && (
|
||||||
|
<div className="p-4 bg-[var(--color-neo-warning-bg)] border-3 border-[var(--color-neo-warning-border)]">
|
||||||
|
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2 text-[var(--color-neo-warning-text)]">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
Blocked By
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[var(--color-neo-warning-text)] mb-2">
|
||||||
|
This feature cannot start until the following dependencies are complete:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{blockingDeps.map(dep => (
|
||||||
|
<li
|
||||||
|
key={dep.id}
|
||||||
|
className="flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Circle size={14} className="text-[var(--color-neo-warning-text)]" />
|
||||||
|
<span className="font-mono text-xs text-[var(--color-neo-warning-text)]">#{dep.id}</span>
|
||||||
|
<span className="text-[var(--color-neo-warning-text)]">{dep.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependencies */}
|
||||||
|
{dependencies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2">
|
||||||
|
<Link2 size={16} />
|
||||||
|
Depends On
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dependencies.map(dep => (
|
||||||
|
<li
|
||||||
|
key={dep.id}
|
||||||
|
className="flex items-center gap-2 p-2 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)]"
|
||||||
|
>
|
||||||
|
{dep.passes ? (
|
||||||
|
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
|
||||||
|
) : (
|
||||||
|
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-xs text-[var(--color-neo-text-secondary)]">#{dep.id}</span>
|
||||||
|
<span className={dep.passes ? 'text-[var(--color-neo-done)]' : ''}>{dep.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
{feature.steps.length > 0 && (
|
{feature.steps.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { KanbanColumn } from './KanbanColumn'
|
import { KanbanColumn } from './KanbanColumn'
|
||||||
import type { Feature, FeatureListResponse } from '../lib/types'
|
import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types'
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
features: FeatureListResponse | undefined
|
features: FeatureListResponse | undefined
|
||||||
onFeatureClick: (feature: Feature) => void
|
onFeatureClick: (feature: Feature) => void
|
||||||
onAddFeature?: () => void
|
onAddFeature?: () => void
|
||||||
onExpandProject?: () => void
|
onExpandProject?: () => void
|
||||||
|
activeAgents?: ActiveAgent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject }: KanbanBoardProps) {
|
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [] }: KanbanBoardProps) {
|
||||||
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
|
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
|
||||||
|
|
||||||
|
// Combine all features for dependency status calculation
|
||||||
|
const allFeatures = features
|
||||||
|
? [...features.pending, ...features.in_progress, ...features.done]
|
||||||
|
: []
|
||||||
|
|
||||||
if (!features) {
|
if (!features) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
@@ -34,6 +40,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
title="Pending"
|
title="Pending"
|
||||||
count={features.pending.length}
|
count={features.pending.length}
|
||||||
features={features.pending}
|
features={features.pending}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
color="pending"
|
color="pending"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
onAddFeature={onAddFeature}
|
onAddFeature={onAddFeature}
|
||||||
@@ -44,6 +52,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
title="In Progress"
|
title="In Progress"
|
||||||
count={features.in_progress.length}
|
count={features.in_progress.length}
|
||||||
features={features.in_progress}
|
features={features.in_progress}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
color="progress"
|
color="progress"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
/>
|
/>
|
||||||
@@ -51,6 +61,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
title="Done"
|
title="Done"
|
||||||
count={features.done.length}
|
count={features.done.length}
|
||||||
features={features.done}
|
features={features.done}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
color="done"
|
color="done"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { FeatureCard } from './FeatureCard'
|
import { FeatureCard } from './FeatureCard'
|
||||||
import { Plus, Sparkles } from 'lucide-react'
|
import { Plus, Sparkles } from 'lucide-react'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature, ActiveAgent } from '../lib/types'
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
title: string
|
title: string
|
||||||
count: number
|
count: number
|
||||||
features: Feature[]
|
features: Feature[]
|
||||||
|
allFeatures?: Feature[] // For dependency status calculation
|
||||||
|
activeAgents?: ActiveAgent[] // Active agents for showing which agent is working on a feature
|
||||||
color: 'pending' | 'progress' | 'done'
|
color: 'pending' | 'progress' | 'done'
|
||||||
onFeatureClick: (feature: Feature) => void
|
onFeatureClick: (feature: Feature) => void
|
||||||
onAddFeature?: () => void
|
onAddFeature?: () => void
|
||||||
@@ -23,12 +25,18 @@ export function KanbanColumn({
|
|||||||
title,
|
title,
|
||||||
count,
|
count,
|
||||||
features,
|
features,
|
||||||
|
allFeatures = [],
|
||||||
|
activeAgents = [],
|
||||||
color,
|
color,
|
||||||
onFeatureClick,
|
onFeatureClick,
|
||||||
onAddFeature,
|
onAddFeature,
|
||||||
onExpandProject,
|
onExpandProject,
|
||||||
showExpandButton,
|
showExpandButton,
|
||||||
}: KanbanColumnProps) {
|
}: KanbanColumnProps) {
|
||||||
|
// Create a map of feature ID to active agent for quick lookup
|
||||||
|
const agentByFeatureId = new Map(
|
||||||
|
activeAgents.map(agent => [agent.featureId, agent])
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="neo-card overflow-hidden"
|
className="neo-card overflow-hidden"
|
||||||
@@ -86,6 +94,8 @@ export function KanbanColumn({
|
|||||||
feature={feature}
|
feature={feature}
|
||||||
onClick={() => onFeatureClick(feature)}
|
onClick={() => onFeatureClick(feature)}
|
||||||
isInProgress={color === 'progress'}
|
isInProgress={color === 'progress'}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgent={agentByFeatureId.get(feature.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
93
ui/src/components/KeyboardShortcutsHelp.tsx
Normal file
93
ui/src/components/KeyboardShortcutsHelp.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useCallback } from 'react'
|
||||||
|
import { X, Keyboard } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Shortcut {
|
||||||
|
key: string
|
||||||
|
description: string
|
||||||
|
context?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcuts: Shortcut[] = [
|
||||||
|
{ key: '?', description: 'Show keyboard shortcuts' },
|
||||||
|
{ key: 'D', description: 'Toggle debug panel' },
|
||||||
|
{ key: 'T', description: 'Toggle terminal tab' },
|
||||||
|
{ key: 'N', description: 'Add new feature', context: 'with project' },
|
||||||
|
{ key: 'E', description: 'Expand project with AI', context: 'with features' },
|
||||||
|
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
|
||||||
|
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
|
||||||
|
{ key: ',', description: 'Open settings' },
|
||||||
|
{ key: 'Esc', description: 'Close modal/panel' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface KeyboardShortcutsHelpProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleKeyDown])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="neo-card p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Keyboard size={20} className="text-neo-accent" />
|
||||||
|
<h2 className="font-display text-lg font-bold">Keyboard Shortcuts</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="neo-btn p-1.5"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcuts list */}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{shortcuts.map((shortcut) => (
|
||||||
|
<li
|
||||||
|
key={shortcut.key}
|
||||||
|
className="flex items-center justify-between py-2 border-b border-neo-border/30 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<kbd className="px-2 py-1 text-sm font-mono bg-neo-bg rounded border border-neo-border shadow-neo-sm min-w-[2rem] text-center">
|
||||||
|
{shortcut.key}
|
||||||
|
</kbd>
|
||||||
|
<span className="text-neo-text">{shortcut.description}</span>
|
||||||
|
</div>
|
||||||
|
{shortcut.context && (
|
||||||
|
<span className="text-xs text-neo-muted">{shortcut.context}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-xs text-neo-muted text-center mt-6">
|
||||||
|
Press ? or Esc to close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -129,7 +129,7 @@ export function NewProjectModal({
|
|||||||
// Auto-start the initializer agent
|
// Auto-start the initializer agent
|
||||||
setInitializerStatus('starting')
|
setInitializerStatus('starting')
|
||||||
try {
|
try {
|
||||||
await startAgent(projectName.trim(), yoloMode)
|
await startAgent(projectName.trim(), { yoloMode })
|
||||||
// Success - navigate to project
|
// Success - navigate to project
|
||||||
changeStep('complete')
|
changeStep('complete')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
46
ui/src/components/ViewToggle.tsx
Normal file
46
ui/src/components/ViewToggle.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { LayoutGrid, GitBranch } from 'lucide-react'
|
||||||
|
|
||||||
|
export type ViewMode = 'kanban' | 'graph'
|
||||||
|
|
||||||
|
interface ViewToggleProps {
|
||||||
|
viewMode: ViewMode
|
||||||
|
onViewModeChange: (mode: ViewMode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle button to switch between Kanban and Graph views
|
||||||
|
*/
|
||||||
|
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-lg border-2 border-neo-border p-1 bg-white shadow-neo-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewModeChange('kanban')}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
|
||||||
|
${viewMode === 'kanban'
|
||||||
|
? 'bg-neo-accent text-white shadow-neo-sm'
|
||||||
|
: 'text-neo-text hover:bg-neo-neutral-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title="Kanban View"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={16} />
|
||||||
|
<span>Kanban</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onViewModeChange('graph')}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
|
||||||
|
${viewMode === 'graph'
|
||||||
|
? 'bg-neo-accent text-white shadow-neo-sm'
|
||||||
|
: 'text-neo-text hover:bg-neo-neutral-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
title="Dependency Graph View"
|
||||||
|
>
|
||||||
|
<GitBranch size={16} />
|
||||||
|
<span>Graph</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -123,7 +123,11 @@ export function useStartAgent(projectName: string) {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (yoloMode: boolean = false) => api.startAgent(projectName, yoloMode),
|
mutationFn: (options: {
|
||||||
|
yoloMode?: boolean
|
||||||
|
parallelMode?: boolean
|
||||||
|
maxConcurrency?: number
|
||||||
|
} = {}) => api.startAgent(projectName, options),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types'
|
import type {
|
||||||
|
WSMessage,
|
||||||
|
AgentStatus,
|
||||||
|
DevServerStatus,
|
||||||
|
ActiveAgent,
|
||||||
|
AgentMascot,
|
||||||
|
} from '../lib/types'
|
||||||
|
|
||||||
|
// Activity item for the feed
|
||||||
|
interface ActivityItem {
|
||||||
|
agentName: string
|
||||||
|
thought: string
|
||||||
|
timestamp: string
|
||||||
|
featureId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celebration trigger for overlay
|
||||||
|
interface CelebrationTrigger {
|
||||||
|
agentName: AgentMascot
|
||||||
|
featureName: string
|
||||||
|
featureId: number
|
||||||
|
}
|
||||||
|
|
||||||
interface WebSocketState {
|
interface WebSocketState {
|
||||||
progress: {
|
progress: {
|
||||||
@@ -13,14 +34,21 @@ interface WebSocketState {
|
|||||||
percentage: number
|
percentage: number
|
||||||
}
|
}
|
||||||
agentStatus: AgentStatus
|
agentStatus: AgentStatus
|
||||||
logs: Array<{ line: string; timestamp: string }>
|
logs: Array<{ line: string; timestamp: string; featureId?: number; agentIndex?: number }>
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
devServerStatus: DevServerStatus
|
devServerStatus: DevServerStatus
|
||||||
devServerUrl: string | null
|
devServerUrl: string | null
|
||||||
devLogs: Array<{ line: string; timestamp: string }>
|
devLogs: Array<{ line: string; timestamp: string }>
|
||||||
|
// Multi-agent state
|
||||||
|
activeAgents: ActiveAgent[]
|
||||||
|
recentActivity: ActivityItem[]
|
||||||
|
// Celebration queue to handle rapid successes without race conditions
|
||||||
|
celebrationQueue: CelebrationTrigger[]
|
||||||
|
celebration: CelebrationTrigger | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||||
|
const MAX_ACTIVITY = 20 // Keep last 20 activity items
|
||||||
|
|
||||||
export function useProjectWebSocket(projectName: string | null) {
|
export function useProjectWebSocket(projectName: string | null) {
|
||||||
const [state, setState] = useState<WebSocketState>({
|
const [state, setState] = useState<WebSocketState>({
|
||||||
@@ -31,6 +59,10 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
devServerStatus: 'stopped',
|
devServerStatus: 'stopped',
|
||||||
devServerUrl: null,
|
devServerUrl: null,
|
||||||
devLogs: [],
|
devLogs: [],
|
||||||
|
activeAgents: [],
|
||||||
|
recentActivity: [],
|
||||||
|
celebrationQueue: [],
|
||||||
|
celebration: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
@@ -83,7 +115,12 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
...prev,
|
...prev,
|
||||||
logs: [
|
logs: [
|
||||||
...prev.logs.slice(-MAX_LOGS + 1),
|
...prev.logs.slice(-MAX_LOGS + 1),
|
||||||
{ line: message.line, timestamp: message.timestamp },
|
{
|
||||||
|
line: message.line,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
featureId: message.featureId,
|
||||||
|
agentIndex: message.agentIndex,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
@@ -92,6 +129,91 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
// Feature updates will trigger a refetch via React Query
|
// Feature updates will trigger a refetch via React Query
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'agent_update':
|
||||||
|
setState(prev => {
|
||||||
|
// Update or add the agent in activeAgents
|
||||||
|
const agentIndex = prev.activeAgents.findIndex(
|
||||||
|
a => a.agentIndex === message.agentIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
let newAgents: ActiveAgent[]
|
||||||
|
if (message.state === 'success') {
|
||||||
|
// Remove agent from active list on success
|
||||||
|
newAgents = prev.activeAgents.filter(
|
||||||
|
a => a.agentIndex !== message.agentIndex
|
||||||
|
)
|
||||||
|
} else if (agentIndex >= 0) {
|
||||||
|
// Update existing agent
|
||||||
|
newAgents = [...prev.activeAgents]
|
||||||
|
newAgents[agentIndex] = {
|
||||||
|
agentIndex: message.agentIndex,
|
||||||
|
agentName: message.agentName,
|
||||||
|
featureId: message.featureId,
|
||||||
|
featureName: message.featureName,
|
||||||
|
state: message.state,
|
||||||
|
thought: message.thought,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new agent
|
||||||
|
newAgents = [
|
||||||
|
...prev.activeAgents,
|
||||||
|
{
|
||||||
|
agentIndex: message.agentIndex,
|
||||||
|
agentName: message.agentName,
|
||||||
|
featureId: message.featureId,
|
||||||
|
featureName: message.featureName,
|
||||||
|
state: message.state,
|
||||||
|
thought: message.thought,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to activity feed if there's a thought
|
||||||
|
let newActivity = prev.recentActivity
|
||||||
|
if (message.thought) {
|
||||||
|
newActivity = [
|
||||||
|
{
|
||||||
|
agentName: message.agentName,
|
||||||
|
thought: message.thought,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
featureId: message.featureId,
|
||||||
|
},
|
||||||
|
...prev.recentActivity.slice(0, MAX_ACTIVITY - 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle celebration queue on success
|
||||||
|
let newCelebrationQueue = prev.celebrationQueue
|
||||||
|
let newCelebration = prev.celebration
|
||||||
|
|
||||||
|
if (message.state === 'success') {
|
||||||
|
const newCelebrationItem: CelebrationTrigger = {
|
||||||
|
agentName: message.agentName,
|
||||||
|
featureName: message.featureName,
|
||||||
|
featureId: message.featureId,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no celebration is showing, show this one immediately
|
||||||
|
// Otherwise, add to queue
|
||||||
|
if (!prev.celebration) {
|
||||||
|
newCelebration = newCelebrationItem
|
||||||
|
} else {
|
||||||
|
newCelebrationQueue = [...prev.celebrationQueue, newCelebrationItem]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
activeAgents: newAgents,
|
||||||
|
recentActivity: newActivity,
|
||||||
|
celebrationQueue: newCelebrationQueue,
|
||||||
|
celebration: newCelebration,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
case 'dev_log':
|
case 'dev_log':
|
||||||
setState(prev => ({
|
setState(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -147,6 +269,19 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Clear celebration and show next one from queue if available
|
||||||
|
const clearCelebration = useCallback(() => {
|
||||||
|
setState(prev => {
|
||||||
|
// Pop the next celebration from the queue if available
|
||||||
|
const [nextCelebration, ...remainingQueue] = prev.celebrationQueue
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
celebration: nextCelebration || null,
|
||||||
|
celebrationQueue: remainingQueue,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Connect when project changes
|
// Connect when project changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset state when project changes to clear stale data
|
// Reset state when project changes to clear stale data
|
||||||
@@ -158,6 +293,10 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
devServerStatus: 'stopped',
|
devServerStatus: 'stopped',
|
||||||
devServerUrl: null,
|
devServerUrl: null,
|
||||||
devLogs: [],
|
devLogs: [],
|
||||||
|
activeAgents: [],
|
||||||
|
recentActivity: [],
|
||||||
|
celebrationQueue: [],
|
||||||
|
celebration: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!projectName) {
|
if (!projectName) {
|
||||||
@@ -200,5 +339,6 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
...state,
|
...state,
|
||||||
clearLogs,
|
clearLogs,
|
||||||
clearDevLogs,
|
clearDevLogs,
|
||||||
|
clearCelebration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
FeatureUpdate,
|
FeatureUpdate,
|
||||||
FeatureBulkCreate,
|
FeatureBulkCreate,
|
||||||
FeatureBulkCreateResponse,
|
FeatureBulkCreateResponse,
|
||||||
|
DependencyGraph,
|
||||||
AgentStatusResponse,
|
AgentStatusResponse,
|
||||||
AgentActionResponse,
|
AgentActionResponse,
|
||||||
SetupStatus,
|
SetupStatus,
|
||||||
@@ -141,6 +142,50 @@ export async function createFeaturesBulk(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dependency Graph API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getDependencyGraph(projectName: string): Promise<DependencyGraph> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/graph`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addDependency(
|
||||||
|
projectName: string,
|
||||||
|
featureId: number,
|
||||||
|
dependencyId: number
|
||||||
|
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
|
||||||
|
return fetchJSON(
|
||||||
|
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeDependency(
|
||||||
|
projectName: string,
|
||||||
|
featureId: number,
|
||||||
|
dependencyId: number
|
||||||
|
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
|
||||||
|
return fetchJSON(
|
||||||
|
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDependencies(
|
||||||
|
projectName: string,
|
||||||
|
featureId: number,
|
||||||
|
dependencyIds: number[]
|
||||||
|
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
|
||||||
|
return fetchJSON(
|
||||||
|
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ dependency_ids: dependencyIds }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Agent API
|
// Agent API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -151,11 +196,19 @@ export async function getAgentStatus(projectName: string): Promise<AgentStatusRe
|
|||||||
|
|
||||||
export async function startAgent(
|
export async function startAgent(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
yoloMode: boolean = false
|
options: {
|
||||||
|
yoloMode?: boolean
|
||||||
|
parallelMode?: boolean
|
||||||
|
maxConcurrency?: number
|
||||||
|
} = {}
|
||||||
): Promise<AgentActionResponse> {
|
): Promise<AgentActionResponse> {
|
||||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ yolo_mode: yoloMode }),
|
body: JSON.stringify({
|
||||||
|
yolo_mode: options.yoloMode ?? false,
|
||||||
|
parallel_mode: options.parallelMode ?? false,
|
||||||
|
max_concurrency: options.maxConcurrency,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ export interface Feature {
|
|||||||
steps: string[]
|
steps: string[]
|
||||||
passes: boolean
|
passes: boolean
|
||||||
in_progress: boolean
|
in_progress: boolean
|
||||||
|
dependencies?: number[] // Optional for backwards compat
|
||||||
|
blocked?: boolean // Computed by API
|
||||||
|
blocking_dependencies?: number[] // Computed by API
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status type for graph nodes
|
||||||
|
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
|
||||||
|
|
||||||
|
// Graph visualization types
|
||||||
|
export interface GraphNode {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
status: FeatureStatus
|
||||||
|
priority: number
|
||||||
|
dependencies: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
source: number
|
||||||
|
target: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DependencyGraph {
|
||||||
|
nodes: GraphNode[]
|
||||||
|
edges: GraphEdge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureListResponse {
|
export interface FeatureListResponse {
|
||||||
@@ -80,6 +106,7 @@ export interface FeatureCreate {
|
|||||||
description: string
|
description: string
|
||||||
steps: string[]
|
steps: string[]
|
||||||
priority?: number
|
priority?: number
|
||||||
|
dependencies?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureUpdate {
|
export interface FeatureUpdate {
|
||||||
@@ -88,6 +115,7 @@ export interface FeatureUpdate {
|
|||||||
description?: string
|
description?: string
|
||||||
steps?: string[]
|
steps?: string[]
|
||||||
priority?: number
|
priority?: number
|
||||||
|
dependencies?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent types
|
// Agent types
|
||||||
@@ -99,6 +127,8 @@ export interface AgentStatusResponse {
|
|||||||
started_at: string | null
|
started_at: string | null
|
||||||
yolo_mode: boolean
|
yolo_mode: boolean
|
||||||
model: string | null // Model being used by running agent
|
model: string | null // Model being used by running agent
|
||||||
|
parallel_mode: boolean
|
||||||
|
max_concurrency: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentActionResponse {
|
export interface AgentActionResponse {
|
||||||
@@ -140,8 +170,26 @@ export interface TerminalInfo {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent mascot names for multi-agent UI
|
||||||
|
export const AGENT_MASCOTS = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz'] as const
|
||||||
|
export type AgentMascot = typeof AGENT_MASCOTS[number]
|
||||||
|
|
||||||
|
// Agent state for Mission Control
|
||||||
|
export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling'
|
||||||
|
|
||||||
|
// Agent update from backend
|
||||||
|
export interface ActiveAgent {
|
||||||
|
agentIndex: number
|
||||||
|
agentName: AgentMascot
|
||||||
|
featureId: number
|
||||||
|
featureName: string
|
||||||
|
state: AgentState
|
||||||
|
thought?: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
// WebSocket message types
|
// WebSocket message types
|
||||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'
|
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update'
|
||||||
|
|
||||||
export interface WSProgressMessage {
|
export interface WSProgressMessage {
|
||||||
type: 'progress'
|
type: 'progress'
|
||||||
@@ -161,6 +209,20 @@ export interface WSLogMessage {
|
|||||||
type: 'log'
|
type: 'log'
|
||||||
line: string
|
line: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
featureId?: number
|
||||||
|
agentIndex?: number
|
||||||
|
agentName?: AgentMascot
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WSAgentUpdateMessage {
|
||||||
|
type: 'agent_update'
|
||||||
|
agentIndex: number
|
||||||
|
agentName: AgentMascot
|
||||||
|
featureId: number
|
||||||
|
featureName: string
|
||||||
|
state: AgentState
|
||||||
|
thought?: string
|
||||||
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSAgentStatusMessage {
|
export interface WSAgentStatusMessage {
|
||||||
@@ -189,6 +251,7 @@ export type WSMessage =
|
|||||||
| WSFeatureUpdateMessage
|
| WSFeatureUpdateMessage
|
||||||
| WSLogMessage
|
| WSLogMessage
|
||||||
| WSAgentStatusMessage
|
| WSAgentStatusMessage
|
||||||
|
| WSAgentUpdateMessage
|
||||||
| WSPongMessage
|
| WSPongMessage
|
||||||
| WSDevLogMessage
|
| WSDevLogMessage
|
||||||
| WSDevServerStatusMessage
|
| WSDevServerStatusMessage
|
||||||
|
|||||||
@@ -870,6 +870,96 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Agent Mascot Animations
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
@keyframes bounce-gentle {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes thinking {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-2px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes working {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes testing {
|
||||||
|
0%, 100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(3deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes celebrate {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1) rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: scale(1.1) rotate(-5deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.15) rotate(0deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: scale(1.1) rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake-gentle {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
20%, 60% {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
40%, 80% {
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) rotate(0deg);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(100vh) rotate(720deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Utilities Layer
|
Utilities Layer
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -970,6 +1060,35 @@
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: var(--font-neo-mono);
|
font-family: var(--font-neo-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent mascot animation utilities */
|
||||||
|
.animate-bounce-gentle {
|
||||||
|
animation: bounce-gentle 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-thinking {
|
||||||
|
animation: thinking 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-working {
|
||||||
|
animation: working 0.3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-testing {
|
||||||
|
animation: testing 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-celebrate {
|
||||||
|
animation: celebrate 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-shake-gentle {
|
||||||
|
animation: shake-gentle 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-confetti {
|
||||||
|
animation: confetti 2s ease-out forwards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user