diff --git a/agent.py b/agent.py index a3daaf88..fe8b5b0a 100644 --- a/agent.py +++ b/agent.py @@ -222,7 +222,7 @@ async def run_autonomous_agent( # Check if all features are already complete (before starting a new session) # Skip this check if running as initializer (needs to create features first) if not is_initializer and iteration == 1: - passing, in_progress, total = count_passing_tests(project_dir) + passing, in_progress, total, _nhi = count_passing_tests(project_dir) if total > 0 and passing == total: print("\n" + "=" * 70) print(" ALL FEATURES ALREADY COMPLETE!") @@ -358,7 +358,7 @@ async def run_autonomous_agent( print_progress_summary(project_dir) # Check if all features are complete - exit gracefully if done - passing, in_progress, total = count_passing_tests(project_dir) + passing, in_progress, total, _nhi = count_passing_tests(project_dir) if total > 0 and passing == total: print("\n" + "=" * 70) print(" ALL FEATURES COMPLETE!") diff --git a/api/database.py b/api/database.py index 4381fbe2..523ea224 100644 --- a/api/database.py +++ b/api/database.py @@ -43,10 +43,10 @@ class Feature(Base): __tablename__ = "features" - # Composite index for common status query pattern (passes, in_progress) + # Composite index for common status query pattern (passes, in_progress, needs_human_input) # Used by feature_get_stats, get_ready_features, and other status queries __table_args__ = ( - Index('ix_feature_status', 'passes', 'in_progress'), + Index('ix_feature_status', 'passes', 'in_progress', 'needs_human_input'), ) id = Column(Integer, primary_key=True, index=True) @@ -61,6 +61,11 @@ class Feature(Base): # NULL/empty = no dependencies (backwards compatible) dependencies = Column(JSON, nullable=True, default=None) + # Human input: agent can request structured input from a human + needs_human_input = Column(Boolean, nullable=False, default=False, index=True) + human_input_request = Column(JSON, nullable=True, default=None) # Agent's structured request + human_input_response = Column(JSON, nullable=True, default=None) # Human's response + def to_dict(self) -> dict: """Convert feature to dictionary for JSON serialization.""" return { @@ -75,6 +80,10 @@ def to_dict(self) -> dict: "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 [], + # Human input fields + "needs_human_input": self.needs_human_input if self.needs_human_input is not None else False, + "human_input_request": self.human_input_request, + "human_input_response": self.human_input_response, } def get_dependencies_safe(self) -> list[int]: @@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool: return False +def _migrate_add_human_input_columns(engine) -> None: + """Add human input columns to existing databases that don't have them.""" + with engine.connect() as conn: + result = conn.execute(text("PRAGMA table_info(features)")) + columns = [row[1] for row in result.fetchall()] + + if "needs_human_input" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN needs_human_input BOOLEAN DEFAULT 0")) + if "human_input_request" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN human_input_request TEXT DEFAULT NULL")) + if "human_input_response" not in columns: + conn.execute(text("ALTER TABLE features ADD COLUMN human_input_response TEXT DEFAULT NULL")) + conn.commit() + + def _migrate_add_schedules_tables(engine) -> None: """Create schedules and schedule_overrides tables if they don't exist.""" from sqlalchemy import inspect @@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple: _migrate_fix_null_boolean_fields(engine) _migrate_add_dependencies_column(engine) _migrate_add_testing_columns(engine) + _migrate_add_human_input_columns(engine) # Migrate to add schedules tables _migrate_add_schedules_tables(engine) diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 06535c7a..28c306ed 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -151,17 +151,20 @@ def feature_get_stats() -> str: result = session.query( func.count(Feature.id).label('total'), func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'), - func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress') + func.sum(case((Feature.in_progress == True, 1), else_=0)).label('in_progress'), + func.sum(case((Feature.needs_human_input == True, 1), else_=0)).label('needs_human_input') ).first() total = result.total or 0 passing = int(result.passing or 0) in_progress = int(result.in_progress or 0) + needs_human_input = int(result.needs_human_input or 0) percentage = round((passing / total) * 100, 1) if total > 0 else 0.0 return json.dumps({ "passing": passing, "in_progress": in_progress, + "needs_human_input": needs_human_input, "total": total, "percentage": percentage }) @@ -221,6 +224,7 @@ def feature_get_summary( "name": feature.name, "passes": feature.passes, "in_progress": feature.in_progress, + "needs_human_input": feature.needs_human_input if feature.needs_human_input is not None else False, "dependencies": feature.dependencies or [] }) finally: @@ -401,11 +405,11 @@ def feature_mark_in_progress( """ session = get_session() try: - # Atomic claim: only succeeds if feature is not already claimed or passing + # Atomic claim: only succeeds if feature is not already claimed, passing, or blocked for human input result = session.execute(text(""" UPDATE features SET in_progress = 1 - WHERE id = :id AND passes = 0 AND in_progress = 0 + WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0 """), {"id": feature_id}) session.commit() @@ -418,6 +422,8 @@ def feature_mark_in_progress( return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) if feature.in_progress: return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"}) + if getattr(feature, 'needs_human_input', False): + return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"}) return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"}) # Fetch the claimed feature @@ -455,11 +461,14 @@ def feature_claim_and_get( if feature.passes: return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) - # Try atomic claim: only succeeds if not already claimed + if getattr(feature, 'needs_human_input', False): + return json.dumps({"error": f"Feature with ID {feature_id} is blocked waiting for human input"}) + + # Try atomic claim: only succeeds if not already claimed and not blocked for human input result = session.execute(text(""" UPDATE features SET in_progress = 1 - WHERE id = :id AND passes = 0 AND in_progress = 0 + WHERE id = :id AND passes = 0 AND in_progress = 0 AND needs_human_input = 0 """), {"id": feature_id}) session.commit() @@ -806,6 +815,8 @@ def feature_get_ready( for f in all_features: if f.passes or f.in_progress: continue + if getattr(f, 'needs_human_input', False): + continue deps = f.dependencies or [] if all(dep_id in passing_ids for dep_id in deps): ready.append(f.to_dict()) @@ -888,6 +899,8 @@ def feature_get_graph() -> str: if f.passes: status = "done" + elif getattr(f, 'needs_human_input', False): + status = "needs_human_input" elif blocking: status = "blocked" elif f.in_progress: @@ -984,6 +997,85 @@ def feature_set_dependencies( return json.dumps({"error": f"Failed to set dependencies: {str(e)}"}) +@mcp.tool() +def feature_request_human_input( + feature_id: Annotated[int, Field(description="The ID of the feature that needs human input", ge=1)], + prompt: Annotated[str, Field(min_length=1, description="Explain what you need from the human and why")], + fields: Annotated[list[dict], Field(min_length=1, description="List of input fields to collect")] +) -> str: + """Request structured input from a human for a feature that is blocked. + + Use this ONLY when the feature genuinely cannot proceed without human intervention: + - Creating API keys or external accounts + - Choosing between design approaches that require human preference + - Configuring external services the agent cannot access + - Providing credentials or secrets + + Do NOT use this for issues you can solve yourself (debugging, reading docs, etc.). + + The feature will be moved out of in_progress and into a "needs human input" state. + Once the human provides their response, the feature returns to the pending queue + and will include the human's response when you pick it up again. + + Args: + feature_id: The ID of the feature that needs human input + prompt: A clear explanation of what you need and why + fields: List of input fields, each with: + - id (str): Unique field identifier + - label (str): Human-readable label + - type (str): "text", "textarea", "select", or "boolean" (default: "text") + - required (bool): Whether the field is required (default: true) + - placeholder (str, optional): Placeholder text + - options (list, optional): For select type: [{value, label}] + + Returns: + JSON with success confirmation or error message + """ + # Validate fields + for i, field in enumerate(fields): + if "id" not in field or "label" not in field: + return json.dumps({"error": f"Field at index {i} missing required 'id' or 'label'"}) + + request_data = { + "prompt": prompt, + "fields": fields, + } + + session = get_session() + try: + # Atomically set needs_human_input, clear in_progress, store request, clear previous response + result = session.execute(text(""" + UPDATE features + SET needs_human_input = 1, + in_progress = 0, + human_input_request = :request, + human_input_response = NULL + WHERE id = :id AND passes = 0 + """), {"id": feature_id, "request": json.dumps(request_data)}) + session.commit() + + if result.rowcount == 0: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + if feature.passes: + return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) + return json.dumps({"error": "Failed to request human input for unknown reason"}) + + feature = session.query(Feature).filter(Feature.id == feature_id).first() + return json.dumps({ + "success": True, + "feature_id": feature_id, + "name": feature.name, + "message": f"Feature '{feature.name}' is now blocked waiting for human input" + }) + except Exception as e: + session.rollback() + return json.dumps({"error": f"Failed to request human input: {str(e)}"}) + finally: + session.close() + + @mcp.tool() def ask_user( questions: Annotated[list[dict], Field(description="List of questions to ask, each with question, header, options (list of {label, description}), and multiSelect (bool)")] diff --git a/parallel_orchestrator.py b/parallel_orchestrator.py index 856e33cb..f0bb627d 100644 --- a/parallel_orchestrator.py +++ b/parallel_orchestrator.py @@ -492,6 +492,9 @@ def get_resumable_features( for fd in feature_dicts: if not fd.get("in_progress") or fd.get("passes"): continue + # Skip if blocked for human input + if fd.get("needs_human_input"): + continue # Skip if already running in this orchestrator instance if fd["id"] in running_ids: continue @@ -536,11 +539,14 @@ def get_ready_features( running_ids.update(batch_ids) ready = [] - skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0} + skipped_reasons = {"passes": 0, "in_progress": 0, "running": 0, "failed": 0, "deps": 0, "needs_human_input": 0} for fd in feature_dicts: if fd.get("passes"): skipped_reasons["passes"] += 1 continue + if fd.get("needs_human_input"): + skipped_reasons["needs_human_input"] += 1 + continue if fd.get("in_progress"): skipped_reasons["in_progress"] += 1 continue diff --git a/progress.py b/progress.py index aa632a87..e2d847e3 100644 --- a/progress.py +++ b/progress.py @@ -62,54 +62,71 @@ def has_features(project_dir: Path) -> bool: return False -def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: +def count_passing_tests(project_dir: Path) -> tuple[int, int, int, int]: """ - Count passing, in_progress, and total tests via direct database access. + Count passing, in_progress, total, and needs_human_input tests via direct database access. Args: project_dir: Directory containing the project Returns: - (passing_count, in_progress_count, total_count) + (passing_count, in_progress_count, total_count, needs_human_input_count) """ from autoforge_paths import get_features_db_path db_file = get_features_db_path(project_dir) if not db_file.exists(): - return 0, 0, 0 + return 0, 0, 0, 0 try: with closing(_get_connection(db_file)) as conn: cursor = conn.cursor() - # Single aggregate query instead of 3 separate COUNT queries - # Handle case where in_progress column doesn't exist yet (legacy DBs) + # Single aggregate query instead of separate COUNT queries + # Handle case where columns don't exist yet (legacy DBs) try: cursor.execute(""" SELECT COUNT(*) as total, SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, - SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress + SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress, + SUM(CASE WHEN needs_human_input = 1 THEN 1 ELSE 0 END) as needs_human_input FROM features """) row = cursor.fetchone() total = row[0] or 0 passing = row[1] or 0 in_progress = row[2] or 0 + needs_human_input = row[3] or 0 except sqlite3.OperationalError: - # Fallback for databases without in_progress column - cursor.execute(""" - SELECT - COUNT(*) as total, - SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing - FROM features - """) - row = cursor.fetchone() - total = row[0] or 0 - passing = row[1] or 0 - in_progress = 0 - return passing, in_progress, total + # Fallback for databases without newer columns + try: + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing, + SUM(CASE WHEN in_progress = 1 THEN 1 ELSE 0 END) as in_progress + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = row[2] or 0 + needs_human_input = 0 + except sqlite3.OperationalError: + cursor.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing + FROM features + """) + row = cursor.fetchone() + total = row[0] or 0 + passing = row[1] or 0 + in_progress = 0 + needs_human_input = 0 + return passing, in_progress, total, needs_human_input except Exception as e: print(f"[Database error in count_passing_tests: {e}]") - return 0, 0, 0 + return 0, 0, 0, 0 def get_all_passing_features(project_dir: Path) -> list[dict]: @@ -234,7 +251,7 @@ def print_session_header(session_num: int, is_initializer: bool) -> None: def print_progress_summary(project_dir: Path) -> None: """Print a summary of current progress.""" - passing, in_progress, total = count_passing_tests(project_dir) + passing, in_progress, total, _needs_human_input = count_passing_tests(project_dir) if total > 0: percentage = (passing / total) * 100 diff --git a/server/routers/features.py b/server/routers/features.py index 488c088c..a844db76 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -23,6 +23,7 @@ FeatureListResponse, FeatureResponse, FeatureUpdate, + HumanInputResponse, ) from ..utils.project_helpers import get_project_path as _get_project_path from ..utils.validation import validate_project_name @@ -104,6 +105,9 @@ def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureRespon in_progress=f.in_progress if f.in_progress is not None else False, blocked=blocked, blocking_dependencies=blocking, + needs_human_input=getattr(f, 'needs_human_input', False) or False, + human_input_request=getattr(f, 'human_input_request', None), + human_input_response=getattr(f, 'human_input_response', None), ) @@ -143,11 +147,14 @@ async def list_features(project_name: str): pending = [] in_progress = [] done = [] + needs_human_input_list = [] for f in all_features: feature_response = feature_to_response(f, passing_ids) if f.passes: done.append(feature_response) + elif getattr(f, 'needs_human_input', False): + needs_human_input_list.append(feature_response) elif f.in_progress: in_progress.append(feature_response) else: @@ -157,6 +164,7 @@ async def list_features(project_name: str): pending=pending, in_progress=in_progress, done=done, + needs_human_input=needs_human_input_list, ) except HTTPException: raise @@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str): deps = f.dependencies or [] blocking = [d for d in deps if d not in passing_ids] - status: Literal["pending", "in_progress", "done", "blocked"] + status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"] if f.passes: status = "done" + elif getattr(f, 'needs_human_input', False): + status = "needs_human_input" elif blocking: status = "blocked" elif f.in_progress: @@ -564,6 +574,71 @@ async def skip_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Failed to skip feature") +@router.post("/{feature_id}/resolve-human-input", response_model=FeatureResponse) +async def resolve_human_input(project_name: str, feature_id: int, response: HumanInputResponse): + """Resolve a human input request for a feature. + + Validates all required fields have values, stores the response, + and returns the feature to the pending queue for agents to pick up. + """ + 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") + + if not getattr(feature, 'needs_human_input', False): + raise HTTPException(status_code=400, detail="Feature is not waiting for human input") + + # Validate required fields + request_data = feature.human_input_request + if request_data and isinstance(request_data, dict): + for field_def in request_data.get("fields", []): + if field_def.get("required", True): + field_id = field_def.get("id") + if field_id not in response.fields or response.fields[field_id] in (None, ""): + raise HTTPException( + status_code=400, + detail=f"Required field '{field_def.get('label', field_id)}' is missing" + ) + + # Store response and return to pending queue + from datetime import datetime, timezone + response_data = { + "fields": {k: v for k, v in response.fields.items()}, + "responded_at": datetime.now(timezone.utc).isoformat(), + } + feature.human_input_response = response_data + feature.needs_human_input = False + # Keep in_progress=False, passes=False so it returns to pending + + session.commit() + session.refresh(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: + raise + except Exception: + logger.exception("Failed to resolve human input") + raise HTTPException(status_code=500, detail="Failed to resolve human input") + + # ============================================================================ # Dependency Management Endpoints # ============================================================================ diff --git a/server/routers/projects.py b/server/routers/projects.py index 36f7ffdc..7787ed74 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats: """Get statistics for a project.""" _init_imports() assert _count_passing_tests is not None # guaranteed by _init_imports() - passing, in_progress, total = _count_passing_tests(project_dir) + passing, in_progress, total, _needs_human_input = _count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0.0 return ProjectStats( passing=passing, diff --git a/server/schemas.py b/server/schemas.py index 5f546e2b..24b28f6a 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase): in_progress: bool blocked: bool = False # Computed: has unmet dependencies blocking_dependencies: list[int] = Field(default_factory=list) # Computed + needs_human_input: bool = False + human_input_request: dict | None = None + human_input_response: dict | None = None class Config: from_attributes = True +class HumanInputField(BaseModel): + """Schema for a single human input field.""" + id: str + label: str + type: Literal["text", "textarea", "select", "boolean"] = "text" + required: bool = True + placeholder: str | None = None + options: list[dict] | None = None # For select: [{value, label}] + + +class HumanInputRequest(BaseModel): + """Schema for an agent's human input request.""" + prompt: str + fields: list[HumanInputField] + + +class HumanInputResponse(BaseModel): + """Schema for a human's response to an input request.""" + fields: dict[str, str | bool | list[str]] + + class FeatureListResponse(BaseModel): """Response containing list of features organized by status.""" pending: list[FeatureResponse] in_progress: list[FeatureResponse] done: list[FeatureResponse] + needs_human_input: list[FeatureResponse] = Field(default_factory=list) class FeatureBulkCreate(BaseModel): @@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel): id: int name: str category: str - status: Literal["pending", "in_progress", "done", "blocked"] + status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"] priority: int dependencies: list[int] @@ -257,6 +282,7 @@ class WSProgressMessage(BaseModel): in_progress: int total: int percentage: float + needs_human_input: int = 0 class WSFeatureUpdateMessage(BaseModel): diff --git a/server/services/process_manager.py b/server/services/process_manager.py index d38d9001..240ffe18 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -255,7 +255,10 @@ def _cleanup_stale_features(self) -> None: ).all() if stuck: for f in stuck: - f.in_progress = False + # Don't clear in_progress for features blocked for human input - + # they should stay in needs_human_input state even after crash + if not getattr(f, 'needs_human_input', False): + f.in_progress = False session.commit() logger.info( "Cleaned up %d stuck feature(s) for %s", diff --git a/server/websocket.py b/server/websocket.py index e6600643..e205cdf5 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -689,15 +689,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa last_in_progress = -1 last_total = -1 + last_needs_human_input = -1 + while True: try: - passing, in_progress, total = count_passing_tests(project_dir) + passing, in_progress, total, needs_human_input = count_passing_tests(project_dir) # Only send if changed - if passing != last_passing or in_progress != last_in_progress or total != last_total: + if (passing != last_passing or in_progress != last_in_progress + or total != last_total or needs_human_input != last_needs_human_input): last_passing = passing last_in_progress = in_progress last_total = total + last_needs_human_input = needs_human_input percentage = (passing / total * 100) if total > 0 else 0 await websocket.send_json({ @@ -706,6 +710,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa "in_progress": in_progress, "total": total, "percentage": round(percentage, 1), + "needs_human_input": needs_human_input, }) await asyncio.sleep(2) # Poll every 2 seconds @@ -858,7 +863,7 @@ async def on_dev_status_change(status: str): # Send initial progress count_passing_tests = _get_count_passing_tests() - passing, in_progress, total = count_passing_tests(project_dir) + passing, in_progress, total, needs_human_input = count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0 await websocket.send_json({ "type": "progress", @@ -866,6 +871,7 @@ async def on_dev_status_change(status: str): "in_progress": in_progress, "total": total, "percentage": round(percentage, 1), + "needs_human_input": needs_human_input, }) # Keep connection alive and handle incoming messages diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b6784fc8..545911c1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -181,7 +181,7 @@ function App() { // E : Expand project with AI (when project selected, has spec and has features) if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && features && - (features.pending.length + features.in_progress.length + features.done.length) > 0) { + (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0) { e.preventDefault() setShowExpandProject(true) } @@ -443,6 +443,7 @@ function App() { features.pending.length === 0 && features.in_progress.length === 0 && features.done.length === 0 && + (features.needs_human_input?.length || 0) === 0 && wsState.agentStatus === 'running' && ( @@ -458,7 +459,7 @@ function App() { )} {/* View Toggle - only show when there are features */} - {features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && ( + {features && (features.pending.length + features.in_progress.length + features.done.length + (features.needs_human_input?.length || 0)) > 0 && (
diff --git a/ui/src/components/DependencyGraph.tsx b/ui/src/components/DependencyGraph.tsx index 4151c392..3b5b3109 100644 --- a/ui/src/components/DependencyGraph.tsx +++ b/ui/src/components/DependencyGraph.tsx @@ -15,7 +15,7 @@ import { Handle, } from '@xyflow/react' import dagre from 'dagre' -import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw } from 'lucide-react' +import { CheckCircle2, Circle, Loader2, AlertTriangle, RefreshCw, UserCircle } from 'lucide-react' import type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types' import { AgentAvatar } from './AgentAvatar' import { Button } from '@/components/ui/button' @@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component void; agent?: NodeAgentInfo } }) { - const statusColors = { + const statusColors: Record = { pending: 'bg-yellow-100 border-yellow-300 dark:bg-yellow-900/30 dark:border-yellow-700', in_progress: 'bg-cyan-100 border-cyan-300 dark:bg-cyan-900/30 dark:border-cyan-700', done: 'bg-green-100 border-green-300 dark:bg-green-900/30 dark:border-green-700', blocked: 'bg-red-50 border-red-300 dark:bg-red-900/20 dark:border-red-700', + needs_human_input: 'bg-amber-100 border-amber-300 dark:bg-amber-900/30 dark:border-amber-700', } - const textColors = { + const textColors: Record = { pending: 'text-yellow-900 dark:text-yellow-100', in_progress: 'text-cyan-900 dark:text-cyan-100', done: 'text-green-900 dark:text-green-100', blocked: 'text-red-900 dark:text-red-100', + needs_human_input: 'text-amber-900 dark:text-amber-100', } const StatusIcon = () => { @@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent return case 'blocked': return + case 'needs_human_input': + return default: return } @@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep return '#06b6d4' // cyan-500 case 'blocked': return '#ef4444' // red-500 + case 'needs_human_input': + return '#f59e0b' // amber-500 default: return '#eab308' // yellow-500 } diff --git a/ui/src/components/FeatureCard.tsx b/ui/src/components/FeatureCard.tsx index 1a4d523d..3552763b 100644 --- a/ui/src/components/FeatureCard.tsx +++ b/ui/src/components/FeatureCard.tsx @@ -1,4 +1,4 @@ -import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react' +import { CheckCircle2, Circle, Loader2, MessageCircle, UserCircle } from 'lucide-react' import type { Feature, ActiveAgent } from '../lib/types' import { DependencyBadge } from './DependencyBadge' import { AgentAvatar } from './AgentAvatar' @@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], cursor-pointer transition-all hover:border-primary py-3 ${isInProgress ? 'animate-pulse' : ''} ${feature.passes ? 'border-primary/50' : ''} - ${isBlocked && !feature.passes ? 'border-destructive/50 opacity-80' : ''} + ${feature.needs_human_input ? 'border-amber-500/50' : ''} + ${isBlocked && !feature.passes && !feature.needs_human_input ? 'border-destructive/50 opacity-80' : ''} ${hasActiveAgent ? 'ring-2 ring-primary ring-offset-2' : ''} `} > @@ -105,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], Complete + ) : feature.needs_human_input ? ( + <> + + Needs Your Input + ) : isBlocked ? ( <> diff --git a/ui/src/components/FeatureModal.tsx b/ui/src/components/FeatureModal.tsx index 25f396f2..6e3e04e3 100644 --- a/ui/src/components/FeatureModal.tsx +++ b/ui/src/components/FeatureModal.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' -import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react' -import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects' +import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react' +import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects' import { EditFeatureForm } from './EditFeatureForm' +import { HumanInputForm } from './HumanInputForm' import type { Feature } from '../lib/types' import { Dialog, @@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp const deleteFeature = useDeleteFeature(projectName) const { data: allFeatures } = useFeatures(projectName) + const resolveHumanInput = useResolveHumanInput(projectName) + // Build a map of feature ID to feature for looking up dependency names const featureMap = new Map() if (allFeatures) { - ;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => { + ;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done, ...(allFeatures.needs_human_input || [])].forEach(f => { featureMap.set(f.id, f) }) } @@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp COMPLETE + ) : feature.needs_human_input ? ( + <> + + NEEDS YOUR INPUT + ) : ( <> @@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp + {/* Human Input Request */} + {feature.needs_human_input && feature.human_input_request && ( + { + setError(null) + try { + await resolveHumanInput.mutateAsync({ featureId: feature.id, fields }) + onClose() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to submit response') + } + }} + isLoading={resolveHumanInput.isPending} + /> + )} + + {/* Previous Human Input Response */} + {feature.human_input_response && !feature.needs_human_input && ( + + + +

