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)
|
# Check if all features are already complete (before starting a new session)
|
||||||
# Skip this check if running as initializer (needs to create features first)
|
# Skip this check if running as initializer (needs to create features first)
|
||||||
if not is_initializer and iteration == 1:
|
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:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES ALREADY COMPLETE!")
|
print(" ALL FEATURES ALREADY COMPLETE!")
|
||||||
@@ -348,7 +348,7 @@ async def run_autonomous_agent(
|
|||||||
print_progress_summary(project_dir)
|
print_progress_summary(project_dir)
|
||||||
|
|
||||||
# Check if all features are complete - exit gracefully if done
|
# 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:
|
if total > 0 and passing == total:
|
||||||
print("\n" + "=" * 70)
|
print("\n" + "=" * 70)
|
||||||
print(" ALL FEATURES COMPLETE!")
|
print(" ALL FEATURES COMPLETE!")
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class Feature(Base):
|
|||||||
|
|
||||||
__tablename__ = "features"
|
__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
|
# Used by feature_get_stats, get_ready_features, and other status queries
|
||||||
__table_args__ = (
|
__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)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
@@ -61,6 +61,11 @@ class Feature(Base):
|
|||||||
# NULL/empty = no dependencies (backwards compatible)
|
# NULL/empty = no dependencies (backwards compatible)
|
||||||
dependencies = Column(JSON, nullable=True, default=None)
|
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:
|
def to_dict(self) -> dict:
|
||||||
"""Convert feature to dictionary for JSON serialization."""
|
"""Convert feature to dictionary for JSON serialization."""
|
||||||
return {
|
return {
|
||||||
@@ -75,6 +80,10 @@ class Feature(Base):
|
|||||||
"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: NULL/empty treated as empty list for backwards compat
|
||||||
"dependencies": self.dependencies if self.dependencies else [],
|
"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]:
|
def get_dependencies_safe(self) -> list[int]:
|
||||||
@@ -302,6 +311,21 @@ def _is_network_path(path: Path) -> bool:
|
|||||||
return False
|
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:
|
def _migrate_add_schedules_tables(engine) -> None:
|
||||||
"""Create schedules and schedule_overrides tables if they don't exist."""
|
"""Create schedules and schedule_overrides tables if they don't exist."""
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
@@ -425,6 +449,7 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
_migrate_fix_null_boolean_fields(engine)
|
_migrate_fix_null_boolean_fields(engine)
|
||||||
_migrate_add_dependencies_column(engine)
|
_migrate_add_dependencies_column(engine)
|
||||||
_migrate_add_testing_columns(engine)
|
_migrate_add_testing_columns(engine)
|
||||||
|
_migrate_add_human_input_columns(engine)
|
||||||
|
|
||||||
# Migrate to add schedules tables
|
# Migrate to add schedules tables
|
||||||
_migrate_add_schedules_tables(engine)
|
_migrate_add_schedules_tables(engine)
|
||||||
|
|||||||
@@ -151,17 +151,20 @@ def feature_get_stats() -> str:
|
|||||||
result = session.query(
|
result = session.query(
|
||||||
func.count(Feature.id).label('total'),
|
func.count(Feature.id).label('total'),
|
||||||
func.sum(case((Feature.passes == True, 1), else_=0)).label('passing'),
|
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()
|
).first()
|
||||||
|
|
||||||
total = result.total or 0
|
total = result.total or 0
|
||||||
passing = int(result.passing or 0)
|
passing = int(result.passing or 0)
|
||||||
in_progress = int(result.in_progress 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
|
percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"passing": passing,
|
"passing": passing,
|
||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": percentage
|
"percentage": percentage
|
||||||
})
|
})
|
||||||
@@ -221,6 +224,7 @@ def feature_get_summary(
|
|||||||
"name": feature.name,
|
"name": feature.name,
|
||||||
"passes": feature.passes,
|
"passes": feature.passes,
|
||||||
"in_progress": feature.in_progress,
|
"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 []
|
"dependencies": feature.dependencies or []
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
@@ -401,11 +405,11 @@ def feature_mark_in_progress(
|
|||||||
"""
|
"""
|
||||||
session = get_session()
|
session = get_session()
|
||||||
try:
|
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("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
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})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -418,6 +422,8 @@ def feature_mark_in_progress(
|
|||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
||||||
if feature.in_progress:
|
if feature.in_progress:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already 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"})
|
return json.dumps({"error": "Failed to mark feature in-progress for unknown reason"})
|
||||||
|
|
||||||
# Fetch the claimed feature
|
# Fetch the claimed feature
|
||||||
@@ -455,11 +461,14 @@ def feature_claim_and_get(
|
|||||||
if feature.passes:
|
if feature.passes:
|
||||||
return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
|
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("""
|
result = session.execute(text("""
|
||||||
UPDATE features
|
UPDATE features
|
||||||
SET in_progress = 1
|
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})
|
"""), {"id": feature_id})
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -806,6 +815,8 @@ def feature_get_ready(
|
|||||||
for f in all_features:
|
for f in all_features:
|
||||||
if f.passes or f.in_progress:
|
if f.passes or f.in_progress:
|
||||||
continue
|
continue
|
||||||
|
if getattr(f, 'needs_human_input', False):
|
||||||
|
continue
|
||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
if all(dep_id in passing_ids for dep_id in deps):
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
ready.append(f.to_dict())
|
ready.append(f.to_dict())
|
||||||
@@ -888,6 +899,8 @@ def feature_get_graph() -> str:
|
|||||||
|
|
||||||
if f.passes:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
@@ -984,6 +997,103 @@ def feature_set_dependencies(
|
|||||||
return json.dumps({"error": f"Failed to set dependencies: {str(e)}"})
|
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()
|
@mcp.tool()
|
||||||
def ask_user(
|
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)")]
|
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:
|
for fd in feature_dicts:
|
||||||
if not fd.get("in_progress") or fd.get("passes"):
|
if not fd.get("in_progress") or fd.get("passes"):
|
||||||
continue
|
continue
|
||||||
|
# Skip if blocked for human input
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
continue
|
||||||
# Skip if already running in this orchestrator instance
|
# Skip if already running in this orchestrator instance
|
||||||
if fd["id"] in running_ids:
|
if fd["id"] in running_ids:
|
||||||
continue
|
continue
|
||||||
@@ -540,11 +543,14 @@ class ParallelOrchestrator:
|
|||||||
running_ids.update(batch_ids)
|
running_ids.update(batch_ids)
|
||||||
|
|
||||||
ready = []
|
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:
|
for fd in feature_dicts:
|
||||||
if fd.get("passes"):
|
if fd.get("passes"):
|
||||||
skipped_reasons["passes"] += 1
|
skipped_reasons["passes"] += 1
|
||||||
continue
|
continue
|
||||||
|
if fd.get("needs_human_input"):
|
||||||
|
skipped_reasons["needs_human_input"] += 1
|
||||||
|
continue
|
||||||
if fd.get("in_progress"):
|
if fd.get("in_progress"):
|
||||||
skipped_reasons["in_progress"] += 1
|
skipped_reasons["in_progress"] += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
59
progress.py
59
progress.py
@@ -62,54 +62,71 @@ def has_features(project_dir: Path) -> bool:
|
|||||||
return False
|
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:
|
Args:
|
||||||
project_dir: Directory containing the project
|
project_dir: Directory containing the project
|
||||||
|
|
||||||
Returns:
|
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
|
from autoforge_paths import get_features_db_path
|
||||||
db_file = get_features_db_path(project_dir)
|
db_file = get_features_db_path(project_dir)
|
||||||
if not db_file.exists():
|
if not db_file.exists():
|
||||||
return 0, 0, 0
|
return 0, 0, 0, 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with closing(_get_connection(db_file)) as conn:
|
with closing(_get_connection(db_file)) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
# Single aggregate query instead of 3 separate COUNT queries
|
# Single aggregate query instead of separate COUNT queries
|
||||||
# Handle case where in_progress column doesn't exist yet (legacy DBs)
|
# Handle case where columns don't exist yet (legacy DBs)
|
||||||
try:
|
try:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing,
|
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
|
FROM features
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
total = row[0] or 0
|
total = row[0] or 0
|
||||||
passing = row[1] or 0
|
passing = row[1] or 0
|
||||||
in_progress = row[2] or 0
|
in_progress = row[2] or 0
|
||||||
|
needs_human_input = row[3] or 0
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
# Fallback for databases without in_progress column
|
# Fallback for databases without newer columns
|
||||||
cursor.execute("""
|
try:
|
||||||
SELECT
|
cursor.execute("""
|
||||||
COUNT(*) as total,
|
SELECT
|
||||||
SUM(CASE WHEN passes = 1 THEN 1 ELSE 0 END) as passing
|
COUNT(*) as total,
|
||||||
FROM features
|
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
|
||||||
row = cursor.fetchone()
|
FROM features
|
||||||
total = row[0] or 0
|
""")
|
||||||
passing = row[1] or 0
|
row = cursor.fetchone()
|
||||||
in_progress = 0
|
total = row[0] or 0
|
||||||
return passing, in_progress, total
|
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:
|
except Exception as e:
|
||||||
print(f"[Database error in count_passing_tests: {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]:
|
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:
|
def print_progress_summary(project_dir: Path) -> None:
|
||||||
"""Print a summary of current progress."""
|
"""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:
|
if total > 0:
|
||||||
percentage = (passing / total) * 100
|
percentage = (passing / total) * 100
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from ..schemas import (
|
|||||||
FeatureListResponse,
|
FeatureListResponse,
|
||||||
FeatureResponse,
|
FeatureResponse,
|
||||||
FeatureUpdate,
|
FeatureUpdate,
|
||||||
|
HumanInputResponse,
|
||||||
)
|
)
|
||||||
from ..utils.project_helpers import get_project_path as _get_project_path
|
from ..utils.project_helpers import get_project_path as _get_project_path
|
||||||
from ..utils.validation import validate_project_name
|
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,
|
in_progress=f.in_progress if f.in_progress is not None else False,
|
||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
blocking_dependencies=blocking,
|
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 = []
|
pending = []
|
||||||
in_progress = []
|
in_progress = []
|
||||||
done = []
|
done = []
|
||||||
|
needs_human_input_list = []
|
||||||
|
|
||||||
for f in all_features:
|
for f in all_features:
|
||||||
feature_response = feature_to_response(f, passing_ids)
|
feature_response = feature_to_response(f, passing_ids)
|
||||||
if f.passes:
|
if f.passes:
|
||||||
done.append(feature_response)
|
done.append(feature_response)
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
needs_human_input_list.append(feature_response)
|
||||||
elif f.in_progress:
|
elif f.in_progress:
|
||||||
in_progress.append(feature_response)
|
in_progress.append(feature_response)
|
||||||
else:
|
else:
|
||||||
@@ -157,6 +164,7 @@ async def list_features(project_name: str):
|
|||||||
pending=pending,
|
pending=pending,
|
||||||
in_progress=in_progress,
|
in_progress=in_progress,
|
||||||
done=done,
|
done=done,
|
||||||
|
needs_human_input=needs_human_input_list,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -341,9 +349,11 @@ async def get_dependency_graph(project_name: str):
|
|||||||
deps = f.dependencies or []
|
deps = f.dependencies or []
|
||||||
blocking = [d for d in deps if d not in passing_ids]
|
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:
|
if f.passes:
|
||||||
status = "done"
|
status = "done"
|
||||||
|
elif getattr(f, 'needs_human_input', False):
|
||||||
|
status = "needs_human_input"
|
||||||
elif blocking:
|
elif blocking:
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
elif f.in_progress:
|
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")
|
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
|
# Dependency Management Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
|
|||||||
"""Get statistics for a project."""
|
"""Get statistics for a project."""
|
||||||
_init_imports()
|
_init_imports()
|
||||||
assert _count_passing_tests is not None # guaranteed by _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
|
percentage = (passing / total * 100) if total > 0 else 0.0
|
||||||
return ProjectStats(
|
return ProjectStats(
|
||||||
passing=passing,
|
passing=passing,
|
||||||
|
|||||||
@@ -120,16 +120,41 @@ class FeatureResponse(FeatureBase):
|
|||||||
in_progress: bool
|
in_progress: bool
|
||||||
blocked: bool = False # Computed: has unmet dependencies
|
blocked: bool = False # Computed: has unmet dependencies
|
||||||
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
|
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:
|
class Config:
|
||||||
from_attributes = True
|
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):
|
class FeatureListResponse(BaseModel):
|
||||||
"""Response containing list of features organized by status."""
|
"""Response containing list of features organized by status."""
|
||||||
pending: list[FeatureResponse]
|
pending: list[FeatureResponse]
|
||||||
in_progress: list[FeatureResponse]
|
in_progress: list[FeatureResponse]
|
||||||
done: list[FeatureResponse]
|
done: list[FeatureResponse]
|
||||||
|
needs_human_input: list[FeatureResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FeatureBulkCreate(BaseModel):
|
class FeatureBulkCreate(BaseModel):
|
||||||
@@ -153,7 +178,7 @@ class DependencyGraphNode(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
status: Literal["pending", "in_progress", "done", "blocked"]
|
status: Literal["pending", "in_progress", "done", "blocked", "needs_human_input"]
|
||||||
priority: int
|
priority: int
|
||||||
dependencies: list[int]
|
dependencies: list[int]
|
||||||
|
|
||||||
@@ -257,6 +282,7 @@ class WSProgressMessage(BaseModel):
|
|||||||
in_progress: int
|
in_progress: int
|
||||||
total: int
|
total: int
|
||||||
percentage: float
|
percentage: float
|
||||||
|
needs_human_input: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WSFeatureUpdateMessage(BaseModel):
|
class WSFeatureUpdateMessage(BaseModel):
|
||||||
|
|||||||
@@ -716,15 +716,19 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
last_in_progress = -1
|
last_in_progress = -1
|
||||||
last_total = -1
|
last_total = -1
|
||||||
|
|
||||||
|
last_needs_human_input = -1
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
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
|
# 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_passing = passing
|
||||||
last_in_progress = in_progress
|
last_in_progress = in_progress
|
||||||
last_total = total
|
last_total = total
|
||||||
|
last_needs_human_input = needs_human_input
|
||||||
percentage = (passing / total * 100) if total > 0 else 0
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
@@ -733,6 +737,7 @@ async def poll_progress(websocket: WebSocket, project_name: str, project_dir: Pa
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
await asyncio.sleep(2) # Poll every 2 seconds
|
await asyncio.sleep(2) # Poll every 2 seconds
|
||||||
@@ -885,7 +890,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
|
|
||||||
# Send initial progress
|
# Send initial progress
|
||||||
count_passing_tests = _get_count_passing_tests()
|
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
|
percentage = (passing / total * 100) if total > 0 else 0
|
||||||
await websocket.send_json({
|
await websocket.send_json({
|
||||||
"type": "progress",
|
"type": "progress",
|
||||||
@@ -893,6 +898,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|||||||
"in_progress": in_progress,
|
"in_progress": in_progress,
|
||||||
"total": total,
|
"total": total,
|
||||||
"percentage": round(percentage, 1),
|
"percentage": round(percentage, 1),
|
||||||
|
"needs_human_input": needs_human_input,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Keep connection alive and handle incoming messages
|
# Keep connection alive and handle incoming messages
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ function App() {
|
|||||||
const allFeatures = [
|
const allFeatures = [
|
||||||
...(features?.pending ?? []),
|
...(features?.pending ?? []),
|
||||||
...(features?.in_progress ?? []),
|
...(features?.in_progress ?? []),
|
||||||
...(features?.done ?? [])
|
...(features?.done ?? []),
|
||||||
|
...(features?.needs_human_input ?? [])
|
||||||
]
|
]
|
||||||
const feature = allFeatures.find(f => f.id === nodeId)
|
const feature = allFeatures.find(f => f.id === nodeId)
|
||||||
if (feature) setSelectedFeature(feature)
|
if (feature) setSelectedFeature(feature)
|
||||||
@@ -181,7 +182,7 @@ function App() {
|
|||||||
|
|
||||||
// E : Expand project with AI (when project selected, has spec and has features)
|
// E : Expand project with AI (when project selected, has spec and has features)
|
||||||
if ((e.key === 'e' || e.key === 'E') && selectedProject && hasSpec && 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()
|
e.preventDefault()
|
||||||
setShowExpandProject(true)
|
setShowExpandProject(true)
|
||||||
}
|
}
|
||||||
@@ -245,7 +246,7 @@ function App() {
|
|||||||
// 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 : {
|
||||||
passing: features?.done.length ?? 0,
|
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,
|
percentage: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +444,7 @@ function App() {
|
|||||||
features.pending.length === 0 &&
|
features.pending.length === 0 &&
|
||||||
features.in_progress.length === 0 &&
|
features.in_progress.length === 0 &&
|
||||||
features.done.length === 0 &&
|
features.done.length === 0 &&
|
||||||
|
(features.needs_human_input?.length || 0) === 0 &&
|
||||||
wsState.agentStatus === 'running' && (
|
wsState.agentStatus === 'running' && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -458,7 +460,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* View Toggle - only show when there are features */}
|
{/* 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">
|
<div className="flex justify-center">
|
||||||
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Handle,
|
Handle,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import dagre from 'dagre'
|
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 type { DependencyGraph as DependencyGraphData, GraphNode, ActiveAgent, AgentMascot, AgentState } from '../lib/types'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -93,18 +93,20 @@ class GraphErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryStat
|
|||||||
|
|
||||||
// Custom node component
|
// Custom node component
|
||||||
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent?: NodeAgentInfo } }) {
|
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',
|
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',
|
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',
|
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',
|
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',
|
pending: 'text-yellow-900 dark:text-yellow-100',
|
||||||
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
in_progress: 'text-cyan-900 dark:text-cyan-100',
|
||||||
done: 'text-green-900 dark:text-green-100',
|
done: 'text-green-900 dark:text-green-100',
|
||||||
blocked: 'text-red-900 dark:text-red-100',
|
blocked: 'text-red-900 dark:text-red-100',
|
||||||
|
needs_human_input: 'text-amber-900 dark:text-amber-100',
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusIcon = () => {
|
const StatusIcon = () => {
|
||||||
@@ -115,6 +117,8 @@ function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void; agent
|
|||||||
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
return <Loader2 size={16} className={`${textColors[data.status]} animate-spin`} />
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return <AlertTriangle size={16} className="text-destructive" />
|
return <AlertTriangle size={16} className="text-destructive" />
|
||||||
|
case 'needs_human_input':
|
||||||
|
return <UserCircle size={16} className={textColors[data.status]} />
|
||||||
default:
|
default:
|
||||||
return <Circle size={16} className={textColors[data.status]} />
|
return <Circle size={16} className={textColors[data.status]} />
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,8 @@ function DependencyGraphInner({ graphData, onNodeClick, activeAgents = [] }: Dep
|
|||||||
return '#06b6d4' // cyan-500
|
return '#06b6d4' // cyan-500
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
return '#ef4444' // red-500
|
return '#ef4444' // red-500
|
||||||
|
case 'needs_human_input':
|
||||||
|
return '#f59e0b' // amber-500
|
||||||
default:
|
default:
|
||||||
return '#eab308' // yellow-500
|
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 type { Feature, ActiveAgent } from '../lib/types'
|
||||||
import { DependencyBadge } from './DependencyBadge'
|
import { DependencyBadge } from './DependencyBadge'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
@@ -45,7 +45,8 @@ export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [],
|
|||||||
cursor-pointer transition-all hover:border-primary py-3
|
cursor-pointer transition-all hover:border-primary py-3
|
||||||
${isInProgress ? 'animate-pulse' : ''}
|
${isInProgress ? 'animate-pulse' : ''}
|
||||||
${feature.passes ? 'border-primary/50' : ''}
|
${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' : ''}
|
${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" />
|
<CheckCircle2 size={16} className="text-primary" />
|
||||||
<span className="text-primary font-medium">Complete</span>
|
<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 ? (
|
) : isBlocked ? (
|
||||||
<>
|
<>
|
||||||
<Circle size={16} className="text-destructive" />
|
<Circle size={16} className="text-destructive" />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
|
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle, UserCircle } from 'lucide-react'
|
||||||
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
|
import { useSkipFeature, useDeleteFeature, useFeatures, useResolveHumanInput } from '../hooks/useProjects'
|
||||||
import { EditFeatureForm } from './EditFeatureForm'
|
import { EditFeatureForm } from './EditFeatureForm'
|
||||||
|
import { HumanInputForm } from './HumanInputForm'
|
||||||
import type { Feature } from '../lib/types'
|
import type { Feature } from '../lib/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -50,10 +51,12 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
const deleteFeature = useDeleteFeature(projectName)
|
const deleteFeature = useDeleteFeature(projectName)
|
||||||
const { data: allFeatures } = useFeatures(projectName)
|
const { data: allFeatures } = useFeatures(projectName)
|
||||||
|
|
||||||
|
const resolveHumanInput = useResolveHumanInput(projectName)
|
||||||
|
|
||||||
// Build a map of feature ID to feature for looking up dependency names
|
// Build a map of feature ID to feature for looking up dependency names
|
||||||
const featureMap = new Map<number, Feature>()
|
const featureMap = new Map<number, Feature>()
|
||||||
if (allFeatures) {
|
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)
|
featureMap.set(f.id, f)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,11 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
<CheckCircle2 size={24} className="text-primary" />
|
<CheckCircle2 size={24} className="text-primary" />
|
||||||
<span className="font-semibold text-primary">COMPLETE</span>
|
<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" />
|
<Circle size={24} className="text-muted-foreground" />
|
||||||
@@ -152,6 +160,38 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold mb-2 text-sm uppercase tracking-wide text-muted-foreground">
|
<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) {
|
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
|
// Combine all features for dependency status calculation
|
||||||
const allFeatures = features
|
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) {
|
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">
|
||||||
@@ -40,7 +43,7 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<KanbanColumn
|
||||||
title="Pending"
|
title="Pending"
|
||||||
count={features.pending.length}
|
count={features.pending.length}
|
||||||
@@ -64,6 +67,17 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
|
|||||||
color="progress"
|
color="progress"
|
||||||
onFeatureClick={onFeatureClick}
|
onFeatureClick={onFeatureClick}
|
||||||
/>
|
/>
|
||||||
|
{showNeedsInput && (
|
||||||
|
<KanbanColumn
|
||||||
|
title="Needs Input"
|
||||||
|
count={needsInputCount}
|
||||||
|
features={features.needs_human_input}
|
||||||
|
allFeatures={allFeatures}
|
||||||
|
activeAgents={activeAgents}
|
||||||
|
color="human_input"
|
||||||
|
onFeatureClick={onFeatureClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
title="Done"
|
title="Done"
|
||||||
count={features.done.length}
|
count={features.done.length}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface KanbanColumnProps {
|
|||||||
features: Feature[]
|
features: Feature[]
|
||||||
allFeatures?: Feature[]
|
allFeatures?: Feature[]
|
||||||
activeAgents?: ActiveAgent[]
|
activeAgents?: ActiveAgent[]
|
||||||
color: 'pending' | 'progress' | 'done'
|
color: 'pending' | 'progress' | 'done' | 'human_input'
|
||||||
onFeatureClick: (feature: Feature) => void
|
onFeatureClick: (feature: Feature) => void
|
||||||
onAddFeature?: () => void
|
onAddFeature?: () => void
|
||||||
onExpandProject?: () => void
|
onExpandProject?: () => void
|
||||||
@@ -24,6 +24,7 @@ const colorMap = {
|
|||||||
pending: 'border-t-4 border-t-muted',
|
pending: 'border-t-4 border-t-muted',
|
||||||
progress: 'border-t-4 border-t-primary',
|
progress: 'border-t-4 border-t-primary',
|
||||||
done: '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({
|
export function KanbanColumn({
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function isAllComplete(features: FeatureListResponse | undefined): boolean {
|
|||||||
return (
|
return (
|
||||||
features.pending.length === 0 &&
|
features.pending.length === 0 &&
|
||||||
features.in_progress.length === 0 &&
|
features.in_progress.length === 0 &&
|
||||||
|
(features.needs_human_input?.length || 0) === 0 &&
|
||||||
features.done.length > 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
|
// Agent
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface WebSocketState {
|
|||||||
progress: {
|
progress: {
|
||||||
passing: number
|
passing: number
|
||||||
in_progress: number
|
in_progress: number
|
||||||
|
needs_human_input: number
|
||||||
total: number
|
total: number
|
||||||
percentage: 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) {
|
export function useProjectWebSocket(projectName: string | null) {
|
||||||
const [state, setState] = useState<WebSocketState>({
|
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',
|
agentStatus: 'loading',
|
||||||
logs: [],
|
logs: [],
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -107,6 +108,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
progress: {
|
progress: {
|
||||||
passing: message.passing,
|
passing: message.passing,
|
||||||
in_progress: message.in_progress,
|
in_progress: message.in_progress,
|
||||||
|
needs_human_input: message.needs_human_input ?? 0,
|
||||||
total: message.total,
|
total: message.total,
|
||||||
percentage: message.percentage,
|
percentage: message.percentage,
|
||||||
},
|
},
|
||||||
@@ -385,7 +387,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
// Reset state when project changes to clear stale data
|
// Reset state when project changes to clear stale data
|
||||||
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
|
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
|
||||||
setState({
|
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',
|
agentStatus: 'loading',
|
||||||
logs: [],
|
logs: [],
|
||||||
isConnected: false,
|
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
|
// Dependency Graph API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -57,6 +57,26 @@ export interface ProjectPrompts {
|
|||||||
coding_prompt: string
|
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
|
// Feature types
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
id: number
|
id: number
|
||||||
@@ -70,10 +90,13 @@ export interface Feature {
|
|||||||
dependencies?: number[] // Optional for backwards compat
|
dependencies?: number[] // Optional for backwards compat
|
||||||
blocked?: boolean // Computed by API
|
blocked?: boolean // Computed by API
|
||||||
blocking_dependencies?: number[] // 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
|
// 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
|
// Graph visualization types
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
@@ -99,6 +122,7 @@ export interface FeatureListResponse {
|
|||||||
pending: Feature[]
|
pending: Feature[]
|
||||||
in_progress: Feature[]
|
in_progress: Feature[]
|
||||||
done: Feature[]
|
done: Feature[]
|
||||||
|
needs_human_input: Feature[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureCreate {
|
export interface FeatureCreate {
|
||||||
@@ -250,6 +274,7 @@ export interface WSProgressMessage {
|
|||||||
in_progress: number
|
in_progress: number
|
||||||
total: number
|
total: number
|
||||||
percentage: number
|
percentage: number
|
||||||
|
needs_human_input?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WSFeatureUpdateMessage {
|
export interface WSFeatureUpdateMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user