mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-16 18:33:08 +00:00
Merge pull request #184 from CaitlynByrne/feature/blocked-for-human-input
feat: add blocked for human input feature
This commit is contained in:
4
agent.py
4
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!")
|
||||
@@ -348,7 +348,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!")
|
||||
|
||||
@@ -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 @@ class Feature(Base):
|
||||
"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)
|
||||
|
||||
@@ -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,103 @@ 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
|
||||
VALID_FIELD_TYPES = {"text", "textarea", "select", "boolean"}
|
||||
seen_ids: set[str] = set()
|
||||
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'"})
|
||||
fid = field["id"]
|
||||
flabel = field["label"]
|
||||
if not isinstance(fid, str) or not fid.strip():
|
||||
return json.dumps({"error": f"Field at index {i} has empty or invalid 'id'"})
|
||||
if not isinstance(flabel, str) or not flabel.strip():
|
||||
return json.dumps({"error": f"Field at index {i} has empty or invalid 'label'"})
|
||||
if fid in seen_ids:
|
||||
return json.dumps({"error": f"Duplicate field id '{fid}' at index {i}"})
|
||||
seen_ids.add(fid)
|
||||
ftype = field.get("type", "text")
|
||||
if ftype not in VALID_FIELD_TYPES:
|
||||
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
|
||||
if ftype == "select" and not field.get("options"):
|
||||
return json.dumps({"error": f"Field at index {i} is type 'select' but missing 'options' array"})
|
||||
|
||||
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 AND in_progress = 1
|
||||
"""), {"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"})
|
||||
if not feature.in_progress:
|
||||
return json.dumps({"error": f"Feature with ID {feature_id} is not in progress"})
|
||||
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)")]
|
||||
|
||||
@@ -496,6 +496,9 @@ class ParallelOrchestrator:
|
||||
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
|
||||
@@ -540,11 +543,14 @@ class ParallelOrchestrator:
|
||||
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
|
||||
|
||||
37
progress.py
37
progress.py
@@ -62,26 +62,42 @@ 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 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 newer columns
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
@@ -94,8 +110,8 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
|
||||
total = row[0] or 0
|
||||
passing = row[1] or 0
|
||||
in_progress = row[2] or 0
|
||||
needs_human_input = 0
|
||||
except sqlite3.OperationalError:
|
||||
# Fallback for databases without in_progress column
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
@@ -106,10 +122,11 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
|
||||
total = row[0] or 0
|
||||
passing = row[1] or 0
|
||||
in_progress = 0
|
||||
return passing, in_progress, total
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..schemas import (
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -716,15 +716,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({
|
||||
@@ -733,6 +737,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
|
||||
@@ -885,7 +890,7 @@ async def project_websocket(websocket: WebSocket, project_name: 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",
|
||||
@@ -893,6 +898,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
||||
"in_progress": in_progress,
|
||||
"total": total,
|
||||
"percentage": round(percentage, 1),
|
||||
"needs_human_input": needs_human_input,
|
||||
})
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
|
||||
@@ -130,7 +130,8 @@ function App() {
|
||||
const allFeatures = [
|
||||
...(features?.pending ?? []),
|
||||
...(features?.in_progress ?? []),
|
||||
...(features?.done ?? [])
|
||||
...(features?.done ?? []),
|
||||
...(features?.needs_human_input ?? [])
|
||||
]
|
||||
const feature = allFeatures.find(f => f.id === nodeId)
|
||||
if (feature) setSelectedFeature(feature)
|
||||
@@ -181,7 +182,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)
|
||||
}
|
||||
@@ -245,7 +246,7 @@ function App() {
|
||||
// Combine WebSocket progress with feature data
|
||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||
passing: features?.done.length ?? 0,
|
||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
|
||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0) + (features?.needs_human_input?.length ?? 0),
|
||||
percentage: 0,
|
||||
}
|
||||
|
||||
@@ -443,6 +444,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' && (
|
||||
<Card className="p-8 text-center">
|
||||
<CardContent className="p-0">
|
||||
@@ -458,7 +460,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 && (
|
||||
<div className="flex justify-center">
|
||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
</div>
|
||||
|
||||
@@ -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<ErrorBoundaryProps, ErrorBoundaryStat
|
||||
|
||||
// Custom node component
|
||||
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
||||
const statusColors = {
|
||||
const statusColors: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
||||
case 'blocked':
|
||||
return <AlertTriangle size={16} className="text-destructive" />
|
||||
case 'needs_human_input':
|
||||
return <UserCircle size={16} className={textColors[data.status]} />
|
||||
default:
|
||||
return <Circle size={16} className={textColors[data.status]} />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = [],
|
||||
<CheckCircle2 size={16} className="text-primary" />
|
||||
<span className="text-primary font-medium">Complete</span>
|
||||
</>
|
||||
) : feature.needs_human_input ? (
|
||||
<>
|
||||
<UserCircle size={16} className="text-amber-500" />
|
||||
<span className="text-amber-500 font-medium">Needs Your Input</span>
|
||||
</>
|
||||
) : isBlocked ? (
|
||||
<>
|
||||
<Circle size={16} className="text-destructive" />
|
||||
|
||||
@@ -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<number, Feature>()
|
||||
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
|
||||
<CheckCircle2 size={24} className="text-primary" />
|
||||
<span className="font-semibold text-primary">COMPLETE</span>
|
||||
</>
|
||||
) : feature.needs_human_input ? (
|
||||
<>
|
||||
<UserCircle size={24} className="text-amber-500" />
|
||||
<span className="font-semibold text-amber-500">NEEDS YOUR INPUT</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={24} className="text-muted-foreground" />
|
||||
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Human Input Request */}
|
||||
{feature.needs_human_input && feature.human_input_request && (
|
||||
<HumanInputForm
|
||||
request={feature.human_input_request}
|
||||
onSubmit={async (fields) => {
|
||||
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 && (
|
||||
<Alert className="border-green-500 bg-green-50 dark:bg-green-950/20">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
<h4 className="font-semibold mb-1 text-green-700 dark:text-green-400">Human Input Provided</h4>
|
||||
<p className="text-sm text-green-600 dark:text-green-300">
|
||||
Response submitted{feature.human_input_response.responded_at
|
||||
? ` at ${new Date(feature.human_input_response.responded_at).toLocaleString()}`
|
||||
: ''}.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
||||
|
||||
150
ui/src/components/HumanInputForm.tsx
Normal file
150
ui/src/components/HumanInputForm.tsx
Normal file
@@ -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<string, string | boolean | string[]>) => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function HumanInputForm({ request, onSubmit, isLoading }: HumanInputFormProps) {
|
||||
const [values, setValues] = useState<Record<string, string | boolean | string[]>>(() => {
|
||||
const initial: Record<string, string | boolean | string[]> = {}
|
||||
for (const field of request.fields) {
|
||||
if (field.type === 'boolean') {
|
||||
initial[field.id] = false
|
||||
} else {
|
||||
initial[field.id] = ''
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const [validationError, setValidationError] = useState<string | null>(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 (
|
||||
<Alert className="border-amber-500 bg-amber-50 dark:bg-amber-950/20">
|
||||
<UserCircle className="h-5 w-5 text-amber-600" />
|
||||
<AlertDescription className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-amber-700 dark:text-amber-400">Agent needs your help</h4>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-300 mt-1">
|
||||
{request.prompt}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{request.fields.map((field) => (
|
||||
<div key={field.id} className="space-y-1.5">
|
||||
<Label htmlFor={`human-input-${field.id}`} className="text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<Input
|
||||
id={`human-input-${field.id}`}
|
||||
value={values[field.id] as string}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<Textarea
|
||||
id={`human-input-${field.id}`}
|
||||
value={values[field.id] as string}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<div className="space-y-1.5">
|
||||
{field.options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors
|
||||
${values[field.id] === option.value
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`human-input-${field.id}`}
|
||||
value={option.value}
|
||||
checked={values[field.id] === option.value}
|
||||
onChange={(e) => setValues(prev => ({ ...prev, [field.id]: e.target.value }))}
|
||||
disabled={isLoading}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={`human-input-${field.id}`}
|
||||
checked={values[field.id] as boolean}
|
||||
onCheckedChange={(checked) => setValues(prev => ({ ...prev, [field.id]: checked }))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label htmlFor={`human-input-${field.id}`} className="text-sm">
|
||||
{values[field.id] ? 'Yes' : 'No'}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<p className="text-sm text-destructive">{validationError}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} />
|
||||
Submit Response
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,16 @@ interface KanbanBoardProps {
|
||||
}
|
||||
|
||||
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: 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 + (features.needs_human_input?.length || 0)) > 0
|
||||
|
||||
// Combine all features for dependency status calculation
|
||||
const allFeatures = features
|
||||
? [...features.pending, ...features.in_progress, ...features.done]
|
||||
? [...features.pending, ...features.in_progress, ...features.done, ...(features.needs_human_input || [])]
|
||||
: []
|
||||
|
||||
const needsInputCount = features?.needs_human_input?.length || 0
|
||||
const showNeedsInput = needsInputCount > 0
|
||||
|
||||
if (!features) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className={`grid grid-cols-1 ${showNeedsInput ? 'md:grid-cols-4' : 'md:grid-cols-3'} gap-6`}>
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
count={features.pending.length}
|
||||
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
||||
color="progress"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
{showNeedsInput && (
|
||||
<KanbanColumn
|
||||
title="Needs Input"
|
||||
count={needsInputCount}
|
||||
features={features.needs_human_input}
|
||||
allFeatures={allFeatures}
|
||||
activeAgents={activeAgents}
|
||||
color="human_input"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
)}
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
count={features.done.length}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface KanbanColumnProps {
|
||||
features: Feature[]
|
||||
allFeatures?: Feature[]
|
||||
activeAgents?: ActiveAgent[]
|
||||
color: 'pending' | 'progress' | 'done'
|
||||
color: 'pending' | 'progress' | 'done' | 'human_input'
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
onAddFeature?: () => void
|
||||
onExpandProject?: () => void
|
||||
@@ -24,6 +24,7 @@ const colorMap = {
|
||||
pending: 'border-t-4 border-t-muted',
|
||||
progress: 'border-t-4 border-t-primary',
|
||||
done: 'border-t-4 border-t-primary',
|
||||
human_input: 'border-t-4 border-t-amber-500',
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
|
||||
@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
|
||||
return (
|
||||
features.pending.length === 0 &&
|
||||
features.in_progress.length === 0 &&
|
||||
(features.needs_human_input?.length || 0) === 0 &&
|
||||
features.done.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,6 +133,18 @@ export function useUpdateFeature(projectName: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useResolveHumanInput(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ featureId, fields }: { featureId: number; fields: Record<string, string | boolean | string[]> }) =>
|
||||
api.resolveHumanInput(projectName, featureId, { fields }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent
|
||||
// ============================================================================
|
||||
|
||||
@@ -33,6 +33,7 @@ interface WebSocketState {
|
||||
progress: {
|
||||
passing: number
|
||||
in_progress: number
|
||||
needs_human_input: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
@@ -60,7 +61,7 @@ const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
|
||||
|
||||
export function useProjectWebSocket(projectName: string | null) {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
|
||||
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
|
||||
agentStatus: 'loading',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
@@ -107,6 +108,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
progress: {
|
||||
passing: message.passing,
|
||||
in_progress: message.in_progress,
|
||||
needs_human_input: message.needs_human_input ?? 0,
|
||||
total: message.total,
|
||||
percentage: message.percentage,
|
||||
},
|
||||
@@ -385,7 +387,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
// Reset state when project changes to clear stale data
|
||||
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
|
||||
setState({
|
||||
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
|
||||
progress: { passing: 0, in_progress: 0, needs_human_input: 0, total: 0, percentage: 0 },
|
||||
agentStatus: 'loading',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
|
||||
@@ -181,6 +181,17 @@ export async function createFeaturesBulk(
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveHumanInput(
|
||||
projectName: string,
|
||||
featureId: number,
|
||||
response: { fields: Record<string, string | boolean | string[]> }
|
||||
): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/resolve-human-input`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(response),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dependency Graph API
|
||||
// ============================================================================
|
||||
|
||||
@@ -57,6 +57,26 @@ export interface ProjectPrompts {
|
||||
coding_prompt: string
|
||||
}
|
||||
|
||||
// Human input types
|
||||
export interface HumanInputField {
|
||||
id: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select' | 'boolean'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
options?: { value: string; label: string }[]
|
||||
}
|
||||
|
||||
export interface HumanInputRequest {
|
||||
prompt: string
|
||||
fields: HumanInputField[]
|
||||
}
|
||||
|
||||
export interface HumanInputResponseData {
|
||||
fields: Record<string, string | boolean | string[]>
|
||||
responded_at?: string
|
||||
}
|
||||
|
||||
// Feature types
|
||||
export interface Feature {
|
||||
id: number
|
||||
@@ -70,10 +90,13 @@ export interface Feature {
|
||||
dependencies?: number[] // Optional for backwards compat
|
||||
blocked?: boolean // Computed by API
|
||||
blocking_dependencies?: number[] // Computed by API
|
||||
needs_human_input?: boolean
|
||||
human_input_request?: HumanInputRequest | null
|
||||
human_input_response?: HumanInputResponseData | null
|
||||
}
|
||||
|
||||
// Status type for graph nodes
|
||||
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
|
||||
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked' | 'needs_human_input'
|
||||
|
||||
// Graph visualization types
|
||||
export interface GraphNode {
|
||||
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
|
||||
pending: Feature[]
|
||||
in_progress: Feature[]
|
||||
done: Feature[]
|
||||
needs_human_input: Feature[]
|
||||
}
|
||||
|
||||
export interface FeatureCreate {
|
||||
@@ -250,6 +274,7 @@ export interface WSProgressMessage {
|
||||
in_progress: number
|
||||
total: number
|
||||
percentage: number
|
||||
needs_human_input?: number
|
||||
}
|
||||
|
||||
export interface WSFeatureUpdateMessage {
|
||||
|
||||
Reference in New Issue
Block a user