Human Input Provided

+

+ Response submitted{feature.human_input_response.responded_at + ? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}` + : ''}. +

+
+
+ )} + {/* Description */}

diff --git a/ui/src/components/HumanInputForm.tsx b/ui/src/components/HumanInputForm.tsx new file mode 100644 index 00000000..b9f376ad --- /dev/null +++ b/ui/src/components/HumanInputForm.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react' +import { Loader2, UserCircle, Send } from 'lucide-react' +import type { HumanInputRequest } from '../lib/types' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Switch } from '@/components/ui/switch' + +interface HumanInputFormProps { + request: HumanInputRequest + onSubmit: (fields: Record) => Promise + isLoading: boolean +} + +export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) { + const [values, setValues] = useState>(() => { + const initial: Record = {} + for (const field of request.fields) { + if (field.type === 'boolean') { + initial[field.id] = false + } else { + initial[field.id] = '' + } + } + return initial + }) + + const [validationError, setValidationError] = useState(null) + + const handleSubmit = async () => { + // Validate required fields + for (const field of request.fields) { + if (field.required) { + const val = values[field.id] + if (val === undefined || val === null || val === '') { + setValidationError(`"${field.label}" is required`) + return + } + } + } + setValidationError(null) + await onSubmit(values) + } + + return ( + + + +
+

Agent needs your help

+

+ {request.prompt} +

+
+ +
+ {request.fields.map((field) => ( +
+ + + {field.type === 'text' && ( + setValues(prev => ({ ...prev, [field.id]: e.target.value }))} + placeholder={field.placeholder || ''} + disabled={isLoading} + /> + )} + + {field.type === 'textarea' && ( +