From 220e0eb134fe354ad4de8e45b0ee2cc50518b19c Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 17:02:15 +1030 Subject: [PATCH 1/6] chore: delete epic/task model, schema, API, component, test, and frontend files Remove the entire Epics & Tasks subsystem files: - Backend: models/epic.py, schemas/epic.py, api/epics.py, api/tasks.py, api/epic_helpers.py, components/epic_tools.py, components/task_tools.py - Tests: test_epics_tasks.py, test_epic_task_tools.py - Frontend: features/epics/, api/epics.ts, api/tasks.ts Co-Authored-By: Claude Opus 4.6 --- platform/api/epic_helpers.py | 140 -- platform/api/epics.py | 182 --- platform/api/tasks.py | 216 --- platform/components/epic_tools.py | 302 ---- platform/components/task_tools.py | 347 ---- platform/frontend/src/api/epics.ts | 48 - platform/frontend/src/api/tasks.ts | 50 - .../src/features/epics/EpicDetailPage.tsx | 246 --- .../frontend/src/features/epics/EpicsPage.tsx | 142 -- platform/models/epic.py | 132 -- platform/schemas/epic.py | 134 -- platform/tests/test_epic_task_tools.py | 1389 ----------------- platform/tests/test_epics_tasks.py | 413 ----- 13 files changed, 3741 deletions(-) delete mode 100644 platform/api/epic_helpers.py delete mode 100644 platform/api/epics.py delete mode 100644 platform/api/tasks.py delete mode 100644 platform/components/epic_tools.py delete mode 100644 platform/components/task_tools.py delete mode 100644 platform/frontend/src/api/epics.ts delete mode 100644 platform/frontend/src/api/tasks.ts delete mode 100644 platform/frontend/src/features/epics/EpicDetailPage.tsx delete mode 100644 platform/frontend/src/features/epics/EpicsPage.tsx delete mode 100644 platform/models/epic.py delete mode 100644 platform/schemas/epic.py delete mode 100644 platform/tests/test_epic_task_tools.py delete mode 100644 platform/tests/test_epics_tasks.py diff --git a/platform/api/epic_helpers.py b/platform/api/epic_helpers.py deleted file mode 100644 index 88f3dabf..00000000 --- a/platform/api/epic_helpers.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Shared helpers for epic/task API routers.""" - -from __future__ import annotations - -import logging - -from sqlalchemy.orm import Session - -from models.epic import Epic, Task - -logger = logging.getLogger(__name__) - - -def remove_from_depends_on(task_ids: list[str], db: Session) -> None: - """Remove deleted task IDs from other tasks' depends_on lists.""" - id_set = set(task_ids) - dependents = db.query(Task).filter(Task.depends_on.isnot(None)).all() - for t in dependents: - deps = t.depends_on or [] - cleaned = [d for d in deps if d not in id_set] - if len(cleaned) != len(deps): - t.depends_on = cleaned - - -def sync_epic_progress(epic: Epic, db: Session) -> None: - """Recount total/completed/failed tasks from DB and update the epic.""" - epic.total_tasks = db.query(Task).filter(Task.epic_id == epic.id).count() - epic.completed_tasks = ( - db.query(Task).filter(Task.epic_id == epic.id, Task.status == "completed").count() - ) - epic.failed_tasks = ( - db.query(Task).filter(Task.epic_id == epic.id, Task.status == "failed").count() - ) - - -def resolve_blocked_tasks(completed_task_id: str, db: Session) -> None: - """Unblock tasks whose dependencies are now all completed. - - When a task is marked completed, find all tasks that depend on it and - check if ALL of their dependencies are now completed. If so, transition - them from ``blocked`` → ``pending``. - """ - # Find tasks whose depends_on JSON array contains the completed task ID - dependents = ( - db.query(Task) - .filter(Task.depends_on.isnot(None), Task.status == "blocked") - .all() - ) - for task in dependents: - deps = task.depends_on or [] - if completed_task_id not in deps: - continue - - # Check if ALL dependencies are now completed - completed_count = ( - db.query(Task) - .filter(Task.id.in_(deps), Task.status == "completed") - .count() - ) - if completed_count >= len(deps): - task.status = "pending" - logger.info( - "Unblocked task %s (all %d dependencies completed)", - task.id, len(deps), - ) - - # Broadcast update - try: - from ws.broadcast import broadcast - broadcast(f"epic:{task.epic_id}", "task_updated", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task_updated for unblocked task %s", task.id) - - # Sync epic progress - epic = db.query(Epic).filter(Epic.id == task.epic_id).first() - if epic: - sync_epic_progress(epic, db) - - -def serialize_epic(epic: Epic) -> dict: - """Serialize an Epic to EpicOut shape.""" - return { - "id": epic.id, - "title": epic.title, - "description": epic.description or "", - "tags": epic.tags or [], - "created_by_node_id": epic.created_by_node_id, - "workflow_id": epic.workflow_id, - "user_profile_id": epic.user_profile_id, - "status": epic.status, - "priority": epic.priority, - "budget_tokens": epic.budget_tokens, - "budget_usd": epic.budget_usd, - "spent_tokens": epic.spent_tokens, - "spent_usd": epic.spent_usd, - "agent_overhead_tokens": epic.agent_overhead_tokens, - "agent_overhead_usd": epic.agent_overhead_usd, - "total_tasks": epic.total_tasks, - "completed_tasks": epic.completed_tasks, - "failed_tasks": epic.failed_tasks, - "created_at": epic.created_at, - "updated_at": epic.updated_at, - "completed_at": epic.completed_at, - "result_summary": epic.result_summary, - } - - -def serialize_task(task: Task) -> dict: - """Serialize a Task to TaskOut shape.""" - return { - "id": task.id, - "epic_id": task.epic_id, - "title": task.title, - "description": task.description or "", - "tags": task.tags or [], - "created_by_node_id": task.created_by_node_id, - "status": task.status, - "priority": task.priority, - "workflow_id": task.workflow_id, - "workflow_slug": task.workflow_slug, - "execution_id": task.execution_id, - "workflow_source": task.workflow_source, - "depends_on": task.depends_on or [], - "requirements": task.requirements, - "estimated_tokens": task.estimated_tokens, - "actual_tokens": task.actual_tokens, - "actual_usd": task.actual_usd, - "llm_calls": task.llm_calls, - "tool_invocations": task.tool_invocations, - "duration_ms": task.duration_ms, - "created_at": task.created_at, - "updated_at": task.updated_at, - "started_at": task.started_at, - "completed_at": task.completed_at, - "result_summary": task.result_summary, - "error_message": task.error_message, - "retry_count": task.retry_count, - "max_retries": task.max_retries, - "notes": task.notes or [], - } diff --git a/platform/api/epics.py b/platform/api/epics.py deleted file mode 100644 index 1ca0680a..00000000 --- a/platform/api/epics.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Epic CRUD API router.""" - -from __future__ import annotations - -import logging - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import or_ -from sqlalchemy.orm import Session - -from auth import get_current_user -from database import get_db -from models.epic import Epic, Task -from models.user import UserProfile -from schemas.epic import BatchDeleteEpicsIn, EpicCreate, EpicOut, EpicUpdate, TaskOut -from api.epic_helpers import serialize_epic, serialize_task, sync_epic_progress -from ws.broadcast import broadcast - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/") -def list_epics( - limit: int = 50, - offset: int = 0, - status: str | None = None, - tags: str | None = None, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - q = db.query(Epic) - if status: - q = q.filter(Epic.status == status) - if tags: - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - if tag_list: - q = q.filter(or_(*[Epic.tags.contains(tag) for tag in tag_list])) - total = q.count() - epics = q.order_by(Epic.created_at.desc()).offset(offset).limit(limit).all() - return {"items": [serialize_epic(e) for e in epics], "total": total} - - -@router.post("/", response_model=EpicOut, status_code=201) -def create_epic( - payload: EpicCreate, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = Epic( - title=payload.title, - description=payload.description, - tags=payload.tags, - priority=payload.priority, - budget_tokens=payload.budget_tokens, - budget_usd=payload.budget_usd, - workflow_id=payload.workflow_id, - user_profile_id=profile.id, - ) - db.add(epic) - db.commit() - db.refresh(epic) - try: - broadcast(f"epic:{epic.id}", "epic_created", serialize_epic(epic)) - except Exception: - logger.exception("Failed to broadcast epic event") - return serialize_epic(epic) - - -@router.get("/{epic_id}/", response_model=EpicOut) -def get_epic( - epic_id: str, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = ( - db.query(Epic) - .filter(Epic.id == epic_id) - .first() - ) - if not epic: - raise HTTPException(status_code=404, detail="Epic not found.") - return serialize_epic(epic) - - -@router.patch("/{epic_id}/", response_model=EpicOut) -def update_epic( - epic_id: str, - payload: EpicUpdate, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = ( - db.query(Epic) - .filter(Epic.id == epic_id) - .first() - ) - if not epic: - raise HTTPException(status_code=404, detail="Epic not found.") - - update_data = payload.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(epic, field, value) - - # If status changed to cancelled, cascade-cancel pending/running tasks - if payload.status == "cancelled": - tasks = ( - db.query(Task) - .filter( - Task.epic_id == epic.id, - Task.status.in_(["pending", "blocked", "running"]), - ) - .all() - ) - for task in tasks: - task.status = "cancelled" - sync_epic_progress(epic, db) - - db.commit() - db.refresh(epic) - try: - broadcast(f"epic:{epic.id}", "epic_updated", serialize_epic(epic)) - except Exception: - logger.exception("Failed to broadcast epic event") - return serialize_epic(epic) - - -@router.delete("/{epic_id}/", status_code=204) -def delete_epic( - epic_id: str, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = ( - db.query(Epic) - .filter(Epic.id == epic_id) - .first() - ) - if not epic: - raise HTTPException(status_code=404, detail="Epic not found.") - db.delete(epic) - db.commit() - try: - broadcast(f"epic:{epic_id}", "epic_deleted", {"id": epic_id}) - except Exception: - logger.exception("Failed to broadcast epic event") - - -@router.post("/batch-delete/", status_code=204) -def batch_delete_epics( - payload: BatchDeleteEpicsIn, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - if not payload.epic_ids: - return - db.query(Task).filter(Task.epic_id.in_(payload.epic_ids)).delete( - synchronize_session=False - ) - db.query(Epic).filter( - Epic.id.in_(payload.epic_ids) - ).delete(synchronize_session=False) - db.commit() - - -@router.get("/{epic_id}/tasks/") -def list_epic_tasks( - epic_id: str, - limit: int = 50, - offset: int = 0, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if not epic: - raise HTTPException(status_code=404, detail="Epic not found.") - - q = db.query(Task).filter(Task.epic_id == epic_id) - total = q.count() - tasks = q.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() - return {"items": [serialize_task(t) for t in tasks], "total": total} diff --git a/platform/api/tasks.py b/platform/api/tasks.py deleted file mode 100644 index e2ef9f7a..00000000 --- a/platform/api/tasks.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Task CRUD API router.""" - -from __future__ import annotations - -import logging - -from collections import defaultdict - -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import or_ -from sqlalchemy.orm import Session - -from auth import get_current_user -from database import get_db -from models.epic import Epic, Task -from models.user import UserProfile -from schemas.epic import BatchDeleteTasksIn, TaskCreate, TaskOut, TaskUpdate -from api.epic_helpers import remove_from_depends_on, resolve_blocked_tasks, serialize_task, sync_epic_progress -from ws.broadcast import broadcast - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -@router.get("/") -def list_tasks( - limit: int = 50, - offset: int = 0, - epic_id: str | None = None, - status: str | None = None, - tags: str | None = None, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - q = db.query(Task) - if epic_id: - q = q.filter(Task.epic_id == epic_id) - if status: - q = q.filter(Task.status == status) - if tags: - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - if tag_list: - q = q.filter(or_(*[Task.tags.contains(tag) for tag in tag_list])) - total = q.count() - tasks = q.order_by(Task.created_at.desc()).offset(offset).limit(limit).all() - return {"items": [serialize_task(t) for t in tasks], "total": total} - - -@router.post("/", response_model=TaskOut, status_code=201) -def create_task( - payload: TaskCreate, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - epic = ( - db.query(Epic) - .filter(Epic.id == payload.epic_id) - .first() - ) - if not epic: - raise HTTPException(status_code=404, detail="Epic not found.") - - # Determine initial status — blocked if depends_on has unfinished deps - initial_status = "pending" - if payload.depends_on: - completed_deps = ( - db.query(Task) - .filter(Task.id.in_(payload.depends_on), Task.status == "completed") - .count() - ) - if completed_deps < len(payload.depends_on): - initial_status = "blocked" - - task = Task( - epic_id=payload.epic_id, - title=payload.title, - description=payload.description, - tags=payload.tags, - depends_on=payload.depends_on, - priority=payload.priority if payload.priority is not None else 2, - workflow_slug=payload.workflow_slug, - estimated_tokens=payload.estimated_tokens, - max_retries=payload.max_retries, - requirements=payload.requirements, - status=initial_status, - ) - db.add(task) - db.flush() - - sync_epic_progress(epic, db) - - db.commit() - db.refresh(task) - try: - broadcast(f"epic:{task.epic_id}", "task_created", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task event") - return serialize_task(task) - - -@router.get("/{task_id}/", response_model=TaskOut) -def get_task( - task_id: str, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found.") - return serialize_task(task) - - -@router.patch("/{task_id}/", response_model=TaskOut) -def update_task( - task_id: str, - payload: TaskUpdate, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found.") - - update_data = payload.model_dump(exclude_unset=True) - for field, value in update_data.items(): - setattr(task, field, value) - - # Flush so count queries in sync_epic_progress see the updated status - db.flush() - - # Auto-unblock dependent tasks when this task is completed - if update_data.get("status") == "completed": - resolve_blocked_tasks(task_id, db) - - # Sync epic progress counters - epic = db.query(Epic).filter(Epic.id == task.epic_id).first() - if epic: - sync_epic_progress(epic, db) - - db.commit() - db.refresh(task) - try: - broadcast(f"epic:{task.epic_id}", "task_updated", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task event") - return serialize_task(task) - - -@router.delete("/{task_id}/", status_code=204) -def delete_task( - task_id: str, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - raise HTTPException(status_code=404, detail="Task not found.") - - epic_id = task.epic_id - remove_from_depends_on([task.id], db) - db.delete(task) - db.flush() - - # Sync epic progress counters - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if epic: - sync_epic_progress(epic, db) - - db.commit() - try: - broadcast(f"epic:{epic_id}", "task_deleted", {"id": task_id}) - except Exception: - logger.exception("Failed to broadcast task event") - - -@router.post("/batch-delete/", status_code=204) -def batch_delete_tasks( - payload: BatchDeleteTasksIn, - db: Session = Depends(get_db), - profile: UserProfile = Depends(get_current_user), -): - if not payload.task_ids: - return - - # Find affected epic IDs and task IDs before deleting - affected_tasks = ( - db.query(Task) - .filter(Task.id.in_(payload.task_ids)) - .all() - ) - epic_to_task_ids: dict[str, list[str]] = defaultdict(list) - for t in affected_tasks: - epic_to_task_ids[t.epic_id].append(t.id) - affected_epic_ids = set(epic_to_task_ids.keys()) - deleted_task_ids = [t.id for t in affected_tasks] - - remove_from_depends_on(deleted_task_ids, db) - - db.query(Task).filter( - Task.id.in_(payload.task_ids), - ).delete(synchronize_session=False) - - # Sync progress for affected epics - for epic_id in affected_epic_ids: - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if epic: - sync_epic_progress(epic, db) - - db.commit() - - for epic_id, task_ids in epic_to_task_ids.items(): - try: - broadcast(f"epic:{epic_id}", "tasks_deleted", {"task_ids": task_ids}) - except Exception: - logger.exception("Failed to broadcast task event") diff --git a/platform/components/epic_tools.py b/platform/components/epic_tools.py deleted file mode 100644 index 62c7c2d5..00000000 --- a/platform/components/epic_tools.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Epic tools component — LangChain tools for managing epics.""" - -from __future__ import annotations - -import json -import logging - -from langchain_core.tools import tool - -from components import register - -logger = logging.getLogger(__name__) - - -@register("epic_tools") -def epic_tools_factory(node): - """Return a list of LangChain tools for epic management.""" - from database import SessionLocal - from models.workflow import Workflow - - db = SessionLocal() - try: - workflow = db.query(Workflow).filter(Workflow.id == node.workflow_id).first() - if not workflow: - raise ValueError(f"epic_tools: workflow {node.workflow_id} not found - cannot resolve owner") - user_profile_id = workflow.owner_id - if not user_profile_id: - raise ValueError(f"epic_tools: workflow {node.workflow_id} has no owner_id - cannot resolve owner") - finally: - db.close() - - tool_node_id = node.node_id - - @tool - def create_epic( - title: str, - description: str = "", - tags: str = "", - priority: int = 2, - budget_tokens: int | None = None, - budget_usd: float | None = None, - ) -> str: - """Create a new epic for organizing tasks. - - Args: - title: Epic title. - description: Detailed description. - tags: Comma-separated tags (e.g. "backend,urgent"). - priority: Priority level 1-5 (1=highest, default 2). - budget_tokens: Optional token budget limit. - budget_usd: Optional USD budget limit. - - Returns: - JSON with success, epic_id, title, status. - """ - from database import SessionLocal - from models.epic import Epic - - if priority < 1 or priority > 5: - return json.dumps({"success": False, "error": "Priority must be between 1 and 5"}) - - tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] - db = SessionLocal() - try: - try: - epic = Epic( - title=title, - description=description, - tags=tag_list, - priority=priority, - budget_tokens=budget_tokens, - budget_usd=budget_usd, - user_profile_id=user_profile_id, - created_by_node_id=tool_node_id, - ) - db.add(epic) - db.commit() - except Exception as e: - db.rollback() - logger.exception("Error creating epic") - return json.dumps({"success": False, "error": str(e)}) - - try: - db.refresh(epic) - except Exception: - logger.exception("Failed to refresh after commit") - - try: - from api.epic_helpers import serialize_epic - from ws.broadcast import broadcast - broadcast(f"epic:{epic.id}", "epic_created", serialize_epic(epic)) - except Exception: - logger.exception("Failed to broadcast epic_created") - - return json.dumps({"success": True, "epic_id": epic.id, "title": epic.title, "status": epic.status}) - finally: - db.close() - - @tool - def epic_status(epic_id: str) -> str: - """Get detailed status of an epic including task breakdown. - - Args: - epic_id: The epic ID (e.g. "ep-abc123"). - - Returns: - JSON with epic details and task list. - """ - from database import SessionLocal - from models.epic import Epic, Task - - db = SessionLocal() - try: - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if not epic: - return json.dumps({"success": False, "error": "Epic not found"}) - - tasks = db.query(Task).filter(Task.epic_id == epic_id).all() - task_list = [ - {"id": t.id, "title": t.title, "status": t.status} - for t in tasks - ] - return json.dumps({ - "success": True, - "epic_id": epic.id, - "title": epic.title, - "status": epic.status, - "priority": epic.priority, - "total_tasks": epic.total_tasks, - "completed_tasks": epic.completed_tasks, - "failed_tasks": epic.failed_tasks, - "spent_tokens": epic.spent_tokens, - "spent_usd": float(epic.spent_usd) if epic.spent_usd is not None else 0.0, - "tasks": task_list, - }) - except Exception as e: - logger.exception("Error getting epic status") - return json.dumps({"success": False, "error": str(e)}) - finally: - db.close() - - @tool - def update_epic( - epic_id: str, - status: str | None = None, - title: str | None = None, - description: str | None = None, - priority: int | None = None, - budget_tokens: int | None = None, - budget_usd: float | None = None, - result_summary: str | None = None, - ) -> str: - """Update an epic's fields. Only provided fields are changed. - - Args: - epic_id: The epic ID. - status: New status (planning, active, paused, completed, cancelled, failed). - title: New title. - description: New description. - priority: New priority 1-5. - budget_tokens: New token budget. - budget_usd: New USD budget. - result_summary: Summary of results when completing. - - Returns: - JSON with success, epic_id, status. - """ - from database import SessionLocal - from models.epic import Epic, Task - from api.epic_helpers import serialize_epic, sync_epic_progress - - db = SessionLocal() - try: - try: - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if not epic: - return json.dumps({"success": False, "error": "Epic not found"}) - - if priority is not None and (priority < 1 or priority > 5): - return json.dumps({"success": False, "error": "Priority must be between 1 and 5"}) - VALID_EPIC_STATUSES = {"planning", "active", "paused", "completed", "cancelled", "failed"} - if status is not None and status not in VALID_EPIC_STATUSES: - return json.dumps({"success": False, "error": f"Invalid status. Must be one of: {', '.join(sorted(VALID_EPIC_STATUSES))}"}) - - if title is not None: - epic.title = title - if description is not None: - epic.description = description - if priority is not None: - epic.priority = priority - if budget_tokens is not None: - epic.budget_tokens = budget_tokens - if budget_usd is not None: - epic.budget_usd = budget_usd - if result_summary is not None: - epic.result_summary = result_summary - if status is not None: - epic.status = status - if status == "cancelled": - tasks = ( - db.query(Task) - .filter( - Task.epic_id == epic.id, - Task.status.in_(["pending", "blocked", "running"]), - ) - .all() - ) - for task in tasks: - task.status = "cancelled" - - sync_epic_progress(epic, db) - db.commit() - except Exception as e: - db.rollback() - logger.exception("Error updating epic") - return json.dumps({"success": False, "error": str(e)}) - - try: - db.refresh(epic) - except Exception: - logger.exception("Failed to refresh after commit") - - try: - from ws.broadcast import broadcast - broadcast(f"epic:{epic.id}", "epic_updated", serialize_epic(epic)) - except Exception: - logger.exception("Failed to broadcast epic_updated") - - return json.dumps({"success": True, "epic_id": epic.id, "status": epic.status}) - finally: - db.close() - - @tool - def search_epics( - query: str = "", - tags: str = "", - status: str | None = None, - limit: int = 10, - ) -> str: - """Search epics by text, tags, or status. - - Args: - query: Search text (matches title and description, case-insensitive). - tags: Comma-separated tags to filter by (OR semantics). - status: Filter by status. - limit: Max results (default 10). - - Returns: - JSON with results list. - """ - from database import SessionLocal - from models.epic import Epic - from sqlalchemy import or_ - - tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] - if len(tag_list) > 20: - return json.dumps({"success": False, "error": "Too many tags specified. Maximum is 20."}) - limit = max(1, min(limit, 100)) - db = SessionLocal() - try: - q = db.query(Epic) - if status: - q = q.filter(Epic.status == status) - if tag_list: - q = q.filter(or_(*[Epic.tags.contains(tag) for tag in tag_list])) - if query: - pattern = f"%{query}%" - q = q.filter( - or_(Epic.title.ilike(pattern), Epic.description.ilike(pattern)) - ) - - epics = q.order_by(Epic.created_at.desc()).limit(limit).all() - - completed_count = sum(1 for e in epics if e.status == "completed") - failed_count = sum(1 for e in epics if e.status == "failed") - finished = completed_count + failed_count - success_rate = (completed_count / finished) if finished > 0 else None - costs = [float(e.spent_usd) for e in epics if e.spent_usd is not None] - avg_cost = (sum(costs) / len(costs)) if costs else 0.0 - - results = [ - { - "epic_id": e.id, - "title": e.title, - "status": e.status, - "tags": e.tags or [], - } - for e in epics - ] - return json.dumps({ - "success": True, - "results": results, - "success_rate": success_rate, - "avg_cost": avg_cost, - }) - except Exception as e: - logger.exception("Error searching epics") - return json.dumps({"success": False, "error": str(e)}) - finally: - db.close() - - return [create_epic, epic_status, update_epic, search_epics] diff --git a/platform/components/task_tools.py b/platform/components/task_tools.py deleted file mode 100644 index 8f674d84..00000000 --- a/platform/components/task_tools.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Task tools component — LangChain tools for managing tasks within epics.""" - -from __future__ import annotations - -import json -import logging - -from langchain_core.tools import tool - -from components import register - -logger = logging.getLogger(__name__) - - -@register("task_tools") -def task_tools_factory(node): - """Return a list of LangChain tools for task management.""" - from database import SessionLocal - from models.workflow import Workflow - - db = SessionLocal() - try: - workflow = db.query(Workflow).filter(Workflow.id == node.workflow_id).first() - if not workflow: - raise ValueError(f"task_tools: workflow {node.workflow_id} not found - cannot resolve owner") - user_profile_id = workflow.owner_id - if not user_profile_id: - raise ValueError(f"task_tools: workflow {node.workflow_id} has no owner_id - cannot resolve owner") - finally: - db.close() - - tool_node_id = node.node_id - - @tool - def create_task( - epic_id: str, - title: str, - description: str = "", - tags: str = "", - depends_on: str = "", - priority: int = 2, - estimated_tokens: int | None = None, - max_retries: int = 2, - ) -> str: - """Create a new task within an epic. - - Args: - epic_id: The parent epic ID. - title: Task title. - description: Detailed description. - tags: Comma-separated tags. - depends_on: Comma-separated task IDs this task depends on. - priority: Priority 1-5 (default 2). - estimated_tokens: Estimated token cost. - max_retries: Max retry attempts (default 2). - - Returns: - JSON with success, task_id, status. - """ - from database import SessionLocal - from models.epic import Epic, Task - from api.epic_helpers import serialize_task, sync_epic_progress - - tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else [] - dep_list = [d.strip() for d in depends_on.split(",") if d.strip()] if depends_on else [] - - db = SessionLocal() - try: - try: - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if not epic: - return json.dumps({"success": False, "error": "Epic not found"}) - - if priority < 1 or priority > 5: - return json.dumps({"success": False, "error": "Priority must be between 1 and 5"}) - - # Validate that dependencies exist within this epic - if dep_list: - existing_deps = ( - db.query(Task) - .filter(Task.id.in_(dep_list), Task.epic_id == epic_id) - .count() - ) - if existing_deps != len(dep_list): - return json.dumps({"success": False, "error": "One or more dependencies do not exist"}) - - # Auto-resolve blocked status if deps not all completed - initial_status = "pending" - if dep_list: - completed_deps = ( - db.query(Task) - .filter(Task.id.in_(dep_list), Task.status == "completed") - .count() - ) - if completed_deps < len(dep_list): - initial_status = "blocked" - - task = Task( - epic_id=epic_id, - title=title, - description=description, - tags=tag_list, - depends_on=dep_list, - priority=priority, - estimated_tokens=estimated_tokens, - max_retries=max_retries, - status=initial_status, - created_by_node_id=tool_node_id, - ) - db.add(task) - db.flush() - sync_epic_progress(epic, db) - db.commit() - except Exception as e: - db.rollback() - logger.exception("Error creating task") - return json.dumps({"success": False, "error": str(e)}) - - try: - db.refresh(task) - except Exception: - logger.exception("Failed to refresh after commit") - - try: - from ws.broadcast import broadcast - broadcast(f"epic:{task.epic_id}", "task_created", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task_created") - - return json.dumps({"success": True, "task_id": task.id, "status": task.status}) - finally: - db.close() - - @tool - def list_tasks( - epic_id: str, - status: str | None = None, - tags: str | None = None, - limit: int = 20, - ) -> str: - """List tasks within an epic, optionally filtered. - - Args: - epic_id: The epic ID. - status: Filter by status (pending, blocked, running, completed, failed, cancelled). - tags: Comma-separated tags to filter by. - limit: Max results (default 20). - - Returns: - JSON with tasks list and total count. - """ - from database import SessionLocal - from models.epic import Epic, Task - from sqlalchemy import or_ - - limit = max(1, min(limit, 100)) - db = SessionLocal() - try: - epic = db.query(Epic).filter(Epic.id == epic_id).first() - if not epic: - return json.dumps({"success": False, "error": "Epic not found"}) - - q = db.query(Task).filter(Task.epic_id == epic_id) - if status: - q = q.filter(Task.status == status) - if tags: - tag_list = [t.strip() for t in tags.split(",") if t.strip()] - if len(tag_list) > 20: - return json.dumps({"success": False, "error": "Too many tags specified. Maximum is 20."}) - if tag_list: - q = q.filter(or_(*[Task.tags.contains(tag) for tag in tag_list])) - - total = q.count() - tasks = q.order_by(Task.created_at.desc()).limit(limit).all() - task_list = [ - { - "id": t.id, - "title": t.title, - "status": t.status, - "priority": t.priority, - "depends_on": t.depends_on or [], - "execution_id": t.execution_id, - } - for t in tasks - ] - return json.dumps({"success": True, "tasks": task_list, "total": total}) - except Exception as e: - logger.exception("Error listing tasks") - return json.dumps({"success": False, "error": str(e)}) - finally: - db.close() - - @tool - def update_task( - task_id: str, - status: str | None = None, - title: str | None = None, - description: str | None = None, - priority: int | None = None, - result_summary: str | None = None, - error_message: str | None = None, - notes: str | None = None, - ) -> str: - """Update a task's fields. Only provided fields are changed. - - Args: - task_id: The task ID. - status: New status (pending, blocked, running, completed, failed, cancelled). - title: New title. - description: New description. - priority: New priority 1-5. - result_summary: Summary of results when completing. - error_message: Error message if failed. - notes: A note to append to the task's notes list. - - Returns: - JSON with success, task_id, status. - """ - from database import SessionLocal - from models.epic import Epic, Task - from api.epic_helpers import resolve_blocked_tasks, serialize_task, sync_epic_progress - - db = SessionLocal() - try: - try: - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - return json.dumps({"success": False, "error": "Task not found"}) - - if title is not None: - task.title = title - if description is not None: - task.description = description - if priority is not None: - if priority < 1 or priority > 5: - return json.dumps({"success": False, "error": "Priority must be between 1 and 5"}) - task.priority = priority - if result_summary is not None: - task.result_summary = result_summary - if error_message is not None: - task.error_message = error_message - VALID_TASK_STATUSES = {"pending", "blocked", "running", "completed", "failed", "cancelled"} - if status is not None: - if status not in VALID_TASK_STATUSES: - return json.dumps({"success": False, "error": f"Invalid status. Must be one of: {', '.join(sorted(VALID_TASK_STATUSES))}"}) - task.status = status - if notes is not None: - existing_notes = task.notes or [] - task.notes = existing_notes + [notes] - - db.flush() - - # Auto-unblock dependent tasks when this task is completed - if status == "completed": - resolve_blocked_tasks(task_id, db) - - epic = db.query(Epic).filter(Epic.id == task.epic_id).first() - if epic: - sync_epic_progress(epic, db) - - db.commit() - except Exception as e: - db.rollback() - logger.exception("Error updating task") - return json.dumps({"success": False, "error": str(e)}) - - try: - db.refresh(task) - except Exception: - logger.exception("Failed to refresh after commit") - - try: - from ws.broadcast import broadcast - broadcast(f"epic:{task.epic_id}", "task_updated", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task_updated") - - return json.dumps({"success": True, "task_id": task.id, "status": task.status}) - finally: - db.close() - - @tool - def cancel_task(task_id: str, reason: str = "") -> str: - """Cancel a task and optionally its linked execution. - - Args: - task_id: The task ID to cancel. - reason: Optional reason for cancellation. - - Returns: - JSON with success, task_id, execution_cancelled. - """ - from database import SessionLocal - from models.epic import Epic, Task - from models.execution import WorkflowExecution - from api.epic_helpers import serialize_task, sync_epic_progress - - db = SessionLocal() - try: - try: - task = db.query(Task).filter(Task.id == task_id).first() - if not task: - return json.dumps({"success": False, "error": "Task not found"}) - - task.status = "cancelled" - if reason: - existing_notes = task.notes or [] - task.notes = existing_notes + [f"Cancelled: {reason}"] - - execution_cancelled = False - if task.execution_id: - execution = ( - db.query(WorkflowExecution) - .filter(WorkflowExecution.execution_id == task.execution_id) - .first() - ) - if execution and execution.status in ("pending", "running"): - execution.status = "cancelled" - execution_cancelled = True - - db.flush() - epic = db.query(Epic).filter(Epic.id == task.epic_id).first() - if epic: - sync_epic_progress(epic, db) - - db.commit() - except Exception as e: - db.rollback() - logger.exception("Error cancelling task") - return json.dumps({"success": False, "error": str(e)}) - - try: - db.refresh(task) - except Exception: - logger.exception("Failed to refresh after commit") - - try: - from ws.broadcast import broadcast - broadcast(f"epic:{task.epic_id}", "task_updated", serialize_task(task)) - except Exception: - logger.exception("Failed to broadcast task_updated") - - return json.dumps({"success": True, "task_id": task.id, "execution_cancelled": execution_cancelled}) - finally: - db.close() - - return [create_task, list_tasks, update_task, cancel_task] diff --git a/platform/frontend/src/api/epics.ts b/platform/frontend/src/api/epics.ts deleted file mode 100644 index 744483fc..00000000 --- a/platform/frontend/src/api/epics.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { apiFetch } from "./client" -import type { Epic, EpicCreate, EpicUpdate, Task, PaginatedResponse } from "@/types/models" - -export function useEpics(filters?: { status?: string; tags?: string; limit?: number; offset?: number }) { - const params = new URLSearchParams() - if (filters?.status) params.set("status", filters.status) - if (filters?.tags) params.set("tags", filters.tags) - if (filters?.limit) params.set("limit", filters.limit.toString()) - if (filters?.offset) params.set("offset", filters.offset.toString()) - const qs = params.toString() - return useQuery({ queryKey: ["epics", filters], queryFn: () => apiFetch>(`/epics/${qs ? `?${qs}` : ""}`) }) -} - -export function useEpic(id: string) { - return useQuery({ queryKey: ["epics", id], queryFn: () => apiFetch(`/epics/${id}/`), enabled: !!id }) -} - -export function useEpicTasks(epicId: string, filters?: { limit?: number; offset?: number }) { - const params = new URLSearchParams() - if (filters?.limit) params.set("limit", filters.limit.toString()) - if (filters?.offset) params.set("offset", filters.offset.toString()) - const qs = params.toString() - return useQuery({ queryKey: ["epics", epicId, "tasks", filters], queryFn: () => apiFetch>(`/epics/${epicId}/tasks/${qs ? `?${qs}` : ""}`), enabled: !!epicId }) -} - -export function useCreateEpic() { - const qc = useQueryClient() - return useMutation({ mutationFn: (data: EpicCreate) => apiFetch("/epics/", { method: "POST", body: JSON.stringify(data) }), onSuccess: () => qc.invalidateQueries({ queryKey: ["epics"] }) }) -} - -export function useUpdateEpic() { - const qc = useQueryClient() - return useMutation({ mutationFn: ({ id, ...data }: EpicUpdate & { id: string }) => apiFetch(`/epics/${id}/`, { method: "PATCH", body: JSON.stringify(data) }), onSuccess: () => qc.invalidateQueries({ queryKey: ["epics"] }) }) -} - -export function useDeleteEpic() { - const qc = useQueryClient() - return useMutation({ mutationFn: (id: string) => apiFetch(`/epics/${id}/`, { method: "DELETE" }), onSuccess: () => qc.invalidateQueries({ queryKey: ["epics"] }) }) -} - -export function useBatchDeleteEpics() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (epic_ids: string[]) => apiFetch("/epics/batch-delete/", { method: "POST", body: JSON.stringify({ epic_ids }) }), - onSuccess: () => qc.invalidateQueries({ queryKey: ["epics"] }), - }) -} diff --git a/platform/frontend/src/api/tasks.ts b/platform/frontend/src/api/tasks.ts deleted file mode 100644 index f456c633..00000000 --- a/platform/frontend/src/api/tasks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { apiFetch } from "./client" -import type { Task, TaskCreate, TaskUpdate, PaginatedResponse } from "@/types/models" - -export function useTasks(filters?: { epic_id?: string; status?: string; tags?: string; limit?: number; offset?: number }) { - const params = new URLSearchParams() - if (filters?.epic_id) params.set("epic_id", filters.epic_id) - if (filters?.status) params.set("status", filters.status) - if (filters?.tags) params.set("tags", filters.tags) - if (filters?.limit) params.set("limit", filters.limit.toString()) - if (filters?.offset) params.set("offset", filters.offset.toString()) - const qs = params.toString() - return useQuery({ queryKey: ["tasks", filters], queryFn: () => apiFetch>(`/tasks/${qs ? `?${qs}` : ""}`) }) -} - -export function useTask(id: string) { - return useQuery({ queryKey: ["tasks", id], queryFn: () => apiFetch(`/tasks/${id}/`), enabled: !!id }) -} - -export function useCreateTask() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (data: TaskCreate) => apiFetch("/tasks/", { method: "POST", body: JSON.stringify(data) }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["tasks"] }); qc.invalidateQueries({ queryKey: ["epics"] }) }, - }) -} - -export function useUpdateTask() { - const qc = useQueryClient() - return useMutation({ - mutationFn: ({ id, ...data }: TaskUpdate & { id: string }) => apiFetch(`/tasks/${id}/`, { method: "PATCH", body: JSON.stringify(data) }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["tasks"] }); qc.invalidateQueries({ queryKey: ["epics"] }) }, - }) -} - -export function useDeleteTask() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (id: string) => apiFetch(`/tasks/${id}/`, { method: "DELETE" }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["tasks"] }); qc.invalidateQueries({ queryKey: ["epics"] }) }, - }) -} - -export function useBatchDeleteTasks() { - const qc = useQueryClient() - return useMutation({ - mutationFn: (task_ids: string[]) => apiFetch("/tasks/batch-delete/", { method: "POST", body: JSON.stringify({ task_ids }) }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["tasks"] }); qc.invalidateQueries({ queryKey: ["epics"] }) }, - }) -} diff --git a/platform/frontend/src/features/epics/EpicDetailPage.tsx b/platform/frontend/src/features/epics/EpicDetailPage.tsx deleted file mode 100644 index 957a7258..00000000 --- a/platform/frontend/src/features/epics/EpicDetailPage.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { useState } from "react" -import { useParams } from "react-router-dom" -import { useEpic, useEpicTasks } from "@/api/epics" -import { useBatchDeleteTasks } from "@/api/tasks" -import { useSubscription } from "@/hooks/useWebSocket" -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Checkbox } from "@/components/ui/checkbox" -import { Badge } from "@/components/ui/badge" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog" -import { PaginationControls } from "@/components/ui/pagination-controls" -import { ChevronDown, ChevronRight, Trash2 } from "lucide-react" -import { format } from "date-fns" -import type { Task } from "@/types/models" - -const PAGE_SIZE = 50 - -const EPIC_STATUS_COLORS: Record = { - planning: "bg-yellow-100 text-yellow-800", - active: "bg-blue-100 text-blue-800", - paused: "bg-orange-100 text-orange-800", - completed: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", - cancelled: "bg-gray-100 text-gray-800", -} - -const TASK_STATUS_COLORS: Record = { - pending: "bg-yellow-100 text-yellow-800", - blocked: "bg-orange-100 text-orange-800", - running: "bg-blue-100 text-blue-800", - completed: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", - cancelled: "bg-gray-100 text-gray-800", -} - -export default function EpicDetailPage() { - const { epicId = "" } = useParams<{ epicId: string }>() - const { data: epic, isLoading } = useEpic(epicId) - const [taskPage, setTaskPage] = useState(1) - const { data: tasksData } = useEpicTasks(epicId, { limit: PAGE_SIZE, offset: (taskPage - 1) * PAGE_SIZE }) - const tasks = tasksData?.items - const taskTotal = tasksData?.total ?? 0 - const batchDeleteTasks = useBatchDeleteTasks() - useSubscription(epicId ? `epic:${epicId}` : null) - - const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set()) - const [confirmBatchDelete, setConfirmBatchDelete] = useState(false) - const [expandedTasks, setExpandedTasks] = useState>(new Set()) - - function toggleTaskSelect(id: string) { - setSelectedTaskIds((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } - - function toggleAllTasks() { - if (!tasks) return - if (selectedTaskIds.size === tasks.length) { - setSelectedTaskIds(new Set()) - } else { - setSelectedTaskIds(new Set(tasks.map((t) => t.id))) - } - } - - function handleBatchDeleteTasks() { - batchDeleteTasks.mutate([...selectedTaskIds], { - onSuccess: () => { setSelectedTaskIds(new Set()); setConfirmBatchDelete(false) }, - }) - } - - function toggleExpand(id: string) { - setExpandedTasks((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } - - if (!epicId) { - return
Epic ID is required
- } - - if (isLoading || !epic) { - return
Loading epic...
- } - - const budgetLabel = epic.budget_usd != null - ? `$${epic.budget_usd.toFixed(2)}` - : epic.budget_tokens != null - ? `${epic.budget_tokens.toLocaleString()} tokens` - : "No budget" - const costLabel = epic.spent_usd != null - ? `$${epic.spent_usd.toFixed(4)}` - : epic.spent_tokens != null - ? `${epic.spent_tokens.toLocaleString()} tokens` - : "No cost" - - return ( -
-
-

{epic.title}

-

{epic.id}

-
- -
- - Status - {epic.status} - - - Progress - {epic.completed_tasks}/{epic.total_tasks} tasks - - - Budget - {budgetLabel} - - - Cost - {costLabel} - -
- - {epic.description && ( - - Description -

{epic.description}

-
- )} - - {epic.result_summary && (epic.status === "completed" || epic.status === "failed") && ( - - {epic.status === "failed" ? "Error" : "Result Summary"} -
{epic.result_summary}
-
- )} - - - -
- Tasks - {selectedTaskIds.size > 0 && ( - - )} -
-
- - - - - - - - - Title - Status - Workflow - Duration - Created - - - - {tasks?.map((task) => ( - toggleExpand(task.id)} - onToggleSelect={() => toggleTaskSelect(task.id)} - /> - ))} - {tasks?.length === 0 && ( - No tasks found. - )} - -
- { setTaskPage(p); setSelectedTaskIds(new Set()) }} /> -
-
- - - - Delete {selectedTaskIds.size} Tasks -

Are you sure you want to delete {selectedTaskIds.size} tasks? This cannot be undone.

- - - - -
-
-
- ) -} - -function TaskRow({ task, isExpanded, isSelected, onToggleExpand, onToggleSelect }: { - task: Task; isExpanded: boolean; isSelected: boolean; onToggleExpand: () => void; onToggleSelect: () => void -}) { - const hasDetails = !!(task.description || task.result_summary || task.error_message || task.depends_on.length > 0) - const durationLabel = task.duration_ms > 0 ? `${(task.duration_ms / 1000).toFixed(1)}s` : "-" - - return ( - <> - hasDetails && onToggleExpand()}> - - {hasDetails && (isExpanded ? : )} - - e.stopPropagation()}> - - - {task.title} - - {task.status} - - {task.workflow_slug ?? "-"} - {durationLabel} - {task.created_at ? format(new Date(task.created_at), "MMM d, HH:mm") : "-"} - - {isExpanded && hasDetails && ( - - -
- {task.description && ( -
Description: {task.description}
- )} - {task.depends_on.length > 0 && ( -
Dependencies: {task.depends_on.join(", ")}
- )} - {task.result_summary && ( -
Result: {task.result_summary}
- )} - {task.error_message && ( -
Error: {task.error_message}
- )} -
-
-
- )} - - ) -} diff --git a/platform/frontend/src/features/epics/EpicsPage.tsx b/platform/frontend/src/features/epics/EpicsPage.tsx deleted file mode 100644 index c88def31..00000000 --- a/platform/frontend/src/features/epics/EpicsPage.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useState } from "react" -import { useNavigate } from "react-router-dom" -import { useEpics, useBatchDeleteEpics } from "@/api/epics" -import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" -import { Checkbox } from "@/components/ui/checkbox" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog" -import { Badge } from "@/components/ui/badge" -import { PaginationControls } from "@/components/ui/pagination-controls" -import { Trash2 } from "lucide-react" -import { format } from "date-fns" -import type { EpicStatus } from "@/types/models" - -const PAGE_SIZE = 50 - -const STATUS_COLORS: Record = { - planning: "bg-yellow-100 text-yellow-800", - active: "bg-blue-100 text-blue-800", - paused: "bg-orange-100 text-orange-800", - completed: "bg-green-100 text-green-800", - failed: "bg-red-100 text-red-800", - cancelled: "bg-gray-100 text-gray-800", -} - -const STATUSES: EpicStatus[] = ["planning", "active", "paused", "completed", "failed", "cancelled"] - -export default function EpicsPage() { - const navigate = useNavigate() - const [statusFilter, setStatusFilter] = useState("all") - const [page, setPage] = useState(1) - const { data, isLoading } = useEpics({ - status: statusFilter === "all" ? undefined : statusFilter, - limit: PAGE_SIZE, - offset: (page - 1) * PAGE_SIZE, - }) - const epics = data?.items - const total = data?.total ?? 0 - const batchDelete = useBatchDeleteEpics() - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [confirmBatchDelete, setConfirmBatchDelete] = useState(false) - - function toggleSelect(id: string) { - setSelectedIds((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - return next - }) - } - - function toggleAll() { - if (!epics) return - if (selectedIds.size === epics.length) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(epics.map((e) => e.id))) - } - } - - function handleBatchDelete() { - batchDelete.mutate([...selectedIds], { - onSuccess: () => { setSelectedIds(new Set()); setConfirmBatchDelete(false) }, - }) - } - - if (isLoading) { - return
{[...Array(5)].map((_, i) =>
)}
- } - - return ( -
-
-

Epics

-
- {selectedIds.size > 0 && ( - - )} - -
-
- - - - - - - - selectedIds.has(e.id)) : false} onCheckedChange={toggleAll} /> - - Title - Status - Progress - Priority - Created - - - - {epics?.map((epic) => ( - navigate(`/epics/${epic.id}`)}> - e.stopPropagation()}> - toggleSelect(epic.id)} /> - - {epic.title} - - {epic.status} - - {epic.completed_tasks}/{epic.total_tasks} tasks - {epic.priority} - {epic.created_at && !isNaN(new Date(epic.created_at).getTime()) ? format(new Date(epic.created_at), "MMM d, HH:mm") : "-"} - - ))} - {epics?.length === 0 && ( - No epics found. - )} - -
- { setPage(p); setSelectedIds(new Set()) }} /> -
-
- - - - Delete {selectedIds.size} Epics -

Are you sure you want to delete {selectedIds.size} epics? This cannot be undone.

- - - - -
-
-
- ) -} diff --git a/platform/models/epic.py b/platform/models/epic.py deleted file mode 100644 index d8f4a41c..00000000 --- a/platform/models/epic.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Epic and Task models for multi-agent delegation.""" - -from __future__ import annotations - -from datetime import datetime -from uuid import uuid4 - -from sqlalchemy import DateTime, ForeignKey, Integer, JSON, Numeric, String, Text, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from database import Base - - -class Epic(Base): - __tablename__ = "epics" - - id: Mapped[str] = mapped_column( - String(20), primary_key=True, default=lambda: f"ep-{uuid4().hex[:12]}" - ) - title: Mapped[str] = mapped_column(String(255)) - description: Mapped[str] = mapped_column(Text, default="") - tags: Mapped[list | None] = mapped_column(JSON, default=list) - - # Ownership - created_by_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - workflow_id: Mapped[int | None] = mapped_column( - ForeignKey("workflows.id", ondelete="SET NULL"), nullable=True, index=True - ) - user_profile_id: Mapped[int | None] = mapped_column( - ForeignKey("user_profiles.id", ondelete="SET NULL"), nullable=True - ) - - # Lifecycle - status: Mapped[str] = mapped_column(String(20), default="planning", index=True) - priority: Mapped[int] = mapped_column(Integer, default=2) - - # Budget - budget_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) - budget_usd: Mapped[float | None] = mapped_column(Numeric(12, 6), nullable=True) - spent_tokens: Mapped[int] = mapped_column(Integer, default=0) - spent_usd: Mapped[float] = mapped_column(Numeric(12, 6), default=0.0) - agent_overhead_tokens: Mapped[int] = mapped_column(Integer, default=0) - agent_overhead_usd: Mapped[float] = mapped_column(Numeric(12, 6), default=0.0) - - # Progress - total_tasks: Mapped[int] = mapped_column(Integer, default=0) - completed_tasks: Mapped[int] = mapped_column(Integer, default=0) - failed_tasks: Mapped[int] = mapped_column(Integer, default=0) - - # Timestamps - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - - # Outcome - result_summary: Mapped[str | None] = mapped_column(Text, nullable=True) - - # Relationships - tasks: Mapped[list["Task"]] = relationship( - "Task", back_populates="epic", cascade="all, delete-orphan" - ) - - def __repr__(self): - return f"" - - -class Task(Base): - __tablename__ = "tasks" - - id: Mapped[str] = mapped_column( - String(20), primary_key=True, default=lambda: f"tk-{uuid4().hex[:12]}" - ) - epic_id: Mapped[str] = mapped_column( - ForeignKey("epics.id", ondelete="CASCADE"), index=True - ) - title: Mapped[str] = mapped_column(String(255)) - description: Mapped[str] = mapped_column(Text, default="") - tags: Mapped[list | None] = mapped_column(JSON, default=list) - - # Ownership - created_by_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) - - # Lifecycle - status: Mapped[str] = mapped_column(String(20), default="pending", index=True) - priority: Mapped[int] = mapped_column(Integer, default=2) - - # Workflow linkage - workflow_id: Mapped[int | None] = mapped_column( - ForeignKey("workflows.id", ondelete="SET NULL"), nullable=True, index=True - ) - workflow_slug: Mapped[str | None] = mapped_column(String(255), nullable=True) - execution_id: Mapped[str | None] = mapped_column(String(36), nullable=True) - workflow_source: Mapped[str] = mapped_column(String(20), default="inline") - - # Dependencies - depends_on: Mapped[list | None] = mapped_column(JSON, default=list) - - # Requirements - requirements: Mapped[dict | None] = mapped_column(JSON, default=dict) - - # Cost - estimated_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) - actual_tokens: Mapped[int] = mapped_column(Integer, default=0) - actual_usd: Mapped[float] = mapped_column(Numeric(12, 6), default=0.0) - llm_calls: Mapped[int] = mapped_column(Integer, default=0) - tool_invocations: Mapped[int] = mapped_column(Integer, default=0) - duration_ms: Mapped[int] = mapped_column(Integer, default=0) - - # Timestamps - created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime, server_default=func.now(), onupdate=func.now() - ) - started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - - # Outcome - result_summary: Mapped[str | None] = mapped_column(Text, nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - retry_count: Mapped[int] = mapped_column(Integer, default=0) - max_retries: Mapped[int] = mapped_column(Integer, default=2) - - # Notes - notes: Mapped[list | None] = mapped_column(JSON, default=list) - - # Relationships - epic: Mapped[Epic] = relationship("Epic", back_populates="tasks") - - def __repr__(self): - return f"" diff --git a/platform/schemas/epic.py b/platform/schemas/epic.py deleted file mode 100644 index 1e6059b6..00000000 --- a/platform/schemas/epic.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Epic and Task Pydantic schemas.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import BaseModel - - -# ── Epic schemas ─────────────────────────────────────────────────────────────── - -class EpicCreate(BaseModel): - title: str - description: str = "" - tags: list[str] = [] - priority: int = 2 - budget_tokens: int | None = None - budget_usd: float | None = None - workflow_id: int | None = None - - -class EpicUpdate(BaseModel): - title: str | None = None - description: str | None = None - tags: list[str] | None = None - status: Literal[ - "planning", "active", "paused", "completed", "failed", "cancelled" - ] | None = None - priority: int | None = None - budget_tokens: int | None = None - budget_usd: float | None = None - result_summary: str | None = None - - -class EpicOut(BaseModel): - id: str - title: str - description: str = "" - tags: list[str] = [] - created_by_node_id: str | None = None - workflow_id: int | None = None - user_profile_id: int | None = None - status: str = "planning" - priority: int = 2 - budget_tokens: int | None = None - budget_usd: float | None = None - spent_tokens: int = 0 - spent_usd: float = 0.0 - agent_overhead_tokens: int = 0 - agent_overhead_usd: float = 0.0 - total_tasks: int = 0 - completed_tasks: int = 0 - failed_tasks: int = 0 - created_at: datetime | None = None - updated_at: datetime | None = None - completed_at: datetime | None = None - result_summary: str | None = None - - model_config = {"from_attributes": True} - - -# ── Task schemas ─────────────────────────────────────────────────────────────── - -class TaskCreate(BaseModel): - epic_id: str - title: str - description: str = "" - tags: list[str] = [] - depends_on: list[str] = [] - priority: int | None = None - workflow_slug: str | None = None - estimated_tokens: int | None = None - max_retries: int = 2 - requirements: dict | None = None - - -class TaskUpdate(BaseModel): - title: str | None = None - description: str | None = None - tags: list[str] | None = None - status: Literal[ - "pending", "blocked", "running", "completed", "failed", "cancelled" - ] | None = None - priority: int | None = None - workflow_slug: str | None = None - execution_id: str | None = None - result_summary: str | None = None - error_message: str | None = None - notes: list | None = None - - -class TaskOut(BaseModel): - id: str - epic_id: str - title: str - description: str = "" - tags: list[str] = [] - created_by_node_id: str | None = None - status: str = "pending" - priority: int = 2 - workflow_id: int | None = None - workflow_slug: str | None = None - execution_id: str | None = None - workflow_source: str = "inline" - depends_on: list[str] = [] - requirements: dict | None = None - estimated_tokens: int | None = None - actual_tokens: int = 0 - actual_usd: float = 0.0 - llm_calls: int = 0 - tool_invocations: int = 0 - duration_ms: int = 0 - created_at: datetime | None = None - updated_at: datetime | None = None - started_at: datetime | None = None - completed_at: datetime | None = None - result_summary: str | None = None - error_message: str | None = None - retry_count: int = 0 - max_retries: int = 2 - notes: list = [] - - model_config = {"from_attributes": True} - - -# ── Batch delete schemas ────────────────────────────────────────────────────── - -class BatchDeleteEpicsIn(BaseModel): - epic_ids: list[str] - - -class BatchDeleteTasksIn(BaseModel): - task_ids: list[str] diff --git a/platform/tests/test_epic_task_tools.py b/platform/tests/test_epic_task_tools.py deleted file mode 100644 index b44d8df3..00000000 --- a/platform/tests/test_epic_task_tools.py +++ /dev/null @@ -1,1389 +0,0 @@ -"""Tests for epic_tools and task_tools component factories.""" - -from __future__ import annotations - -import json -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - -from models.epic import Epic, Task - - -def _make_node(component_type, workflow_id, node_id="tool_node_1"): - config = SimpleNamespace( - component_type=component_type, - extra_config={}, - system_prompt="", - ) - return SimpleNamespace( - node_id=node_id, - workflow_id=workflow_id, - component_type=component_type, - component_config=config, - ) - - -@pytest.fixture -def mock_session(db): - """Patch SessionLocal to return the test db session with close() as no-op.""" - original_close = db.close - db.close = lambda: None - with patch("database.SessionLocal", return_value=db): - yield db - db.close = original_close - - -# --------------------------------------------------------------------------- -# Epic tools -# --------------------------------------------------------------------------- - -class TestEpicToolsFactory: - def test_returns_list_of_four_tools(self, mock_session, workflow): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - assert isinstance(tools, list) - assert len(tools) == 4 - names = {t.name for t in tools} - assert names == {"create_epic", "epic_status", "update_epic", "search_epics"} - - def test_factory_raises_on_missing_workflow(self, mock_session): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow_id=99999) - with pytest.raises(ValueError, match="workflow 99999 not found"): - epic_tools_factory(node) - - def test_factory_raises_on_null_owner_id(self, mock_session, workflow): - from components.epic_tools import epic_tools_factory - - fake_workflow = SimpleNamespace(owner_id=None) - node = _make_node("epic_tools", workflow.id) - with patch("database.SessionLocal") as mock_sl: - mock_db = mock_sl.return_value - mock_db.query.return_value.filter.return_value.first.return_value = fake_workflow - mock_db.close = lambda: None - with pytest.raises(ValueError, match="has no owner_id"): - epic_tools_factory(node) - - -class TestCreateEpic: - def test_create_epic_priority_out_of_range(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_epic") - # priority too low - result = json.loads(create_tool.invoke({ - "title": "Bad Priority", - "priority": 0, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - # priority too high - result = json.loads(create_tool.invoke({ - "title": "Bad Priority", - "priority": 6, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - def test_create_epic_success(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_epic") - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({ - "title": "My Epic", - "description": "Test desc", - "tags": "backend,urgent", - "priority": 1, - })) - - assert result["success"] is True - assert result["title"] == "My Epic" - assert result["status"] == "planning" - assert result["epic_id"].startswith("ep-") - - # Verify in DB - epic = mock_session.query(Epic).filter(Epic.id == result["epic_id"]).first() - assert epic is not None - assert epic.tags == ["backend", "urgent"] - assert epic.user_profile_id == user_profile.id - assert epic.created_by_node_id == "tool_node_1" - - -class TestEpicStatus: - def test_epic_status_success(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Status Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - t1 = Task(epic_id=epic.id, title="Task 1", status="completed") - t2 = Task(epic_id=epic.id, title="Task 2", status="pending") - mock_session.add_all([t1, t2]) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - status_tool = next(t for t in tools if t.name == "epic_status") - result = json.loads(status_tool.invoke({"epic_id": epic.id})) - - assert result["success"] is True - assert result["epic_id"] == epic.id - assert len(result["tasks"]) == 2 - - def test_epic_status_not_found(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - status_tool = next(t for t in tools if t.name == "epic_status") - result = json.loads(status_tool.invoke({"epic_id": "ep-nonexistent"})) - - assert result["success"] is False - assert "not found" in result["error"] - - -class TestUpdateEpic: - def test_update_epic_fields(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Original", user_profile_id=user_profile.id, priority=2) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - with patch("ws.broadcast.broadcast"): - result = json.loads(update_tool.invoke({ - "epic_id": epic.id, - "title": "Updated", - "priority": 1, - "status": "active", - })) - - assert result["success"] is True - assert result["status"] == "active" - mock_session.refresh(epic) - assert epic.title == "Updated" - assert epic.priority == 1 - - def test_cancel_epic_cascades_tasks(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Cancel Test", user_profile_id=user_profile.id, status="active") - mock_session.add(epic) - mock_session.flush() - t1 = Task(epic_id=epic.id, title="Pending", status="pending") - t2 = Task(epic_id=epic.id, title="Running", status="running") - t3 = Task(epic_id=epic.id, title="Completed", status="completed") - mock_session.add_all([t1, t2, t3]) - mock_session.commit() - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - with patch("ws.broadcast.broadcast"): - result = json.loads(update_tool.invoke({ - "epic_id": epic.id, - "status": "cancelled", - })) - - assert result["success"] is True - mock_session.refresh(t1) - mock_session.refresh(t2) - mock_session.refresh(t3) - assert t1.status == "cancelled" - assert t2.status == "cancelled" - assert t3.status == "completed" # completed tasks stay completed - - -class TestUpdateEpicAllFields: - def test_update_all_optional_fields(self, mock_session, workflow, user_profile): - """Cover description, budget_tokens, budget_usd, result_summary branches.""" - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Full Update", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - with patch("ws.broadcast.broadcast"): - result = json.loads(update_tool.invoke({ - "epic_id": epic.id, - "description": "New desc", - "budget_tokens": 5000, - "budget_usd": 1.50, - "result_summary": "Done well", - })) - - assert result["success"] is True - mock_session.refresh(epic) - assert epic.description == "New desc" - assert epic.budget_tokens == 5000 - assert float(epic.budget_usd) == 1.50 - assert epic.result_summary == "Done well" - - def test_update_epic_priority_out_of_range(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Priority Update", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - result = json.loads(update_tool.invoke({ - "epic_id": epic.id, - "priority": 0, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - def test_update_epic_invalid_status(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Status Update", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - result = json.loads(update_tool.invoke({ - "epic_id": epic.id, - "status": "bogus", - })) - assert result["success"] is False - assert "Invalid status" in result["error"] - - def test_update_epic_not_found(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - result = json.loads(update_tool.invoke({ - "epic_id": "ep-nonexistent", - "title": "X", - })) - - assert result["success"] is False - assert "not found" in result["error"] - - -class TestSearchEpics: - def test_search_by_text(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - e1 = Epic(title="Deploy backend", user_profile_id=user_profile.id) - e2 = Epic(title="Fix frontend bug", user_profile_id=user_profile.id) - mock_session.add_all([e1, e2]) - mock_session.commit() - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - result = json.loads(search_tool.invoke({"query": "backend"})) - - assert result["success"] is True - assert len(result["results"]) == 1 - assert result["results"][0]["title"] == "Deploy backend" - - def test_search_includes_zero_cost_in_avg(self, mock_session, workflow, user_profile): - """Verify epics with $0.00 spent_usd are included in avg_cost.""" - from components.epic_tools import epic_tools_factory - - e1 = Epic(title="Free", user_profile_id=user_profile.id, spent_usd=0.0) - e2 = Epic(title="Paid", user_profile_id=user_profile.id, spent_usd=2.0) - mock_session.add_all([e1, e2]) - mock_session.commit() - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - result = json.loads(search_tool.invoke({})) - - assert result["success"] is True - # avg_cost should be (0.0 + 2.0) / 2 = 1.0, not 2.0/1 = 2.0 - assert result["avg_cost"] == 1.0 - - def test_search_clamps_limit(self, mock_session, workflow, user_profile): - """Verify limit is clamped to [1, 100].""" - from components.epic_tools import epic_tools_factory - - mock_session.add(Epic(title="One", user_profile_id=user_profile.id)) - mock_session.commit() - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - # Huge limit should still work (clamped to 100) - result = json.loads(search_tool.invoke({"limit": 999999})) - assert result["success"] is True - - def test_search_by_status_and_tags(self, mock_session, workflow, user_profile): - """Cover status filter and tag filter branches in search_epics.""" - from components.epic_tools import epic_tools_factory - - e1 = Epic(title="Active One", user_profile_id=user_profile.id, status="active", tags=["deploy"]) - e2 = Epic(title="Active Two", user_profile_id=user_profile.id, status="active", tags=["test"]) - e3 = Epic(title="Done", user_profile_id=user_profile.id, status="completed", tags=["deploy"]) - mock_session.add_all([e1, e2, e3]) - mock_session.commit() - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - result = json.loads(search_tool.invoke({"status": "active", "tags": "deploy"})) - - assert result["success"] is True - assert len(result["results"]) == 1 - assert result["results"][0]["title"] == "Active One" - - def test_search_too_many_tags(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - many_tags = ",".join(f"tag{i}" for i in range(21)) - result = json.loads(search_tool.invoke({"tags": many_tags})) - - assert result["success"] is False - assert "Maximum is 20" in result["error"] - - -# --------------------------------------------------------------------------- -# Task tools -# --------------------------------------------------------------------------- - -class TestTaskToolsFactory: - def test_returns_list_of_four_tools(self, mock_session, workflow): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - assert isinstance(tools, list) - assert len(tools) == 4 - names = {t.name for t in tools} - assert names == {"create_task", "list_tasks", "update_task", "cancel_task"} - - def test_factory_raises_on_missing_workflow(self, mock_session): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow_id=99999) - with pytest.raises(ValueError, match="workflow 99999 not found"): - task_tools_factory(node) - - def test_factory_raises_on_null_owner_id(self, mock_session, workflow): - from components.task_tools import task_tools_factory - - fake_workflow = SimpleNamespace(owner_id=None) - node = _make_node("task_tools", workflow.id) - with patch("database.SessionLocal") as mock_sl: - mock_db = mock_sl.return_value - mock_db.query.return_value.filter.return_value.first.return_value = fake_workflow - mock_db.close = lambda: None - with pytest.raises(ValueError, match="has no owner_id"): - task_tools_factory(node) - - -class TestCreateTask: - def test_create_task_pending(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "My Task", - "tags": "api,test", - })) - - assert result["success"] is True - assert result["status"] == "pending" - task = mock_session.query(Task).filter(Task.id == result["task_id"]).first() - assert task is not None - assert task.tags == ["api", "test"] - assert task.created_by_node_id == "tool_node_1" - - def test_create_task_blocked_by_deps(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Dep Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - dep_task = Task(epic_id=epic.id, title="Dependency", status="pending") - mock_session.add(dep_task) - mock_session.commit() - mock_session.refresh(dep_task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "Blocked Task", - "depends_on": dep_task.id, - })) - - assert result["success"] is True - assert result["status"] == "blocked" - - def test_create_task_priority_out_of_range(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Priority Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - # priority too low - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "Bad Priority", - "priority": 0, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - # priority too high - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "Bad Priority", - "priority": 6, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - def test_create_task_nonexistent_dependency(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Dep Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "Bad Deps", - "depends_on": "tk-nonexistent", - })) - - assert result["success"] is False - assert "dependencies do not exist" in result["error"] - - def test_create_task_epic_not_found(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - result = json.loads(create_tool.invoke({ - "epic_id": "ep-nonexistent", - "title": "Orphan Task", - })) - - assert result["success"] is False - assert "not found" in result["error"] - - -class TestListTasks: - def test_list_tasks_success(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="List Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - mock_session.add_all([ - Task(epic_id=epic.id, title="Task A", status="pending"), - Task(epic_id=epic.id, title="Task B", status="completed"), - ]) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - result = json.loads(list_tool.invoke({"epic_id": epic.id})) - - assert result["success"] is True - assert result["total"] == 2 - assert len(result["tasks"]) == 2 - - def test_list_tasks_filter_status(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Filter Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - mock_session.add_all([ - Task(epic_id=epic.id, title="Pending", status="pending"), - Task(epic_id=epic.id, title="Done", status="completed"), - ]) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - result = json.loads(list_tool.invoke({"epic_id": epic.id, "status": "pending"})) - - assert result["total"] == 1 - assert result["tasks"][0]["title"] == "Pending" - - def test_list_tasks_filter_tags(self, mock_session, workflow, user_profile): - """Cover tags filter branch in list_tasks.""" - from components.task_tools import task_tools_factory - - epic = Epic(title="Tag Filter Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - mock_session.add_all([ - Task(epic_id=epic.id, title="Tagged", status="pending", tags=["api"]), - Task(epic_id=epic.id, title="Untagged", status="pending", tags=["ui"]), - ]) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - result = json.loads(list_tool.invoke({"epic_id": epic.id, "tags": "api"})) - - assert result["success"] is True - assert result["total"] == 1 - assert result["tasks"][0]["title"] == "Tagged" - - def test_list_tasks_clamps_limit(self, mock_session, workflow, user_profile): - """Verify limit is clamped to [1, 100].""" - from components.task_tools import task_tools_factory - - epic = Epic(title="Limit Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - mock_session.add(Task(epic_id=epic.id, title="T1", status="pending")) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - result = json.loads(list_tool.invoke({"epic_id": epic.id, "limit": 999999})) - assert result["success"] is True - assert result["total"] == 1 - - def test_list_tasks_too_many_tags(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Tag Cap Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - many_tags = ",".join(f"tag{i}" for i in range(21)) - result = json.loads(list_tool.invoke({"epic_id": epic.id, "tags": many_tags})) - - assert result["success"] is False - assert "Maximum is 20" in result["error"] - - def test_list_tasks_epic_not_found(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - result = json.loads(list_tool.invoke({"epic_id": "ep-nonexistent"})) - - assert result["success"] is False - assert "not found" in result["error"] - - -class TestUpdateTask: - def test_update_task_fields(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Update Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="Original", status="pending") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(update_tool.invoke({ - "task_id": task.id, - "status": "running", - "title": "Updated", - "notes": "Started work", - })) - - assert result["success"] is True - assert result["status"] == "running" - mock_session.refresh(task) - assert task.title == "Updated" - assert task.notes == ["Started work"] - - def test_update_task_all_optional_fields(self, mock_session, workflow, user_profile): - """Cover description, priority, result_summary, error_message branches.""" - from components.task_tools import task_tools_factory - - epic = Epic(title="All Fields", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="Full Update", status="pending") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(update_tool.invoke({ - "task_id": task.id, - "description": "New desc", - "priority": 1, - "result_summary": "Great work", - "error_message": "Minor issue", - "status": "completed", - })) - - assert result["success"] is True - mock_session.refresh(task) - assert task.description == "New desc" - assert task.priority == 1 - assert task.result_summary == "Great work" - assert task.error_message == "Minor issue" - assert task.status == "completed" - - def test_update_task_priority_out_of_range(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Priority Update", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="PrioTask", status="pending") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - result = json.loads(update_tool.invoke({ - "task_id": task.id, - "priority": 0, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - result = json.loads(update_tool.invoke({ - "task_id": task.id, - "priority": 10, - })) - assert result["success"] is False - assert "Priority must be between 1 and 5" in result["error"] - - def test_update_task_invalid_status(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Status Update", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="StatusTask", status="pending") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - result = json.loads(update_tool.invoke({ - "task_id": task.id, - "status": "bogus", - })) - assert result["success"] is False - assert "Invalid status" in result["error"] - - def test_update_task_not_found(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - result = json.loads(update_tool.invoke({ - "task_id": "tk-nonexistent", - "status": "completed", - })) - - assert result["success"] is False - assert "not found" in result["error"] - - -class TestCancelTask: - def test_cancel_task_success(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Cancel Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="To Cancel", status="running") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - cancel_tool = next(t for t in tools if t.name == "cancel_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(cancel_tool.invoke({ - "task_id": task.id, - "reason": "No longer needed", - })) - - assert result["success"] is True - mock_session.refresh(task) - assert task.status == "cancelled" - assert "Cancelled: No longer needed" in task.notes - - def test_cancel_task_not_found(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - cancel_tool = next(t for t in tools if t.name == "cancel_task") - result = json.loads(cancel_tool.invoke({"task_id": "tk-nonexistent"})) - - assert result["success"] is False - assert "not found" in result["error"] - - def test_cancel_with_execution(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - from models.execution import WorkflowExecution - - epic = Epic(title="Cancel Exec Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - - execution = WorkflowExecution( - workflow_id=workflow.id, - user_profile_id=user_profile.id, - thread_id="test-thread", - status="running", - ) - mock_session.add(execution) - mock_session.flush() - - task = Task( - epic_id=epic.id, - title="With Execution", - status="running", - execution_id=execution.execution_id, - ) - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - cancel_tool = next(t for t in tools if t.name == "cancel_task") - with patch("ws.broadcast.broadcast"): - result = json.loads(cancel_tool.invoke({"task_id": task.id})) - - assert result["success"] is True - assert result["execution_cancelled"] is True - mock_session.refresh(execution) - assert execution.status == "cancelled" - - -# --------------------------------------------------------------------------- -# Agent list-returning factory support -# --------------------------------------------------------------------------- - -class TestAgentListFactorySupport: - """Test that agent._resolve_tools handles list-returning factories.""" - - def test_resolve_tools_list_factory(self, mock_session, workflow): - from components.agent import _resolve_tools - from models.node import BaseComponentConfig, WorkflowEdge, WorkflowNode - - # Create agent node - agent_config = BaseComponentConfig(component_type="agent", system_prompt="test") - mock_session.add(agent_config) - mock_session.flush() - agent_node = WorkflowNode( - workflow_id=workflow.id, - node_id="agent_1", - component_type="agent", - component_config_id=agent_config.id, - ) - mock_session.add(agent_node) - - # Create epic_tools node - tool_config = BaseComponentConfig(component_type="epic_tools") - mock_session.add(tool_config) - mock_session.flush() - tool_node = WorkflowNode( - workflow_id=workflow.id, - node_id="epic_tools_1", - component_type="epic_tools", - component_config_id=tool_config.id, - ) - mock_session.add(tool_node) - - # Connect them with a tool edge - edge = WorkflowEdge( - workflow_id=workflow.id, - source_node_id="epic_tools_1", - target_node_id="agent_1", - edge_label="tool", - ) - mock_session.add(edge) - mock_session.commit() - mock_session.refresh(agent_node) - - tools, tool_metadata = _resolve_tools(agent_node) - - # epic_tools returns 4 tools - assert len(tools) == 4 - tool_names = {t.name for t in tools} - assert "create_epic" in tool_names - assert "epic_status" in tool_names - # Verify tool_metadata was populated - assert "create_epic" in tool_metadata - assert tool_metadata["create_epic"]["tool_node_id"] == "epic_tools_1" - - def test_resolve_tools_single_tool_factory(self, mock_session, workflow): - """Cover the else branch (L176-177) — single tool from factory.""" - from components.agent import _resolve_tools - from models.node import BaseComponentConfig, WorkflowEdge, WorkflowNode - - agent_config = BaseComponentConfig(component_type="agent", system_prompt="test") - mock_session.add(agent_config) - mock_session.flush() - agent_node = WorkflowNode( - workflow_id=workflow.id, - node_id="agent_single", - component_type="agent", - component_config_id=agent_config.id, - ) - mock_session.add(agent_node) - - # get_totp_code returns a single tool (not a list) - tool_config = BaseComponentConfig(component_type="get_totp_code") - mock_session.add(tool_config) - mock_session.flush() - tool_node = WorkflowNode( - workflow_id=workflow.id, - node_id="totp_1", - component_type="get_totp_code", - component_config_id=tool_config.id, - ) - mock_session.add(tool_node) - - edge = WorkflowEdge( - workflow_id=workflow.id, - source_node_id="totp_1", - target_node_id="agent_single", - edge_label="tool", - ) - mock_session.add(edge) - mock_session.commit() - mock_session.refresh(agent_node) - - tools, tool_metadata = _resolve_tools(agent_node) - - assert len(tools) == 1 - assert tools[0].name == "get_totp_code" - assert "get_totp_code" in tool_metadata - assert tool_metadata["get_totp_code"]["tool_node_id"] == "totp_1" - - def test_resolve_tools_exception_returns_empty(self, db, workflow): - """Cover the except branch in _resolve_tools (L176-177).""" - from components.agent import _resolve_tools - from models.node import BaseComponentConfig, WorkflowNode - - agent_config = BaseComponentConfig(component_type="agent", system_prompt="test") - db.add(agent_config) - db.flush() - agent_node = WorkflowNode( - workflow_id=workflow.id, - node_id="agent_err", - component_type="agent", - component_config_id=agent_config.id, - ) - db.add(agent_node) - db.commit() - db.refresh(agent_node) - - # Force an exception by making SessionLocal raise - with patch("database.SessionLocal", side_effect=RuntimeError("DB down")): - tools, tool_metadata = _resolve_tools(agent_node) - - assert tools == [] - assert tool_metadata == {} - - -# --------------------------------------------------------------------------- -# Broadcast exception handling (catch-and-log branches) -# --------------------------------------------------------------------------- - -class TestBroadcastExceptions: - """Cover the except branches where broadcast fails but tool returns success.""" - - def test_create_epic_broadcast_failure(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_epic") - with patch("ws.broadcast.broadcast", side_effect=RuntimeError("Redis down")): - result = json.loads(create_tool.invoke({"title": "Broadcast Fail"})) - - # Tool should still succeed despite broadcast failure - assert result["success"] is True - - def test_update_epic_broadcast_failure(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - epic = Epic(title="Bcast Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - with patch("ws.broadcast.broadcast", side_effect=RuntimeError("Redis down")): - result = json.loads(update_tool.invoke({"epic_id": epic.id, "status": "active"})) - - assert result["success"] is True - - def test_create_task_broadcast_failure(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Task Bcast", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - mock_session.refresh(epic) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - with patch("ws.broadcast.broadcast", side_effect=RuntimeError("Redis down")): - result = json.loads(create_tool.invoke({"epic_id": epic.id, "title": "Bcast fail"})) - - assert result["success"] is True - - def test_update_task_broadcast_failure(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Update Bcast", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="Bcast", status="pending") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - with patch("ws.broadcast.broadcast", side_effect=RuntimeError("Redis down")): - result = json.loads(update_tool.invoke({"task_id": task.id, "status": "running"})) - - assert result["success"] is True - - def test_cancel_task_broadcast_failure(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Cancel Bcast", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.flush() - task = Task(epic_id=epic.id, title="Bcast Cancel", status="running") - mock_session.add(task) - mock_session.commit() - mock_session.refresh(task) - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - cancel_tool = next(t for t in tools if t.name == "cancel_task") - with patch("ws.broadcast.broadcast", side_effect=RuntimeError("Redis down")): - result = json.loads(cancel_tool.invoke({"task_id": task.id})) - - assert result["success"] is True - - -# --------------------------------------------------------------------------- -# DB error handling (outer except branches with rollback) -# --------------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Post-commit refresh failure (verify success despite refresh error) -# --------------------------------------------------------------------------- - -class TestPostCommitRefreshFailure: - """Verify that a failing db.refresh() after successful commit still returns success.""" - - def test_create_epic_refresh_failure(self, mock_session, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - node = _make_node("epic_tools", workflow.id) - tools = epic_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_epic") - original_refresh = mock_session.refresh - - def broken_refresh(obj): - raise RuntimeError("refresh exploded") - - mock_session.refresh = broken_refresh - try: - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({"title": "Refresh Fail Epic"})) - finally: - mock_session.refresh = original_refresh - - assert result["success"] is True - assert result["epic_id"].startswith("ep-") - - def test_create_task_refresh_failure(self, mock_session, workflow, user_profile): - from components.task_tools import task_tools_factory - - epic = Epic(title="Refresh Test", user_profile_id=user_profile.id) - mock_session.add(epic) - mock_session.commit() - - node = _make_node("task_tools", workflow.id) - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - original_refresh = mock_session.refresh - - def broken_refresh(obj): - raise RuntimeError("refresh exploded") - - mock_session.refresh = broken_refresh - try: - with patch("ws.broadcast.broadcast"): - result = json.loads(create_tool.invoke({ - "epic_id": epic.id, - "title": "Refresh Fail Task", - })) - finally: - mock_session.refresh = original_refresh - - assert result["success"] is True - assert result["task_id"].startswith("tk-") - - -# --------------------------------------------------------------------------- -# Component type mapping correctness -# --------------------------------------------------------------------------- - -class TestComponentTypeMapping: - """Verify COMPONENT_TYPE_TO_CONFIG uses the correct config classes.""" - - def test_epic_tools_maps_to_epic_tools_config(self): - from models.node import COMPONENT_TYPE_TO_CONFIG, _EpicToolsConfig - - assert COMPONENT_TYPE_TO_CONFIG["epic_tools"] is _EpicToolsConfig - - def test_task_tools_maps_to_task_tools_config(self): - from models.node import COMPONENT_TYPE_TO_CONFIG, _TaskToolsConfig - - assert COMPONENT_TYPE_TO_CONFIG["task_tools"] is _TaskToolsConfig - - -class TestDBErrorBranches: - """Cover except Exception: db.rollback() branches by forcing DB errors.""" - - def _make_broken_session(self, db, original_close): - """Patch db to raise on commit. Returns (db, original_commit, original_rollback, original_close).""" - original_commit = db.commit - original_rollback = db.rollback - - db.close = lambda: None - db.commit = lambda: (_ for _ in ()).throw(RuntimeError("DB commit failed")) - db.rollback = lambda: None - return db, original_commit, original_rollback, original_close - - def _restore_session(self, db, original_commit, original_rollback, original_close): - db.commit = original_commit - db.rollback = original_rollback - db.close = original_close - - def test_create_epic_db_error(self, db, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - original_close = db.close - - node = _make_node("epic_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = epic_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_epic") - - broken_db, orig_commit, orig_rb, orig_close = self._make_broken_session(db, original_close) - with patch("database.SessionLocal", return_value=broken_db): - result = json.loads(create_tool.invoke({"title": "Will fail"})) - - self._restore_session(db, orig_commit, orig_rb, orig_close) - assert result["success"] is False - assert "error" in result - - def test_epic_status_db_error(self, db, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - original_close = db.close - - node = _make_node("epic_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = epic_tools_factory(node) - - status_tool = next(t for t in tools if t.name == "epic_status") - - with patch("database.SessionLocal") as mock_sl: - mock_db = mock_sl.return_value - mock_db.query.side_effect = RuntimeError("DB query failed") - mock_db.close = lambda: None - result = json.loads(status_tool.invoke({"epic_id": "ep-x"})) - - db.close = original_close - assert result["success"] is False - assert "error" in result - - def test_update_epic_db_error(self, db, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - original_close = db.close - - epic = Epic(title="Will Error", user_profile_id=user_profile.id) - db.add(epic) - db.commit() - db.refresh(epic) - - node = _make_node("epic_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = epic_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_epic") - - broken_db, orig_commit, orig_rb, orig_close = self._make_broken_session(db, original_close) - with patch("database.SessionLocal", return_value=broken_db): - result = json.loads(update_tool.invoke({"epic_id": epic.id, "title": "New"})) - - self._restore_session(db, orig_commit, orig_rb, orig_close) - assert result["success"] is False - assert "error" in result - - def test_search_epics_db_error(self, db, workflow, user_profile): - from components.epic_tools import epic_tools_factory - - original_close = db.close - - node = _make_node("epic_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = epic_tools_factory(node) - - search_tool = next(t for t in tools if t.name == "search_epics") - - with patch("database.SessionLocal") as mock_sl: - mock_db = mock_sl.return_value - mock_db.query.side_effect = RuntimeError("DB error") - mock_db.close = lambda: None - result = json.loads(search_tool.invoke({})) - - db.close = original_close - assert result["success"] is False - assert "error" in result - - def test_create_task_db_error(self, db, workflow, user_profile): - from components.task_tools import task_tools_factory - - original_close = db.close - - epic = Epic(title="Task Error", user_profile_id=user_profile.id) - db.add(epic) - db.commit() - db.refresh(epic) - - node = _make_node("task_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = task_tools_factory(node) - - create_tool = next(t for t in tools if t.name == "create_task") - - broken_db, orig_commit, orig_rb, orig_close = self._make_broken_session(db, original_close) - with patch("database.SessionLocal", return_value=broken_db): - result = json.loads(create_tool.invoke({"epic_id": epic.id, "title": "Fail"})) - - self._restore_session(db, orig_commit, orig_rb, orig_close) - assert result["success"] is False - assert "error" in result - - def test_list_tasks_db_error(self, db, workflow, user_profile): - from components.task_tools import task_tools_factory - - original_close = db.close - - node = _make_node("task_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = task_tools_factory(node) - - list_tool = next(t for t in tools if t.name == "list_tasks") - - with patch("database.SessionLocal") as mock_sl: - mock_db = mock_sl.return_value - mock_db.query.side_effect = RuntimeError("DB error") - mock_db.close = lambda: None - result = json.loads(list_tool.invoke({"epic_id": "ep-x"})) - - db.close = original_close - assert result["success"] is False - assert "error" in result - - def test_update_task_db_error(self, db, workflow, user_profile): - from components.task_tools import task_tools_factory - - original_close = db.close - - epic = Epic(title="UTask Error", user_profile_id=user_profile.id) - db.add(epic) - db.flush() - task = Task(epic_id=epic.id, title="Will Error", status="pending") - db.add(task) - db.commit() - db.refresh(task) - - node = _make_node("task_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = task_tools_factory(node) - - update_tool = next(t for t in tools if t.name == "update_task") - - broken_db, orig_commit, orig_rb, orig_close = self._make_broken_session(db, original_close) - with patch("database.SessionLocal", return_value=broken_db): - result = json.loads(update_tool.invoke({"task_id": task.id, "status": "running"})) - - self._restore_session(db, orig_commit, orig_rb, orig_close) - assert result["success"] is False - assert "error" in result - - def test_cancel_task_db_error(self, db, workflow, user_profile): - from components.task_tools import task_tools_factory - - original_close = db.close - - epic = Epic(title="CTask Error", user_profile_id=user_profile.id) - db.add(epic) - db.flush() - task = Task(epic_id=epic.id, title="Will Error", status="running") - db.add(task) - db.commit() - db.refresh(task) - - node = _make_node("task_tools", workflow.id) - with patch("database.SessionLocal", return_value=db): - db.close = lambda: None - tools = task_tools_factory(node) - - cancel_tool = next(t for t in tools if t.name == "cancel_task") - - broken_db, orig_commit, orig_rb, orig_close = self._make_broken_session(db, original_close) - with patch("database.SessionLocal", return_value=broken_db): - result = json.loads(cancel_tool.invoke({"task_id": task.id})) - - self._restore_session(db, orig_commit, orig_rb, orig_close) - assert result["success"] is False - assert "error" in result diff --git a/platform/tests/test_epics_tasks.py b/platform/tests/test_epics_tasks.py deleted file mode 100644 index 5fd01c9c..00000000 --- a/platform/tests/test_epics_tasks.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Tests for the Epic and Task REST API.""" - -from __future__ import annotations - -import sys -from pathlib import Path -from unittest.mock import patch - -import pytest -from fastapi.testclient import TestClient - -_platform_dir = str(Path(__file__).resolve().parent.parent) -if _platform_dir not in sys.path: - sys.path.insert(0, _platform_dir) - -from models.epic import Epic, Task - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def app(db): - from main import app as _app - from database import get_db - - def _override_get_db(): - try: - yield db - finally: - pass - - _app.dependency_overrides[get_db] = _override_get_db - yield _app - _app.dependency_overrides.clear() - - -@pytest.fixture -def client(app): - return TestClient(app) - - -@pytest.fixture -def auth_client(client, api_key): - client.headers["Authorization"] = f"Bearer {api_key.key}" - return client - - -@pytest.fixture -def epic(db, user_profile): - e = Epic( - title="Test Epic", - description="A test epic", - tags=["test"], - user_profile_id=user_profile.id, - ) - db.add(e) - db.commit() - db.refresh(e) - return e - - -@pytest.fixture -def task(db, epic): - t = Task( - epic_id=epic.id, - title="Test Task", - description="A test task", - tags=["backend"], - ) - db.add(t) - db.commit() - db.refresh(t) - return t - - -# --------------------------------------------------------------------------- -# Epic API Tests -# --------------------------------------------------------------------------- - -class TestEpicCRUD: - @patch("api.epics.broadcast") - def test_create_epic(self, mock_broadcast, auth_client): - resp = auth_client.post("/api/v1/epics/", json={ - "title": "My Epic", - "description": "Build something", - "tags": ["feature"], - "priority": 1, - }) - assert resp.status_code == 201 - data = resp.json() - assert data["title"] == "My Epic" - assert data["description"] == "Build something" - assert data["tags"] == ["feature"] - assert data["priority"] == 1 - assert data["status"] == "planning" - assert data["id"].startswith("ep-") - - @patch("api.epics.broadcast") - def test_list_epics(self, mock_broadcast, auth_client, epic): - resp = auth_client.get("/api/v1/epics/") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - assert any(e["id"] == epic.id for e in data["items"]) - - @patch("api.epics.broadcast") - def test_list_epics_filter_status(self, mock_broadcast, auth_client, epic): - resp = auth_client.get("/api/v1/epics/?status=planning") - assert resp.status_code == 200 - assert resp.json()["total"] >= 1 - - resp2 = auth_client.get("/api/v1/epics/?status=completed") - assert resp2.status_code == 200 - assert resp2.json()["total"] == 0 - - @patch("api.epics.broadcast") - def test_get_epic(self, mock_broadcast, auth_client, epic): - resp = auth_client.get(f"/api/v1/epics/{epic.id}/") - assert resp.status_code == 200 - assert resp.json()["id"] == epic.id - assert resp.json()["title"] == "Test Epic" - - def test_get_epic_not_found(self, auth_client): - resp = auth_client.get("/api/v1/epics/nonexistent/") - assert resp.status_code == 404 - - @patch("api.epics.broadcast") - def test_update_epic(self, mock_broadcast, auth_client, epic): - resp = auth_client.patch(f"/api/v1/epics/{epic.id}/", json={ - "title": "Updated Epic", - "status": "active", - }) - assert resp.status_code == 200 - data = resp.json() - assert data["title"] == "Updated Epic" - assert data["status"] == "active" - - @patch("api.epics.broadcast") - def test_update_epic_cancel_cascades_tasks(self, mock_broadcast, auth_client, epic, task): - resp = auth_client.patch(f"/api/v1/epics/{epic.id}/", json={ - "status": "cancelled", - }) - assert resp.status_code == 200 - # Check that the task was cancelled too - task_resp = auth_client.get(f"/api/v1/tasks/{task.id}/") - assert task_resp.json()["status"] == "cancelled" - - @patch("api.epics.broadcast") - def test_delete_epic(self, mock_broadcast, auth_client, epic): - resp = auth_client.delete(f"/api/v1/epics/{epic.id}/") - assert resp.status_code == 204 - # Verify it's gone - resp2 = auth_client.get(f"/api/v1/epics/{epic.id}/") - assert resp2.status_code == 404 - - @patch("api.epics.broadcast") - def test_batch_delete_epics(self, mock_broadcast, auth_client, epic): - resp = auth_client.post("/api/v1/epics/batch-delete/", json={ - "epic_ids": [epic.id], - }) - assert resp.status_code == 204 - - @patch("api.epics.broadcast") - def test_list_epic_tasks(self, mock_broadcast, auth_client, epic, task): - resp = auth_client.get(f"/api/v1/epics/{epic.id}/tasks/") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - assert any(t["id"] == task.id for t in data["items"]) - - -# --------------------------------------------------------------------------- -# Task API Tests -# --------------------------------------------------------------------------- - -class TestTaskCRUD: - @patch("api.tasks.broadcast") - def test_create_task(self, mock_broadcast, auth_client, epic): - resp = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "New Task", - "description": "Do something", - "tags": ["frontend"], - }) - assert resp.status_code == 201 - data = resp.json() - assert data["title"] == "New Task" - assert data["epic_id"] == epic.id - assert data["status"] == "pending" - assert data["id"].startswith("tk-") - - @patch("api.tasks.broadcast") - def test_create_task_blocked_by_deps(self, mock_broadcast, auth_client, epic, task): - resp = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "Dependent Task", - "depends_on": [task.id], - }) - assert resp.status_code == 201 - data = resp.json() - assert data["status"] == "blocked" - assert data["depends_on"] == [task.id] - - @patch("api.tasks.broadcast") - def test_list_tasks(self, mock_broadcast, auth_client, task): - resp = auth_client.get("/api/v1/tasks/") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - - @patch("api.tasks.broadcast") - def test_list_tasks_filter_epic(self, mock_broadcast, auth_client, epic, task): - resp = auth_client.get(f"/api/v1/tasks/?epic_id={epic.id}") - assert resp.status_code == 200 - assert resp.json()["total"] >= 1 - - @patch("api.tasks.broadcast") - def test_get_task(self, mock_broadcast, auth_client, task): - resp = auth_client.get(f"/api/v1/tasks/{task.id}/") - assert resp.status_code == 200 - assert resp.json()["id"] == task.id - - def test_get_task_not_found(self, auth_client): - resp = auth_client.get("/api/v1/tasks/nonexistent/") - assert resp.status_code == 404 - - @patch("api.tasks.broadcast") - def test_update_task(self, mock_broadcast, auth_client, task): - resp = auth_client.patch(f"/api/v1/tasks/{task.id}/", json={ - "title": "Updated Task", - "status": "running", - }) - assert resp.status_code == 200 - data = resp.json() - assert data["title"] == "Updated Task" - assert data["status"] == "running" - - @patch("api.tasks.broadcast") - def test_update_task_syncs_epic_progress(self, mock_broadcast, auth_client, epic, task): - # Complete the task - auth_client.patch(f"/api/v1/tasks/{task.id}/", json={"status": "completed"}) - # Check epic progress - resp = auth_client.get(f"/api/v1/epics/{epic.id}/") - data = resp.json() - assert data["completed_tasks"] == 1 - assert data["total_tasks"] == 1 - - @patch("api.tasks.broadcast") - def test_delete_task(self, mock_broadcast, auth_client, task): - resp = auth_client.delete(f"/api/v1/tasks/{task.id}/") - assert resp.status_code == 204 - resp2 = auth_client.get(f"/api/v1/tasks/{task.id}/") - assert resp2.status_code == 404 - - @patch("api.tasks.broadcast") - def test_batch_delete_tasks(self, mock_broadcast, auth_client, task): - resp = auth_client.post("/api/v1/tasks/batch-delete/", json={ - "task_ids": [task.id], - }) - assert resp.status_code == 204 - - @patch("api.tasks.broadcast") - def test_delete_task_cleans_depends_on(self, mock_broadcast, auth_client, epic, task): - # Create a second task that depends on the first - resp = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "Dependent Task", - "depends_on": [task.id], - }) - dep_task_id = resp.json()["id"] - assert resp.json()["depends_on"] == [task.id] - - # Delete the dependency - auth_client.delete(f"/api/v1/tasks/{task.id}/") - - # Verify depends_on was cleaned up - resp2 = auth_client.get(f"/api/v1/tasks/{dep_task_id}/") - assert resp2.json()["depends_on"] == [] - - @patch("api.tasks.broadcast") - @patch("api.epic_helpers.broadcast", create=True) - def test_complete_task_unblocks_dependent(self, mock_helper_bc, mock_tasks_bc, auth_client, epic): - """Completing a task unblocks dependents whose deps are all completed.""" - # Create task A (pending) - resp_a = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "Task A", - }) - assert resp_a.status_code == 201 - task_a_id = resp_a.json()["id"] - - # Create task B that depends on A — should start as blocked - resp_b = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "Task B", - "depends_on": [task_a_id], - }) - assert resp_b.status_code == 201 - task_b_id = resp_b.json()["id"] - assert resp_b.json()["status"] == "blocked" - - # Complete task A - resp_a_complete = auth_client.patch(f"/api/v1/tasks/{task_a_id}/", json={"status": "completed"}) - assert resp_a_complete.status_code == 200 - - # Task B should now be pending (auto-unblocked) - resp_b2 = auth_client.get(f"/api/v1/tasks/{task_b_id}/") - assert resp_b2.json()["status"] == "pending" - - @patch("api.tasks.broadcast") - @patch("api.epic_helpers.broadcast", create=True) - def test_complete_task_partial_deps_stays_blocked(self, mock_helper_bc, mock_tasks_bc, auth_client, epic): - """If only some deps are completed, dependent task stays blocked.""" - resp_a = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, "title": "Dep A", - }) - assert resp_a.status_code == 201 - task_a_id = resp_a.json()["id"] - - resp_b = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, "title": "Dep B", - }) - assert resp_b.status_code == 201 - task_b_id = resp_b.json()["id"] - - resp_c = auth_client.post("/api/v1/tasks/", json={ - "epic_id": epic.id, - "title": "Blocked Task", - "depends_on": [task_a_id, task_b_id], - }) - assert resp_c.status_code == 201 - task_c_id = resp_c.json()["id"] - assert resp_c.json()["status"] == "blocked" - - # Complete only task A - resp_a_complete = auth_client.patch(f"/api/v1/tasks/{task_a_id}/", json={"status": "completed"}) - assert resp_a_complete.status_code == 200 - - # Task C should still be blocked (B is not completed) - resp_c2 = auth_client.get(f"/api/v1/tasks/{task_c_id}/") - assert resp_c2.json()["status"] == "blocked" - - @patch("api.tasks.broadcast") - def test_complete_task_no_dependents_noop(self, mock_broadcast, auth_client, epic, task): - """Completing a task with no dependents doesn't error.""" - resp = auth_client.patch(f"/api/v1/tasks/{task.id}/", json={ - "status": "completed", - }) - assert resp.status_code == 200 - assert resp.json()["status"] == "completed" - - def test_create_task_epic_not_found(self, auth_client): - resp = auth_client.post("/api/v1/tasks/", json={ - "epic_id": "nonexistent", - "title": "Orphan Task", - }) - assert resp.status_code == 404 - - -# --------------------------------------------------------------------------- -# Model Tests -# --------------------------------------------------------------------------- - -class TestModels: - def test_epic_defaults(self, db, user_profile): - epic = Epic(title="Defaults Test", user_profile_id=user_profile.id) - db.add(epic) - db.commit() - db.refresh(epic) - assert epic.id.startswith("ep-") - assert epic.status == "planning" - assert epic.priority == 2 - assert epic.spent_tokens == 0 - assert epic.total_tasks == 0 - - def test_task_defaults(self, db, user_profile): - epic = Epic(title="Parent", user_profile_id=user_profile.id) - db.add(epic) - db.commit() - db.refresh(epic) - - task = Task(epic_id=epic.id, title="Defaults Test") - db.add(task) - db.commit() - db.refresh(task) - assert task.id.startswith("tk-") - assert task.status == "pending" - assert task.priority == 2 - assert task.retry_count == 0 - assert task.max_retries == 2 - - def test_epic_cascade_delete(self, db, user_profile): - epic = Epic(title="Cascade Test", user_profile_id=user_profile.id) - db.add(epic) - db.commit() - db.refresh(epic) - - task = Task(epic_id=epic.id, title="Child Task") - db.add(task) - db.commit() - task_id = task.id - - db.delete(epic) - db.commit() - assert db.query(Task).filter(Task.id == task_id).first() is None - - def test_workflow_tags_column(self, db, workflow): - workflow.tags = ["automation", "llm"] - db.commit() - db.refresh(workflow) - assert workflow.tags == ["automation", "llm"] From 2ebf017371bc520f4916841ba15b017a18c5be77 Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 17:06:02 +1030 Subject: [PATCH 2/6] refactor: remove epic/task references from backend modules and tests - models/__init__.py: remove Epic, Task imports - models/node.py: remove _EpicToolsConfig, _TaskToolsConfig and config map entries - api/__init__.py: remove epic/task routers - components/__init__.py: remove epic_tools, task_tools imports - schemas/node.py: remove from ComponentTypeStr - schemas/node_type_defs.py: remove epic_tools, task_tools registrations - services/dsl_compiler.py: remove from TOOL_TYPE_MAP - services/builder.py, topology.py: remove from SUB_COMPONENT_TYPES - services/orchestrator.py: remove _check_budget(), _sync_task_costs(), and all their call sites - components/agent.py: remove task linking in _create_child_from_interrupt() - tests: remove TestTaskLinkage, TestCostSync, TestSyncTaskCostsEdgeCases, TestCheckBudget classes and update expected sets Co-Authored-By: Claude Opus 4.6 --- .omc/state/hud-state.json | 6 + .omc/state/hud-stdin-cache.json | 1 + .sisyphus/plans/remove-epics-tasks.md | 175 +++++++++++++ platform/api/__init__.py | 4 - platform/components/__init__.py | 2 - platform/components/agent.py | 15 -- platform/models/__init__.py | 1 - platform/models/node.py | 10 - platform/schemas/node.py | 2 - platform/schemas/node_type_defs.py | 16 -- platform/services/builder.py | 2 +- platform/services/dsl_compiler.py | 2 - platform/services/orchestrator.py | 145 +---------- platform/services/topology.py | 2 +- platform/test_import.db | Bin 0 -> 4096 bytes platform/tests/test_spawn_and_await.py | 332 ------------------------- platform/tests/test_token_usage.py | 71 ------ platform/tests/test_topology.py | 2 +- 18 files changed, 186 insertions(+), 602 deletions(-) create mode 100644 .omc/state/hud-state.json create mode 100644 .omc/state/hud-stdin-cache.json create mode 100644 .sisyphus/plans/remove-epics-tasks.md create mode 100644 platform/test_import.db diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json new file mode 100644 index 00000000..45edf3dd --- /dev/null +++ b/.omc/state/hud-state.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-03-19T23:18:51.836Z", + "backgroundTasks": [], + "sessionStartTimestamp": "2026-03-19T23:18:26.506Z", + "sessionId": "c28ba0e5-d3a0-4572-9165-fecdc67660dc" +} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json new file mode 100644 index 00000000..7cfe6503 --- /dev/null +++ b/.omc/state/hud-stdin-cache.json @@ -0,0 +1 @@ +{"session_id":"c28ba0e5-d3a0-4572-9165-fecdc67660dc","transcript_path":"/home/aka/.claude/projects/-home-aka-programs/c28ba0e5-d3a0-4572-9165-fecdc67660dc.jsonl","cwd":"/home/aka/programs/pipelit","model":{"id":"claude-opus-4-6[1m]","display_name":"Opus 4.6 (1M context)"},"workspace":{"current_dir":"/home/aka/programs/pipelit","project_dir":"/home/aka/programs","added_dirs":[]},"version":"2.1.80","output_style":{"name":"default"},"cost":{"total_cost_usd":22.262601849999996,"total_duration_ms":26729304,"total_api_duration_ms":2588718,"total_lines_added":359,"total_lines_removed":29},"context_window":{"total_input_tokens":1036,"total_output_tokens":80428,"context_window_size":1000000,"current_usage":{"input_tokens":3,"output_tokens":155,"cache_creation_input_tokens":238,"cache_read_input_tokens":219387},"used_percentage":22,"remaining_percentage":78},"exceeds_200k_tokens":true,"rate_limits":{"five_hour":{"used_percentage":1,"resets_at":1773986400},"seven_day":{"used_percentage":4,"resets_at":1774472400}}} \ No newline at end of file diff --git a/.sisyphus/plans/remove-epics-tasks.md b/.sisyphus/plans/remove-epics-tasks.md new file mode 100644 index 00000000..11c1b42c --- /dev/null +++ b/.sisyphus/plans/remove-epics-tasks.md @@ -0,0 +1,175 @@ +# Plan: Remove Epics & Tasks System + +## Context +Remove the entire Epic/Task subsystem (models, API, components, frontend pages, orchestrator integration) while preserving the memory system, checkpoints, identify_user, and migration files. Branch: `fix/remove-epics` from `master`. + +## Task Dependency Graph +``` +Wave 1 (backend deletions) → Wave 2 (backend edits) +Wave 1 → Wave 3 (frontend deletions + edits) +Wave 2 + Wave 3 → Wave 4 (fixture cleanup + verification) +``` + +## Execution Waves + +### Wave 1: Backend File Deletions (parallel, safe — no other files import these directly) + +- **Task 1.1**: Delete `platform/models/epic.py` — Agent: sisyphus-junior +- **Task 1.2**: Delete `platform/schemas/epic.py` — Agent: sisyphus-junior +- **Task 1.3**: Delete `platform/api/epics.py` — Agent: sisyphus-junior +- **Task 1.4**: Delete `platform/api/tasks.py` — Agent: sisyphus-junior +- **Task 1.5**: Delete `platform/api/epic_helpers.py` — Agent: sisyphus-junior +- **Task 1.6**: Delete `platform/components/epic_tools.py` — Agent: sisyphus-junior +- **Task 1.7**: Delete `platform/components/task_tools.py` — Agent: sisyphus-junior +- **Task 1.8**: Delete `platform/tests/test_epics_tasks.py` — Agent: sisyphus-junior +- **Task 1.9**: Delete `platform/tests/test_epic_task_tools.py` — Agent: sisyphus-junior + +> All 9 deletions can be done in a single sisyphus-junior task (just `git rm` them all). + +--- + +### Wave 2: Backend File Edits (parallel within wave, depends on Wave 1) + +- **Task 2.1**: Edit `platform/models/__init__.py` — Agent: sisyphus-junior + - Remove line 29: `from models.epic import Epic, Task # noqa: F401` + +- **Task 2.2**: Edit `platform/models/node.py` — Agent: sisyphus-junior + - Remove lines 171-176 (the `_EpicToolsConfig` and `_TaskToolsConfig` classes) + - Remove from `COMPONENT_CONFIG_MAP` (lines 287-288): `"epic_tools": _EpicToolsConfig,` and `"task_tools": _TaskToolsConfig,` + +- **Task 2.3**: Edit `platform/api/__init__.py` — Agent: sisyphus-junior + - Remove line 11: `from api.epics import router as epics_router` + - Remove line 12: `from api.tasks import router as tasks_router` + - Remove line 27: `api_router.include_router(epics_router, prefix="/epics", tags=["epics"])` + - Remove line 28: `api_router.include_router(tasks_router, prefix="/tasks", tags=["tasks"])` + +- **Task 2.4**: Edit `platform/components/__init__.py` — Agent: sisyphus-junior + - Remove line 40: `epic_tools,` + - Remove line 56: `task_tools,` + +- **Task 2.5**: Edit `platform/schemas/node.py` — Agent: sisyphus-junior + - Remove `"epic_tools",` and `"task_tools",` from the `ComponentTypeStr` Literal (lines 21-22) + +- **Task 2.6**: Edit `platform/schemas/node_type_defs.py` — Agent: sisyphus-junior + - Remove lines 281-295: both `register_node_type()` calls for `epic_tools` and `task_tools` + +- **Task 2.7**: Edit `platform/services/dsl_compiler.py` — Agent: sisyphus-junior + - Remove lines 76-77 from `TOOL_TYPE_MAP`: `"epic_tools": "epic_tools"` and `"task_tools": "task_tools"` + +- **Task 2.8**: Edit `platform/services/builder.py` — Agent: sisyphus-junior + - Remove `"epic_tools"` and `"task_tools"` from `SUB_COMPONENT_TYPES` set (line 17) + +- **Task 2.9**: Edit `platform/services/topology.py` — Agent: sisyphus-junior + - Remove `"epic_tools"` and `"task_tools"` from `SUB_COMPONENT_TYPES` set (line 13) + +- **Task 2.10**: Edit `platform/services/orchestrator.py` — Agent: hephaestus — **CRITICAL, highest risk** + - Remove the `_check_budget()` function (lines 1471-1507) entirely + - Remove the `_sync_task_costs()` function (lines 1651-1737) entirely + - Remove call sites: + - Line 452-453: `budget_error = _check_budget(execution_id, state, db)` and the conditional block following it + - Line 460: `_sync_task_costs(execution_id, db)` call + - Line 630: `_sync_task_costs(execution_id, db)` call + - Line 813: `_sync_task_costs(execution_id, db)` call + - Line 1308: `_sync_task_costs(execution_id, db)` call + - **Caution**: Read surrounding context carefully. The budget check is inside a larger execution flow — removing it must not break the control flow (e.g., check for `if budget_error:` blocks that may short-circuit). The `_sync_task_costs` calls are in `try/except` or `finally` blocks — ensure the block structure stays valid. + +- **Task 2.11**: Edit `platform/components/agent.py` — Agent: sisyphus-junior + - Remove lines 461-473: the `if task_id:` block that imports `Task` from `models.epic` and links task to child execution + - Keep the rest of `_create_child_from_interrupt()` intact + +> Tasks 2.1-2.9 and 2.11 are simple string removals — can be batched into one or two sisyphus-junior tasks. Task 2.10 (orchestrator) needs hephaestus due to complexity and risk. + +--- + +### Wave 3: Frontend Deletions + Edits (parallel within wave, depends on Wave 1) + +- **Task 3.1**: Delete frontend files — Agent: sisyphus-junior + - Delete `platform/frontend/src/api/epics.ts` + - Delete `platform/frontend/src/api/tasks.ts` + - Delete `platform/frontend/src/features/epics/` directory (EpicsPage.tsx, EpicDetailPage.tsx) + +- **Task 3.2**: Edit `platform/frontend/src/App.tsx` — Agent: sisyphus-junior + - Remove lines 15-16: imports of `EpicsPage` and `EpicDetailPage` + - Remove lines 40-41: routes for `/epics` and `/epics/:epicId` + +- **Task 3.3**: Edit `platform/frontend/src/components/layout/AppLayout.tsx` — Agent: sisyphus-junior + - Remove line 14: `ListTodo` icon import (or just the `ListTodo` from the import) + - Remove line 28: `{ to: "/epics", icon: ListTodo, label: "Epics" }` nav item + +- **Task 3.4**: Edit `platform/frontend/src/types/models.ts` — Agent: sisyphus-junior + - Remove `epic_tools` and `task_tools` from `ComponentType` union (lines 15-16) + - Remove `EpicStatus` type (line 127) + - Remove `TaskStatus` type (line 128) + - Remove `Epic` interface (lines 130-140) + - Remove `Task` interface (lines 142-154) + - Remove `EpicCreate`, `EpicUpdate`, `TaskCreate`, `TaskUpdate` types (lines 156-159) + +- **Task 3.5**: Edit `platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx` — Agent: sisyphus-junior + - Remove from `COMPONENT_COLORS` (lines 72-73): `epic_tools: "#14b8a6"` and `task_tools: "#14b8a6"` + - Remove from `COMPONENT_ICONS` (line 108 area): `epic_tools: faClipboardList` and `task_tools: faListCheck` + - Remove from `isTool` set (line 136): `epic_tools`, `task_tools` + - Remove from `isSubComponent` set (line 137): `epic_tools`, `task_tools` + - Remove icon imports (`faClipboardList`, `faListCheck`) if no longer used elsewhere + +- **Task 3.6**: Edit `platform/frontend/src/features/workflows/components/NodePalette.tsx` — Agent: sisyphus-junior + - Remove from `ICONS` (lines 36-37): `epic_tools: ClipboardList` and `task_tools: ListChecks` + - Remove from `NODE_CATEGORIES` "Agent" array (line 67): `epic_tools`, `task_tools` + - Remove icon imports (`ClipboardList`, `ListChecks`) if no longer used elsewhere + +- **Task 3.7**: Edit `platform/frontend/src/lib/wsManager.ts` — Agent: sisyphus-junior + - Remove line 2: `Epic` from the import type statement + - Remove lines 180-201: the `case "epic_updated"` and `case "epic_deleted"` and task-related epic handlers + +> All frontend tasks can be batched into one sisyphus-junior task since they're straightforward removals. + +--- + +### Wave 4: Fixture/Prompt Cleanup + Verification (depends on Waves 2 & 3) + +- **Task 4.1**: Edit `platform/tests/dsl_fixtures/06_error_routing/Step1/topology.yaml` — Agent: sisyphus-junior + - Remove lines 35-36: `- type: epic_tools` and `- type: task_tools` + +- **Task 4.2**: Edit `platform/tests/dsl_fixtures/workflow_generator/fixture.json` — Agent: sisyphus-junior + - Remove `epic_tools` and `task_tools` from the Scribe and Topology Agent system prompt node catalogs + - Be careful with JSON syntax (trailing commas, etc.) + +- **Task 4.3**: Verification — Agent: sisyphus-junior + - Run `cd platform && grep -rn "epic_tools\|task_tools\|EpicTools\|TaskTools\|_check_budget\|_sync_task_costs\|epic_helpers" --include="*.py" --include="*.ts" --include="*.tsx" --include="*.json" --include="*.yaml" --exclude-dir=node_modules --exclude-dir=alembic --exclude-dir=.git` — should return zero results + - Run `grep -rn "from models.epic\|from schemas.epic\|from api.epics\|from api.tasks\|from api.epic_helpers" --include="*.py" --exclude-dir=alembic` — should return zero results + +- **Task 4.4**: Run tests — Agent: sisyphus-junior + - `cd platform && python -m pytest tests/ -v --ignore=tests/test_epics_tasks.py --ignore=tests/test_epic_task_tools.py -x` + - Verify no import errors or test failures from the removal + +- **Task 4.5**: Frontend build check — Agent: sisyphus-junior + - `cd platform/frontend && npx tsc --noEmit` — verify no TypeScript errors + - `npm run build` — verify production build succeeds + +--- + +## Recommended Execution Batching + +Given the simplicity of most tasks, the actual execution can be compressed: + +| Batch | Agent | Tasks | +|---|---|---| +| A (parallel) | sisyphus-junior | Wave 1 (all deletions) + Wave 2 tasks 2.1-2.9, 2.11 (simple edits) + Wave 3 (all frontend) | +| B (parallel with A) | hephaestus | Task 2.10 (orchestrator — complex) | +| C (after A+B) | sisyphus-junior | Wave 4 (fixtures + verification) | + +## Risk Flags + +1. **Orchestrator surgery (Task 2.10)**: The `_check_budget` and `_sync_task_costs` functions are called from multiple places in the execution flow. Incorrect removal could break the `try/except/finally` block structure or leave dangling references. Must read full context around each call site. +2. **JSON fixture editing (Task 4.2)**: The workflow_generator fixture.json contains long lines with embedded system prompts. Removing `epic_tools`/`task_tools` from comma-separated lists requires care with JSON syntax. +3. **Polymorphic identity orphans**: Existing DB rows with `component_type="epic_tools"` or `"task_tools"` will have no Python class. This is acceptable since we're keeping migration files and these nodes simply won't load. But if any workflow references them, it could cause runtime errors on load. Consider: should we add a data migration or just accept this? +4. **`task_id` parameter in `_create_child_from_interrupt`**: After removing the `if task_id:` block, the `task_id` variable may still be referenced elsewhere in the function. Verify that `task_id` is only used in the removed block. + +## QA Scenarios +- [ ] All Python tests pass (excluding deleted test files) +- [ ] `grep` for epic/task references returns zero hits (excluding alembic) +- [ ] TypeScript compilation succeeds (`tsc --noEmit`) +- [ ] Frontend production build succeeds (`npm run build`) +- [ ] App starts without import errors (`python -c "from api import api_router; from components import *; from models import *"`) +- [ ] Workflow editor canvas loads without console errors (manual check) +- [ ] Sidebar no longer shows "Epics" link (manual check) +- [ ] `/api/v1/epics` returns 404 (manual check) diff --git a/platform/api/__init__.py b/platform/api/__init__.py index 7b6eee4a..50cd1d74 100644 --- a/platform/api/__init__.py +++ b/platform/api/__init__.py @@ -8,8 +8,6 @@ from api.executions import router as executions_router from api.credentials import router as credentials_router from api.memory import router as memory_router -from api.epics import router as epics_router -from api.tasks import router as tasks_router from api.schedules import router as schedules_router from api.workspaces import router as workspaces_router from api.settings import router as settings_router @@ -24,8 +22,6 @@ api_router.include_router(executions_router, prefix="/executions", tags=["executions"]) api_router.include_router(credentials_router, prefix="/credentials", tags=["credentials"]) api_router.include_router(memory_router, prefix="/memories", tags=["memories"]) -api_router.include_router(epics_router, prefix="/epics", tags=["epics"]) -api_router.include_router(tasks_router, prefix="/tasks", tags=["tasks"]) api_router.include_router(schedules_router, prefix="/schedules", tags=["schedules"]) api_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"]) api_router.include_router(settings_router, prefix="/settings", tags=["settings"]) diff --git a/platform/components/__init__.py b/platform/components/__init__.py index f2cc6f33..f295dfb0 100644 --- a/platform/components/__init__.py +++ b/platform/components/__init__.py @@ -36,7 +36,6 @@ def get_component_factory(component_type: str): control_flow, data_ops, deep_agent, - epic_tools, get_totp_code, human_confirmation, identify_user, @@ -52,7 +51,6 @@ def get_component_factory(component_type: str): subworkflow, switch, system_health, - task_tools, trigger, validate_gherkin, validate_topology, diff --git a/platform/components/agent.py b/platform/components/agent.py index f4b5d0c3..cd2df673 100644 --- a/platform/components/agent.py +++ b/platform/components/agent.py @@ -374,7 +374,6 @@ def _create_child_from_interrupt( workflow_slug = task_data.get("workflow_slug", "") input_text = task_data.get("input_text", "") - task_id = task_data.get("task_id") input_data = task_data.get("input_data", {}) parent_execution_id = state.get("execution_id", "") @@ -458,20 +457,6 @@ def _create_child_from_interrupt( child_id = str(child_execution.execution_id) - # Link task if provided - if task_id: - try: - from models.epic import Task - task = db.query(Task).filter(Task.id == task_id).first() - if task: - task.execution_id = child_id - task.status = "running" - db.commit() - logger.info("spawn_and_await: linked task %s to child execution %s", task_id, child_id) - except Exception: - db.rollback() - logger.exception("spawn_and_await: failed to link task %s", task_id) - # Enqueue child execution on RQ import redis from rq import Queue diff --git a/platform/models/__init__.py b/platform/models/__init__.py index ade47d08..9544930f 100644 --- a/platform/models/__init__.py +++ b/platform/models/__init__.py @@ -26,7 +26,6 @@ from models.code import CodeBlock, CodeBlockVersion, CodeBlockTest, CodeBlockTestRun # noqa: F401 from models.git import GitRepository, GitCommit, GitSyncTask # noqa: F401 from models.conversation import Conversation # noqa: F401 -from models.epic import Epic, Task # noqa: F401 from models.scheduled_job import ScheduledJob # noqa: F401 from models.memory import ( # noqa: F401 MemoryEpisode, diff --git a/platform/models/node.py b/platform/models/node.py index 87a0214e..d7116a93 100644 --- a/platform/models/node.py +++ b/platform/models/node.py @@ -164,14 +164,6 @@ class _WhoamiConfig(BaseComponentConfig): __mapper_args__ = {"polymorphic_identity": "whoami"} -class _EpicToolsConfig(BaseComponentConfig): - __mapper_args__ = {"polymorphic_identity": "epic_tools"} - - -class _TaskToolsConfig(BaseComponentConfig): - __mapper_args__ = {"polymorphic_identity": "task_tools"} - - class _SpawnAndAwaitConfig(BaseComponentConfig): __mapper_args__ = {"polymorphic_identity": "spawn_and_await"} @@ -280,8 +272,6 @@ class _ReplyChatConfig(BaseComponentConfig): "run_command": ToolComponentConfig, "platform_api": ToolComponentConfig, "whoami": ToolComponentConfig, - "epic_tools": _EpicToolsConfig, - "task_tools": _TaskToolsConfig, "spawn_and_await": _SpawnAndAwaitConfig, "workflow_create": _WorkflowCreateConfig, "workflow_discover": _WorkflowDiscoverConfig, diff --git a/platform/schemas/node.py b/platform/schemas/node.py index 2667c43b..d53ba7c3 100644 --- a/platform/schemas/node.py +++ b/platform/schemas/node.py @@ -17,8 +17,6 @@ "get_totp_code", "platform_api", "whoami", - "epic_tools", - "task_tools", "spawn_and_await", "workflow_create", "workflow_discover", diff --git a/platform/schemas/node_type_defs.py b/platform/schemas/node_type_defs.py index 85e2a7fa..9bf06e8f 100644 --- a/platform/schemas/node_type_defs.py +++ b/platform/schemas/node_type_defs.py @@ -278,22 +278,6 @@ outputs=[PortDefinition(name="totp_code", data_type=DataType.STRING, description="JSON with username and current TOTP code")], )) -register_node_type(NodeTypeSpec( - component_type="epic_tools", - display_name="Epic Tools", - description="Create, query, update, and search epics for task delegation", - category="agent", - outputs=[PortDefinition(name="result", data_type=DataType.STRING, description="JSON result from epic operations")], -)) - -register_node_type(NodeTypeSpec( - component_type="task_tools", - display_name="Task Tools", - description="Create, list, update, and cancel tasks within epics", - category="agent", - outputs=[PortDefinition(name="result", data_type=DataType.STRING, description="JSON result from task operations")], -)) - register_node_type(NodeTypeSpec( component_type="spawn_and_await", display_name="Spawn & Await", diff --git a/platform/services/builder.py b/platform/services/builder.py index edfc3132..dd0a8545 100644 --- a/platform/services/builder.py +++ b/platform/services/builder.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -SUB_COMPONENT_TYPES = {"ai_model", "run_command", "output_parser", "memory_read", "memory_write", "code_execute", "platform_api", "whoami", "epic_tools", "task_tools", "spawn_and_await", "workflow_create", "workflow_discover", "skill"} +SUB_COMPONENT_TYPES = {"ai_model", "run_command", "output_parser", "memory_read", "memory_write", "code_execute", "platform_api", "whoami", "spawn_and_await", "workflow_create", "workflow_discover", "skill"} def _reachable_node_ids( diff --git a/platform/services/dsl_compiler.py b/platform/services/dsl_compiler.py index c36659df..db348778 100644 --- a/platform/services/dsl_compiler.py +++ b/platform/services/dsl_compiler.py @@ -72,8 +72,6 @@ "run_command": "run_command", "platform_api": "platform_api", "whoami": "whoami", - "epic_tools": "epic_tools", - "task_tools": "task_tools", "spawn_and_await": "spawn_and_await", "workflow_create": "workflow_create", "workflow_discover": "workflow_discover", diff --git a/platform/services/orchestrator.py b/platform/services/orchestrator.py index 88f2db16..a1b54221 100644 --- a/platform/services/orchestrator.py +++ b/platform/services/orchestrator.py @@ -449,19 +449,6 @@ def execute_node_job(execution_id: str, node_id: str, retry_count: int = 0) -> N _cleanup_redis(execution_id) return - # Budget enforcement: check epic budget before executing node - budget_error = _check_budget(execution_id, state, db) - if budget_error: - execution.status = "failed" - execution.error_message = budget_error[:2000] - execution.completed_at = datetime.now(timezone.utc) - _persist_execution_costs(execution, state) - db.commit() - _sync_task_costs(execution_id, db) - _publish_event(execution_id, "execution_failed", {"error": budget_error[:500]}, workflow_slug=slug) - _cleanup_redis(execution_id) - return - _node_meta = _get_node_meta(node_info) _publish_event(execution_id, "node_status", {"node_id": node_id, "status": NodeStatus.RUNNING.value, **_node_meta}, workflow_slug=slug) @@ -627,7 +614,6 @@ def execute_node_job(execution_id: str, node_id: str, retry_count: int = 0) -> N _persist_execution_costs(execution, load_state(execution_id)) db.commit() _clear_stale_checkpoints(execution_id, db) - _sync_task_costs(execution_id, db) _publish_event(execution_id, "execution_failed", {"error": error_msg[:500]}, workflow_slug=slug) _complete_episode( execution_id=execution_id, @@ -810,8 +796,7 @@ def execute_node_job(execution_id: str, node_id: str, retry_count: int = 0) -> N logger.exception("Failed to persist execution costs for %s", execution_id) db.commit() _clear_stale_checkpoints(execution_id, db) - _sync_task_costs(execution_id, db) - except Exception: + except Exception: logger.exception( "Failed to persist failure status for execution %s", execution_id ) @@ -1468,45 +1453,6 @@ def _persist_execution_costs(execution, state: dict) -> None: execution.llm_calls = exec_usage.get("llm_calls", 0) -def _check_budget(execution_id: str, state: dict, db: Session) -> str | None: - """Check if the execution's epic budget has been exceeded. - - Returns an error message string if budget is exceeded, None otherwise. - """ - try: - from models.epic import Task - - task = db.query(Task).filter(Task.execution_id == execution_id).first() - if not task or not task.epic: - return None - - epic = task.epic - exec_usage = state.get("_execution_token_usage", {}) - current_tokens = exec_usage.get("total_tokens", 0) - current_usd = exec_usage.get("cost_usd", 0.0) - - if epic.budget_tokens is not None: - total_tokens = (epic.spent_tokens or 0) + current_tokens - if total_tokens > epic.budget_tokens: - return ( - f"Epic budget exceeded: {total_tokens} tokens used " - f"(budget: {epic.budget_tokens} tokens)" - ) - - if epic.budget_usd is not None: - total_usd = float(epic.spent_usd or 0) + current_usd - if total_usd > float(epic.budget_usd): - return ( - f"Epic budget exceeded: ${total_usd:.4f} spent " - f"(budget: ${float(epic.budget_usd):.4f})" - ) - - except Exception: - logger.exception("Budget check failed for execution %s", execution_id) - - return None - - # ── Helpers ──────────────────────────────────────────────────────────────────── @@ -1648,95 +1594,6 @@ def _get_workflow_slug(execution_id: str, db: Session | None = None) -> str | No return None -def _sync_task_costs(execution_id: str, db: Session) -> None: - """Sync Task status and duration from a completed/failed execution.""" - try: - from models.epic import Task - from models.execution import WorkflowExecution - from api.epic_helpers import sync_epic_progress - - task = db.query(Task).filter(Task.execution_id == execution_id).first() - if not task: - return - - execution = ( - db.query(WorkflowExecution) - .filter(WorkflowExecution.execution_id == execution_id) - .first() - ) - if not execution: - return - - if execution.status not in ("completed", "failed"): - return - - if execution.status == "completed": - task.status = "completed" - if execution.final_output: - summary = str(execution.final_output) - task.result_summary = summary[:500] - elif execution.status == "failed": - task.status = "failed" - task.error_message = (execution.error_message or "")[:500] - - if execution.started_at and execution.completed_at: - # Normalise both to naive UTC to avoid mixed-tz subtraction errors - sa = execution.started_at.replace(tzinfo=None) if execution.started_at.tzinfo else execution.started_at - ca = execution.completed_at.replace(tzinfo=None) if execution.completed_at.tzinfo else execution.completed_at - delta = ca - sa - task.duration_ms = int(delta.total_seconds() * 1000) - - task.completed_at = execution.completed_at - - # Sync cost fields from execution to task - task.actual_tokens = execution.total_tokens or 0 - task.actual_usd = float(execution.total_cost_usd or 0) - task.llm_calls = execution.llm_calls or 0 - # tool_invocations from accumulated state - try: - exec_state = load_state(execution_id) - exec_usage = exec_state.get("_execution_token_usage", {}) - task.tool_invocations = exec_usage.get("tool_invocations", 0) - except Exception: - logger.exception("Failed to load tool_invocations for task %s", task.id) - - db.commit() - logger.info("Synced task %s costs from execution %s (status=%s)", task.id, execution_id, task.status) - - # Roll up costs to epic (recalculate from all tasks to avoid double-counting) - if task.epic: - epic = task.epic - from sqlalchemy import func as sa_func - from models.epic import Task as _Task - totals = db.query( - sa_func.coalesce(sa_func.sum(_Task.actual_tokens), 0), - sa_func.coalesce(sa_func.sum(_Task.actual_usd), 0), - ).filter(_Task.epic_id == epic.id).one() - epic.spent_tokens = int(totals[0]) - epic.spent_usd = float(totals[1]) - db.commit() - - # Auto-unblock dependent tasks when this task is completed - if task.status == "completed": - try: - from api.epic_helpers import resolve_blocked_tasks - resolve_blocked_tasks(task.id, db) - db.commit() - except Exception: - logger.exception("Failed to resolve blocked tasks for task %s", task.id) - - # Sync epic progress counters (best-effort — task status already committed) - if task.epic: - try: - sync_epic_progress(task.epic, db) - db.commit() - except Exception: - logger.exception("Failed to sync epic progress for task %s", task.id) - - except Exception: - logger.exception("Failed to sync task costs for execution %s", execution_id) - - def _clear_stale_checkpoints(execution_id: str, db: Session) -> None: """Clear SqliteSaver checkpoints for agent threads in a failed/cancelled execution. diff --git a/platform/services/topology.py b/platform/services/topology.py index be59da23..1466d4ea 100644 --- a/platform/services/topology.py +++ b/platform/services/topology.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -SUB_COMPONENT_TYPES = {"ai_model", "run_command", "output_parser", "memory_read", "memory_write", "code_execute", "platform_api", "whoami", "epic_tools", "task_tools", "spawn_and_await", "workflow_create", "workflow_discover", "scheduler_tools", "system_health", "skill"} +SUB_COMPONENT_TYPES = {"ai_model", "run_command", "output_parser", "memory_read", "memory_write", "code_execute", "platform_api", "whoami", "spawn_and_await", "workflow_create", "workflow_discover", "scheduler_tools", "system_health", "skill"} @dataclass diff --git a/platform/test_import.db b/platform/test_import.db new file mode 100644 index 0000000000000000000000000000000000000000..0de02ecf623141161c863ee065d9f7dd83cbe849 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WYDWB Date: Sat, 21 Mar 2026 17:07:43 +1030 Subject: [PATCH 3/6] refactor: remove epic/task references from frontend - App.tsx: remove EpicsPage/EpicDetailPage imports and routes - AppLayout.tsx: remove Epics sidebar nav link - types/models.ts: remove Epic, Task types and ComponentType entries - WorkflowCanvas.tsx: remove epic_tools/task_tools from colors, icons, isTool, isSubComponent - NodePalette.tsx: remove from categories and icons - NodeDetailsPanel.tsx: remove from SUB_TYPES - wsManager.ts: remove Epic import and epic/task event handlers Co-Authored-By: Claude Opus 4.6 --- platform/frontend/src/App.tsx | 4 --- .../src/components/layout/AppLayout.tsx | 2 -- .../workflows/components/NodeDetailsPanel.tsx | 2 +- .../workflows/components/NodePalette.tsx | 6 ++-- .../workflows/components/WorkflowCanvas.tsx | 10 +++--- platform/frontend/src/lib/wsManager.ts | 28 +-------------- platform/frontend/src/types/models.ts | 36 ------------------- 7 files changed, 8 insertions(+), 80 deletions(-) diff --git a/platform/frontend/src/App.tsx b/platform/frontend/src/App.tsx index 9449cb4b..121c870c 100644 --- a/platform/frontend/src/App.tsx +++ b/platform/frontend/src/App.tsx @@ -12,8 +12,6 @@ import ExecutionsPage from "@/features/executions/ExecutionsPage" import ExecutionDetailPage from "@/features/executions/ExecutionDetailPage" import SettingsPage from "@/features/settings/SettingsPage" import MemoriesPage from "@/features/memories/MemoriesPage" -import EpicsPage from "@/features/epics/EpicsPage" -import EpicDetailPage from "@/features/epics/EpicDetailPage" import WorkspacesPage from "@/features/workspaces/WorkspacesPage" import WorkspaceDetailPage from "@/features/workspaces/WorkspaceDetailPage" @@ -37,8 +35,6 @@ export default function App() { } /> } /> } /> - } /> - } /> } /> } /> diff --git a/platform/frontend/src/components/layout/AppLayout.tsx b/platform/frontend/src/components/layout/AppLayout.tsx index d606a2bc..26c69af7 100644 --- a/platform/frontend/src/components/layout/AppLayout.tsx +++ b/platform/frontend/src/components/layout/AppLayout.tsx @@ -11,7 +11,6 @@ import { FolderOpen, Activity, Brain, - ListTodo, LogOut, Workflow, ChevronLeft, @@ -25,7 +24,6 @@ const navItems = [ { to: "/credentials", icon: KeyRound, label: "Credentials" }, { to: "/workspaces", icon: FolderOpen, label: "Workspaces" }, { to: "/executions", icon: Activity, label: "Executions" }, - { to: "/epics", icon: ListTodo, label: "Epics" }, { to: "/memories", icon: Brain, label: "Memories" }, ] diff --git a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx index 0d661f1d..f33d54c1 100644 --- a/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx +++ b/platform/frontend/src/features/workflows/components/NodeDetailsPanel.tsx @@ -222,7 +222,7 @@ function NodeConfigPanel({ slug, node, workflow, onClose }: Props) { // Compute all upstream ancestor nodes for switch/filter/loop (BFS backward through data edges) const upstreamNodes = useMemo(() => { if (!workflow) return [] - const SUB_TYPES = new Set(["ai_model", "run_command", "output_parser", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "epic_tools", "task_tools", "spawn_and_await"]) + const SUB_TYPES = new Set(["ai_model", "run_command", "output_parser", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "spawn_and_await"]) const visited = new Set() const queue = [node.node_id] while (queue.length > 0) { diff --git a/platform/frontend/src/features/workflows/components/NodePalette.tsx b/platform/frontend/src/features/workflows/components/NodePalette.tsx index 965d44d9..45bfcd75 100644 --- a/platform/frontend/src/features/workflows/components/NodePalette.tsx +++ b/platform/frontend/src/features/workflows/components/NodePalette.tsx @@ -10,7 +10,7 @@ import { Repeat, Pause, Merge, Filter, Code, UserCheck, ShieldAlert, FileText, CheckSquare, FileCheck, Database, DatabaseZap, UserSearch, UserPlus, Plug, Fingerprint, KeyRound, - ClipboardList, ListChecks, Rocket, PencilRuler, CalendarClock, HeartPulse, + Rocket, PencilRuler, CalendarClock, HeartPulse, type LucideIcon, } from "lucide-react" @@ -33,8 +33,6 @@ const ICONS: Record = { get_totp_code: KeyRound, platform_api: Plug, whoami: Fingerprint, - epic_tools: ClipboardList, - task_tools: ListChecks, spawn_and_await: Rocket, workflow_create: PencilRuler, workflow_discover: Compass, @@ -63,7 +61,7 @@ const NODE_CATEGORIES: { label: string; types: ComponentType[] }[] = [ { label: "AI", types: ["ai_model", "agent", "deep_agent", "skill"] }, { label: "Routing", types: ["categorizer", "extractor"] }, { label: "Memory", types: ["memory_read", "memory_write", "identify_user"] }, - { label: "Agent", types: ["whoami", "create_agent_user", "get_totp_code", "platform_api", "epic_tools", "task_tools", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create"] }, + { label: "Agent", types: ["whoami", "create_agent_user", "get_totp_code", "platform_api", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create"] }, { label: "Tools", types: ["run_command", "workflow_discover", "validate_gherkin", "validate_topology"] }, { label: "Logic", types: ["switch", "loop", "filter", "merge", "wait"] }, { label: "Output", types: ["reply_chat"] }, diff --git a/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx b/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx index f8b5ac1e..4d950913 100644 --- a/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx +++ b/platform/frontend/src/features/workflows/components/WorkflowCanvas.tsx @@ -10,7 +10,7 @@ import { faPlay, faBug, faComments, faCircleNotch, faCircleCheck, faCircleXmark, faMinus, faTerminal, faUserPlus, faPlug, faFingerprint, faDatabase, faFloppyDisk, faIdCard, - faClipboardList, faListCheck, faRocket, faPenRuler, faCompass, faBrain, faGraduationCap, + faRocket, faPenRuler, faCompass, faBrain, faGraduationCap, faSquareCheck, faFileCircleCheck, } from "@fortawesome/free-solid-svg-icons" import { faTelegram } from "@fortawesome/free-brands-svg-icons" @@ -69,8 +69,6 @@ const COMPONENT_COLORS: Record = { create_agent_user: "#14b8a6", platform_api: "#14b8a6", whoami: "#14b8a6", - epic_tools: "#14b8a6", - task_tools: "#14b8a6", spawn_and_await: "#14b8a6", workflow_create: "#14b8a6", workflow_discover: "#10b981", @@ -104,7 +102,7 @@ const COMPONENT_ICONS: Record = { ai_model: faMicrochip, agent: faRobot, deep_agent: faBrain, categorizer: faTags, router: faCodeBranch, switch: faCodeBranch, extractor: faMagnifyingGlassChart, run_command: faTerminal, - create_agent_user: faUserPlus, platform_api: faPlug, whoami: faFingerprint, epic_tools: faClipboardList, task_tools: faListCheck, scheduler_tools: faCalendarDays, system_health: faHeartPulse, spawn_and_await: faRocket, workflow_create: faPenRuler, workflow_discover: faCompass, + create_agent_user: faUserPlus, platform_api: faPlug, whoami: faFingerprint,scheduler_tools: faCalendarDays, system_health: faHeartPulse, spawn_and_await: faRocket, workflow_create: faPenRuler, workflow_discover: faCompass, validate_gherkin: faSquareCheck, validate_topology: faFileCircleCheck, workflow: faSitemap, code: faCode, error_handler: faTriangleExclamation, @@ -132,8 +130,8 @@ function WorkflowNodeComponent({ data, selected }: { data: { label: string; comp const isTrigger = data.componentType.startsWith("trigger_") const isLoop = data.componentType === "loop" const isFixedWidth = ["router", "categorizer", "agent", "deep_agent", "extractor", "switch", "loop"].includes(data.componentType) - const isTool = ["run_command", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "epic_tools", "task_tools", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "validate_gherkin", "validate_topology"].includes(data.componentType) - const isSubComponent = ["ai_model", "run_command", "output_parser", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "epic_tools", "task_tools", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "skill", "validate_gherkin", "validate_topology"].includes(data.componentType) + const isTool = ["run_command", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "validate_gherkin", "validate_topology"].includes(data.componentType) + const isSubComponent = ["ai_model", "run_command", "output_parser", "memory_read", "memory_write", "create_agent_user", "platform_api", "whoami", "scheduler_tools", "system_health", "spawn_and_await", "workflow_create", "workflow_discover", "skill", "validate_gherkin", "validate_topology"].includes(data.componentType) const isAiModel = data.componentType === "ai_model" const hasModel = ["agent", "deep_agent", "categorizer", "router", "extractor"].includes(data.componentType) const hasTools = ["agent", "deep_agent"].includes(data.componentType) diff --git a/platform/frontend/src/lib/wsManager.ts b/platform/frontend/src/lib/wsManager.ts index 61f75815..1f8dbef2 100644 --- a/platform/frontend/src/lib/wsManager.ts +++ b/platform/frontend/src/lib/wsManager.ts @@ -1,5 +1,5 @@ import type { QueryClient } from "@tanstack/react-query" -import type { WorkflowDetail, WorkflowNode, WorkflowEdge, Epic } from "@/types/models" +import type { WorkflowDetail, WorkflowNode, WorkflowEdge } from "@/types/models" interface WsMessage { type: string @@ -177,32 +177,6 @@ class WebSocketManager { } break } - case "epic_updated": { - const epicMatch = channel.match(/^epic:(.+)$/) - if (epicMatch?.[1] && msg.data) { - qc.setQueryData(["epics", epicMatch[1]], msg.data as unknown as Epic) - qc.invalidateQueries({ queryKey: ["epics"] }) - } - break - } - case "epic_deleted": { - qc.invalidateQueries({ queryKey: ["epics"] }) - break - } - case "task_created": - case "task_updated": - case "task_deleted": - case "tasks_deleted": { - const epicMatch = channel.match(/^epic:(.+)$/) - const epicId = epicMatch?.[1] - if (epicId) { - qc.invalidateQueries({ queryKey: ["epics", epicId, "tasks"] }) - qc.invalidateQueries({ queryKey: ["epics", epicId] }) - qc.invalidateQueries({ queryKey: ["epics"] }) - qc.invalidateQueries({ queryKey: ["tasks"] }) - } - break - } } } diff --git a/platform/frontend/src/types/models.ts b/platform/frontend/src/types/models.ts index bf5b59c6..a9aadb30 100644 --- a/platform/frontend/src/types/models.ts +++ b/platform/frontend/src/types/models.ts @@ -12,8 +12,6 @@ export type ComponentType = | "get_totp_code" | "platform_api" | "whoami" - | "epic_tools" - | "task_tools" | "scheduler_tools" | "system_health" | "spawn_and_await" @@ -123,37 +121,3 @@ export interface FilterRule { id: string; field: string; operator: string; value // Checkpoints export interface Checkpoint { thread_id: string; checkpoint_ns: string; checkpoint_id: string; parent_checkpoint_id: string | null; step: number | null; source: string | null; blob_size: number } -// Epic status + types -export type EpicStatus = "planning" | "active" | "paused" | "completed" | "failed" | "cancelled" -export type TaskStatus = "pending" | "blocked" | "running" | "completed" | "failed" | "cancelled" - -export interface Epic { - id: string; title: string; description: string; tags: string[] - created_by_node_id: string | null; workflow_id: number | null; user_profile_id: number | null - status: EpicStatus; priority: number - budget_tokens: number | null; budget_usd: number | null - spent_tokens: number; spent_usd: number - agent_overhead_tokens: number; agent_overhead_usd: number - total_tasks: number; completed_tasks: number; failed_tasks: number - created_at: string | null; updated_at: string | null; completed_at: string | null - result_summary: string | null -} - -export interface Task { - id: string; epic_id: string; title: string; description: string; tags: string[] - created_by_node_id: string | null; status: TaskStatus; priority: number - workflow_id: number | null; workflow_slug: string | null - execution_id: string | null; workflow_source: string - depends_on: string[]; requirements: Record | null - estimated_tokens: number | null; actual_tokens: number; actual_usd: number - llm_calls: number; tool_invocations: number; duration_ms: number - created_at: string | null; updated_at: string | null - started_at: string | null; completed_at: string | null - result_summary: string | null; error_message: string | null - retry_count: number; max_retries: number; notes: unknown[] -} - -export interface EpicCreate { title: string; description?: string; tags?: string[]; priority?: number; budget_tokens?: number | null; budget_usd?: number | null; workflow_id?: number | null } -export interface EpicUpdate { title?: string; description?: string; tags?: string[]; status?: EpicStatus; priority?: number; budget_tokens?: number | null; budget_usd?: number | null; result_summary?: string | null } -export interface TaskCreate { epic_id: string; title: string; description?: string; tags?: string[]; depends_on?: string[]; priority?: number; workflow_slug?: string | null; estimated_tokens?: number | null; max_retries?: number; requirements?: Record | null } -export interface TaskUpdate { title?: string; description?: string; tags?: string[]; status?: TaskStatus; priority?: number; workflow_slug?: string | null; execution_id?: string | null; result_summary?: string | null; error_message?: string | null; notes?: unknown[] } From a3996d2255e661f0dc0da42ca8a837e6cbb48a30 Mon Sep 17 00:00:00 2001 From: aka Date: Sat, 21 Mar 2026 17:08:43 +1030 Subject: [PATCH 4/6] chore: remove epic_tools/task_tools from test fixtures - Remove from 06_error_routing topology.yaml - Remove from workflow_generator fixture.json system prompts Co-Authored-By: Claude Opus 4.6 --- .../dsl_fixtures/06_error_routing/Step1/topology.yaml | 3 --- .../tests/dsl_fixtures/workflow_generator/fixture.json | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/platform/tests/dsl_fixtures/06_error_routing/Step1/topology.yaml b/platform/tests/dsl_fixtures/06_error_routing/Step1/topology.yaml index 82ac24c5..d79b23e5 100644 --- a/platform/tests/dsl_fixtures/06_error_routing/Step1/topology.yaml +++ b/platform/tests/dsl_fixtures/06_error_routing/Step1/topology.yaml @@ -31,9 +31,6 @@ steps: - type: agent id: handle_error - tools: - - type: epic_tools - - type: task_tools - type: agent id: handle_unknown diff --git a/platform/tests/dsl_fixtures/workflow_generator/fixture.json b/platform/tests/dsl_fixtures/workflow_generator/fixture.json index 9d18c2b9..979e7dcd 100644 --- a/platform/tests/dsl_fixtures/workflow_generator/fixture.json +++ b/platform/tests/dsl_fixtures/workflow_generator/fixture.json @@ -187,7 +187,7 @@ "position_x": 170, "position_y": -264, "config": { - "system_prompt": "[agent]\nname = \"Gherkin Agent\"\nrole = \"\"\"\nYou are the Gherkin Agent. You receive a requirements document from the Scribe\nand produce a Gherkin behavior specification for the described workflow.\nYour output is the execution contract for the Verifier Agent \u2014 a live LLM-driven\nworkflow that will send each scenario as a real chat request to the workflow trigger\nand match actual responses against your Then clauses. Write accordingly.\n\"\"\"\n\n[context]\n# The trigger IS the entry point \u2014 do NOT create scenarios for \"receiving\" a message\ntrigger_is_entry_point = true\n\n# reply_chat is a terminal node that sends a message back to the chat caller\nreply_chat_is_terminal = true\n\n# Scenarios test end-to-end observable behaviour via the trigger endpoint\n# Node IDs are used for traceability only \u2014 assertions are always on reply_chat output\ntest_observable_behaviour_only = true\n\n[rules]\n# Every processing step from the requirements MUST appear in at least one scenario\nfull_step_coverage = true\n\n# Use the exact node IDs from the requirements document\nuse_exact_node_ids = true\n\n# Use standard Given/When/Then format\nformat = \"Given/When/Then\"\n\n# Every scenario must be independently executable\n# No scenario may depend on state or output from a previous scenario\nscenarios_are_stateless = true\n\n# Every Then clause must assert a specific, verifiable value or condition\n# The Verifier matches Then clauses directly against raw response strings \u2014 no inference\nverifiable_assertions_only = true\n\n[rules.given_when]\n# Given defines the exact input precondition \u2014 use concrete literal values\n# When defines the action \u2014 name the exact node_id performing it\n# Given + When must compose into an unambiguous natural language chat message\n# The Verifier derives the actual message to send from these two clauses verbatim\nmust_compose_to_sendable_message = true\n\n[rules.then]\n# Then clauses must be matchable against a raw response string\n# Specify exact strings, HTTP status codes, or unambiguous structural conditions\nmust_be_raw_response_matchable = true\n\n[rules.error_scenario_requirements]\n# Error scenarios must specify the exact HTTP status code returned\nrequire_http_status_code = true\n\n# Error scenarios must specify the exact error message or response body shape\nrequire_error_message = true\n\n# Error scenarios must state whether the node retries or fails immediately\nrequire_retry_behaviour = true\n\n[rules.input_boundary_requirements]\n# Every workflow that accepts string input MUST include these boundary scenarios\n[[rules.input_boundary_requirements.cases]]\ncase = \"empty_string\"\ninput = '\"\"'\nreason = \"empty input must have a defined rejection or fallback behaviour\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"whitespace_only\"\ninput = '\" \"'\nreason = \"whitespace-only must not be treated as valid content\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"null_or_missing\"\ninput = \"null / field absent\"\nreason = \"missing input must return HTTP 400 with a specific error message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"oversized_payload\"\ninput = \"string exceeding max token or byte limit\"\nreason = \"must return HTTP 413 or equivalent with a size exceeded message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"malformed_encoding\"\ninput = \"non-UTF-8 or broken JSON\"\nreason = \"must return HTTP 400 with a parse error message\"\n\n[[rules.then_antipatterns]]\npattern = \"should generate an? (appropriate|correct|valid|concise) .*\"\nreason = \"adjective without qualification is untestable \u2014 specify exact value or format\"\n\n[[rules.then_antipatterns]]\npattern = \"should handle .* appropriately\"\nreason = \"unverifiable \u2014 specify the exact output or behaviour\"\n\n[[rules.then_antipatterns]]\npattern = \"should send an? (appropriate|correct|valid) response\"\nreason = \"must specify the exact response value or structure\"\n\n[[rules.then_antipatterns]]\npattern = \"should fail gracefully\"\nreason = \"specify the exact HTTP code, error message, and response shape\"\n\n[[rules.then_antipatterns]]\npattern = \"should return an error\"\nreason = \"specify HTTP 4xx/5xx code, error field value, and whether retry is expected\"\n\n[[rules.then_antipatterns]]\npattern = \"should contain relevant information\"\nreason = \"Verifier cannot match 'relevant' \u2014 specify exact field or substring\"\n\n[[rules.then_antipatterns]]\npattern = \"should respond within .* seconds\"\nreason = \"Verifier is not a latency tester \u2014 remove timing assertions\"\n\n[[rules.then_antipatterns]]\npattern = \"should process the (input|message)\"\nreason = \"processing is not observable \u2014 assert on the output only\"\n\n[input]\n# Receives the full requirements document from the Scribe\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\n# Emit ONLY a single fenced code block \u2014 no preamble, no explanation\nformat = \"single fenced code block, label: behavior.feature\"\n\n[output.structure]\n# Feature name derived from workflow PURPOSE in the requirements doc\nfeature = \"\"\n\n# Required scenario types per processing step:\n# 1. happy path \u2014 concrete input, exact expected output\n# 2. error case \u2014 specific failure condition, HTTP code, exact error message\n# 3. input boundary \u2014 one scenario per boundary case for every string-accepting node\n[[output.structure.scenarios]]\ntype = \"happy_path\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When processes the message\n Then should return \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"error_case\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When encounters \n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"input_boundary\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \n When attempts to process the input\n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[validation]\n# Before returning your output, you MUST call the validate_gherkin tool\n# to check your Gherkin spec for syntax errors and lint warnings.\n# Fix any issues the tool reports before returning your final output.\nrequire_validation = true\ntool_name = \"validate_gherkin\"\n\n[validation.workflow]\n# 1. Generate your Gherkin spec\n# 2. Call validate_gherkin with the raw spec text (no fences)\n# 3. If valid=false or lint_errors exist, fix the issues and re-validate\n# 4. Only return the spec once it passes validation\nmust_pass_before_output = true\n", + "system_prompt": "[agent]\nname = \"Gherkin Agent\"\nrole = \"\"\"\nYou are the Gherkin Agent. You receive a requirements document from the Scribe\nand produce a Gherkin behavior specification for the described workflow.\nYour output is the execution contract for the Verifier Agent — a live LLM-driven\nworkflow that will send each scenario as a real chat request to the workflow trigger\nand match actual responses against your Then clauses. Write accordingly.\n\"\"\"\n\n[context]\n# The trigger IS the entry point — do NOT create scenarios for \"receiving\" a message\ntrigger_is_entry_point = true\n\n# reply_chat is a terminal node that sends a message back to the chat caller\nreply_chat_is_terminal = true\n\n# Scenarios test end-to-end observable behaviour via the trigger endpoint\n# Node IDs are used for traceability only — assertions are always on reply_chat output\ntest_observable_behaviour_only = true\n\n[rules]\n# Every processing step from the requirements MUST appear in at least one scenario\nfull_step_coverage = true\n\n# Use the exact node IDs from the requirements document\nuse_exact_node_ids = true\n\n# Use standard Given/When/Then format\nformat = \"Given/When/Then\"\n\n# Every scenario must be independently executable\n# No scenario may depend on state or output from a previous scenario\nscenarios_are_stateless = true\n\n# Every Then clause must assert a specific, verifiable value or condition\n# The Verifier matches Then clauses directly against raw response strings — no inference\nverifiable_assertions_only = true\n\n[rules.given_when]\n# Given defines the exact input precondition — use concrete literal values\n# When defines the action — name the exact node_id performing it\n# Given + When must compose into an unambiguous natural language chat message\n# The Verifier derives the actual message to send from these two clauses verbatim\nmust_compose_to_sendable_message = true\n\n[rules.then]\n# Then clauses must be matchable against a raw response string\n# Specify exact strings, HTTP status codes, or unambiguous structural conditions\nmust_be_raw_response_matchable = true\n\n[rules.error_scenario_requirements]\n# Error scenarios must specify the exact HTTP status code returned\nrequire_http_status_code = true\n\n# Error scenarios must specify the exact error message or response body shape\nrequire_error_message = true\n\n# Error scenarios must state whether the node retries or fails immediately\nrequire_retry_behaviour = true\n\n[rules.input_boundary_requirements]\n# Every workflow that accepts string input MUST include these boundary scenarios\n[[rules.input_boundary_requirements.cases]]\ncase = \"empty_string\"\ninput = '\"\"'\nreason = \"empty input must have a defined rejection or fallback behaviour\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"whitespace_only\"\ninput = '\" \"'\nreason = \"whitespace-only must not be treated as valid content\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"null_or_missing\"\ninput = \"null / field absent\"\nreason = \"missing input must return HTTP 400 with a specific error message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"oversized_payload\"\ninput = \"string exceeding max token or byte limit\"\nreason = \"must return HTTP 413 or equivalent with a size exceeded message\"\n\n[[rules.input_boundary_requirements.cases]]\ncase = \"malformed_encoding\"\ninput = \"non-UTF-8 or broken JSON\"\nreason = \"must return HTTP 400 with a parse error message\"\n\n[[rules.then_antipatterns]]\npattern = \"should generate an? (appropriate|correct|valid|concise) .*\"\nreason = \"adjective without qualification is untestable — specify exact value or format\"\n\n[[rules.then_antipatterns]]\npattern = \"should handle .* appropriately\"\nreason = \"unverifiable — specify the exact output or behaviour\"\n\n[[rules.then_antipatterns]]\npattern = \"should send an? (appropriate|correct|valid) response\"\nreason = \"must specify the exact response value or structure\"\n\n[[rules.then_antipatterns]]\npattern = \"should fail gracefully\"\nreason = \"specify the exact HTTP code, error message, and response shape\"\n\n[[rules.then_antipatterns]]\npattern = \"should return an error\"\nreason = \"specify HTTP 4xx/5xx code, error field value, and whether retry is expected\"\n\n[[rules.then_antipatterns]]\npattern = \"should contain relevant information\"\nreason = \"Verifier cannot match 'relevant' — specify exact field or substring\"\n\n[[rules.then_antipatterns]]\npattern = \"should respond within .* seconds\"\nreason = \"Verifier is not a latency tester — remove timing assertions\"\n\n[[rules.then_antipatterns]]\npattern = \"should process the (input|message)\"\nreason = \"processing is not observable — assert on the output only\"\n\n[input]\n# Receives the full requirements document from the Scribe\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\n# Emit ONLY a single fenced code block — no preamble, no explanation\nformat = \"single fenced code block, label: behavior.feature\"\n\n[output.structure]\n# Feature name derived from workflow PURPOSE in the requirements doc\nfeature = \"\"\n\n# Required scenario types per processing step:\n# 1. happy path — concrete input, exact expected output\n# 2. error case — specific failure condition, HTTP code, exact error message\n# 3. input boundary — one scenario per boundary case for every string-accepting node\n[[output.structure.scenarios]]\ntype = \"happy_path\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When processes the message\n Then should return \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"error_case\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \"\"\n When encounters \n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[[output.structure.scenarios]]\ntype = \"input_boundary\"\ntemplate = \"\"\"\nScenario: \n Given a chat message \n When attempts to process the input\n Then should return HTTP with error \"\"\n And reply_chat should send \"\" back to the chat caller\n\"\"\"\n\n[validation]\n# Before returning your output, you MUST call the validate_gherkin tool\n# to check your Gherkin spec for syntax errors and lint warnings.\n# Fix any issues the tool reports before returning your final output.\nrequire_validation = true\ntool_name = \"validate_gherkin\"\n\n[validation.workflow]\n# 1. Generate your Gherkin spec\n# 2. Call validate_gherkin with the raw spec text (no fences)\n# 3. If valid=false or lint_errors exist, fix the issues and re-validate\n# 4. Only return the spec once it passes validation\nmust_pass_before_output = true\n", "extra_config": { "input_template": "{{ scribe.output }}", "conversation_memory": false, @@ -378,7 +378,7 @@ "position_x": 1482, "position_y": -141, "config": { - "system_prompt": "\u2705 Verification passed! Here is what I designed:\n\n**Topology:**\n{{ topology_agent.output }}\n\n---\n\n**Behavior Spec:**\n{{ gherkin_agent.output }}", + "system_prompt": "✅ Verification passed! Here is what I designed:\n\n**Topology:**\n{{ topology_agent.output }}\n\n---\n\n**Behavior Spec:**\n{{ gherkin_agent.output }}", "extra_config": {}, "llm_credential_id": null, "model_name": "", @@ -413,7 +413,7 @@ "position_x": -840, "position_y": -45, "config": { - "system_prompt": "[agent]\nname = \"Scribe\"\nrole = \"\"\"\nYou are the Scribe. Your job is to turn the user's request into a structured requirements document that feeds a validation gate, a Gherkin test scenario agent, and a topology/DSL builder agent.\n\"\"\"\n\n[rules]\n# Always produce the requirements document immediately, even if the request is incomplete\nalways_produce_document = true\n\n# Do not ask the user directly \u2014 the validation layer owns the clarification loop\nask_user_directly = false\nclarification_owner = \"validation layer\"\n\n# The trigger (chat, schedule, manual, telegram) IS the entry point \u2014 do NOT list a separate \"receive\" or \"capture\" step\ntrigger_is_entry_point = true\n\n# STEPS should only include PROCESSING nodes\nsteps_processing_only = true\n\n# Make reasonable assumptions for anything unspecified \u2014 record them in ASSUMPTIONS\n# If anything is genuinely unresolvable without user input, record it in OPEN_QUESTIONS\nrecord_assumptions = true\nrecord_open_questions = true\n\n[node_catalog]\n\n# Triggers (entry points \u2014 every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients via msg-gateway generic adapter\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM to reason, classify, generate, extract)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with built-in task planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on input\"\n\n# Logic Nodes (no LLM \u2014 deterministic control flow)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Merge outputs from multiple branches into one (fan-in barrier)\"\nswitch = \"Routes to different branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval before continuing\"\n\n# Output Nodes (terminal \u2014 end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\n\n# security and privileged access\nidentify_user = \"Only relevant as a step before totp, which means whenever there is intention to acquire privileged access to information or actions\"\n\n# Agent Tools (attach to agent/deep_agent via tool edges)\nepic_tools = \"Create, query, update, and search epics for task delegation\"\nget_totp_code = \"Retrieve the current TOTP code for agent identity verification\"\nplatform_api = \"Make authenticated requests to the platform API\"\nscheduler_tools = \"Create, pause, resume, stop, and list scheduled recurring jobs\"\nspawn_and_await = \"Spawn a child workflow and wait for its result inside an agent's reasoning loop\"\nsystem_health = \"Check platform infrastructure health: Redis, RQ workers, queues, stuck executions\"\ntask_tools = \"Create, list, update, and cancel tasks within epics\"\nwhoami = \"Get self-awareness \u2014 workflow, node ID, and how to modify yourself\"\nworkflow_create = \"Create workflows programmatically from a YAML DSL specification\"\nworkflow_discover = \"Search existing workflows by requirements and get reuse recommendations\"\n\n# Sub-components (wired by the builder, not listed in STEPS)\nai_model = \"LLM model configuration \u2014 attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool \u2014 retrieves information from global memory\"\nmemory_write = \"Remember tool \u2014 stores information in global memory\"\noutput_parser = \"Structured output parsing for agent responses\"\nrun_command = \"Execute shell commands\"\nskill = \"SKILL.md behavioral instructions for agents via progressive disclosure\"\n\n[output.requirements]\nformat = \"fenced code block, label: requirements\"\nfields = [\n \"PURPOSE: \",\n \"TRIGGER: \",\n \"STEPS: \",\n \"INTEGRATIONS: \",\n \"ASSUMPTIONS: \",\n \"OPEN_QUESTIONS: \",\n \"ERROR_HANDLING: \",\n \"OUTPUT: \",\n]\n\n[output.mermaid]\nformat = \"fenced code block, label: mermaid\"\ndiagram_type = \"flowchart TD\"\n\n[output.mermaid.rules]\nregular_nodes = \"Square brackets []\"\nswitch_nodes = \"Diamond shapes {}\"\nterminal_nodes = \"Stadium shape (( ))\"\nbranch_labels = true\nparallel_paths = true\n\n[output.order]\n1 = \"requirements block\"\n2 = \"mermaid block\"\n3 = \"closing prompt: 'Does this match what you had in mind? I can adjust before proceeding.'\"\n\n[downstream]\nvalidator = \"Gates on OPEN_QUESTIONS \u2014 empty means pass, non-empty triggers clarification back to user\"\ngherkin = \"Generates test scenario scripts from full requirements document\"\ntopology_dsl = \"Builds workflow DSL from full requirements document\"\n", + "system_prompt": "[agent]\nname = \"Scribe\"\nrole = \"\"\"\nYou are the Scribe. Your job is to turn the user's request into a structured requirements document that feeds a validation gate, a Gherkin test scenario agent, and a topology/DSL builder agent.\n\"\"\"\n\n[rules]\n# Always produce the requirements document immediately, even if the request is incomplete\nalways_produce_document = true\n\n# Do not ask the user directly — the validation layer owns the clarification loop\nask_user_directly = false\nclarification_owner = \"validation layer\"\n\n# The trigger (chat, schedule, manual, telegram) IS the entry point — do NOT list a separate \"receive\" or \"capture\" step\ntrigger_is_entry_point = true\n\n# STEPS should only include PROCESSING nodes\nsteps_processing_only = true\n\n# Make reasonable assumptions for anything unspecified — record them in ASSUMPTIONS\n# If anything is genuinely unresolvable without user input, record it in OPEN_QUESTIONS\nrecord_assumptions = true\nrecord_open_questions = true\n\n[node_catalog]\n\n# Triggers (entry points — every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients via msg-gateway generic adapter\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM to reason, classify, generate, extract)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with built-in task planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on input\"\n\n# Logic Nodes (no LLM — deterministic control flow)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Merge outputs from multiple branches into one (fan-in barrier)\"\nswitch = \"Routes to different branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval before continuing\"\n\n# Output Nodes (terminal — end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\n\n# security and privileged access\nidentify_user = \"Only relevant as a step before totp, which means whenever there is intention to acquire privileged access to information or actions\"\n\n# Agent Tools (attach to agent/deep_agent via tool edges)\nget_totp_code = \"Retrieve the current TOTP code for agent identity verification\"\nplatform_api = \"Make authenticated requests to the platform API\"\nscheduler_tools = \"Create, pause, resume, stop, and list scheduled recurring jobs\"\nspawn_and_await = \"Spawn a child workflow and wait for its result inside an agent's reasoning loop\"\nsystem_health = \"Check platform infrastructure health: Redis, RQ workers, queues, stuck executions\"\nwhoami = \"Get self-awareness — workflow, node ID, and how to modify yourself\"\nworkflow_create = \"Create workflows programmatically from a YAML DSL specification\"\nworkflow_discover = \"Search existing workflows by requirements and get reuse recommendations\"\n\n# Sub-components (wired by the builder, not listed in STEPS)\nai_model = \"LLM model configuration — attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool — retrieves information from global memory\"\nmemory_write = \"Remember tool — stores information in global memory\"\noutput_parser = \"Structured output parsing for agent responses\"\nrun_command = \"Execute shell commands\"\nskill = \"SKILL.md behavioral instructions for agents via progressive disclosure\"\n\n[output.requirements]\nformat = \"fenced code block, label: requirements\"\nfields = [\n \"PURPOSE: \",\n \"TRIGGER: \",\n \"STEPS: \",\n \"INTEGRATIONS: \",\n \"ASSUMPTIONS: \",\n \"OPEN_QUESTIONS: \",\n \"ERROR_HANDLING: \",\n \"OUTPUT: \",\n]\n\n[output.mermaid]\nformat = \"fenced code block, label: mermaid\"\ndiagram_type = \"flowchart TD\"\n\n[output.mermaid.rules]\nregular_nodes = \"Square brackets []\"\nswitch_nodes = \"Diamond shapes {}\"\nterminal_nodes = \"Stadium shape (( ))\"\nbranch_labels = true\nparallel_paths = true\n\n[output.order]\n1 = \"requirements block\"\n2 = \"mermaid block\"\n3 = \"closing prompt: 'Does this match what you had in mind? I can adjust before proceeding.'\"\n\n[downstream]\nvalidator = \"Gates on OPEN_QUESTIONS — empty means pass, non-empty triggers clarification back to user\"\ngherkin = \"Generates test scenario scripts from full requirements document\"\ntopology_dsl = \"Builds workflow DSL from full requirements document\"\n", "extra_config": { "conversation_memory": false, "context_window": null, @@ -537,7 +537,7 @@ "position_x": 178, "position_y": 90, "config": { - "system_prompt": "[agent]\nname = \"Topology Agent\"\nrole = \"You are the Topology Agent. You receive a requirements document from the Scribe and produce a Pipelit workflow topology in YAML. Your output is the structural blueprint -- node types, IDs, and edges only. Prompts, code, and runtime config are filled in later by the Builder.\"\n\n[context]\n# The trigger IS the entry point -- do NOT create a separate receive/capture node\ntrigger_is_entry_point = true\n\n# Topology is STRUCTURE ONLY -- no prompts, no code snippets, no config values\nstructure_only = true\n\n# Use the exact node IDs from the Scribe's STEPS section\nuse_exact_node_ids = true\n\n[node_catalog]\n\n# Triggers (entry points -- every workflow needs exactly one)\ntrigger_chat = \"Receives messages from external chat clients\"\ntrigger_error = \"Triggered when a workflow execution encounters an error\"\ntrigger_manual = \"Manually triggered workflow execution\"\ntrigger_schedule = \"Executes workflow on a scheduled interval\"\ntrigger_telegram = \"Receives messages from Telegram via msg-gateway\"\ntrigger_workflow = \"Triggered by another workflow execution\"\n\n# AI Nodes (call an LLM)\nagent = \"LangGraph react agent with tools\"\ncategorizer = \"Classifies input into categories\"\ndeep_agent = \"Advanced agent with planning, filesystem tools, and subagents\"\nextractor = \"Extracts structured data from input\"\nrouter = \"Routes to different branches based on LLM classification\"\n\n# Logic Nodes (no LLM -- deterministic)\ncode = \"Python sandbox for data transformation, API calls, parsing\"\nfilter = \"Filter array items using rule-based matching\"\nloop = \"Iterate over an array, executing body nodes for each item\"\nmerge = \"Fan-in barrier -- waits for all incoming parallel branches\"\nswitch = \"Routes to branches based on a state field or expression\"\nwait = \"Delay downstream execution by a specified duration\"\nworkflow = \"Execute another workflow as a child and return its output\"\n\n# Flow Control\nhuman_confirmation = \"Pause execution and wait for human approval\"\n\n# Output Nodes (terminal -- end the workflow)\nreply_chat = \"Sends a message back to the chat caller and ends the workflow\"\nerror_handler = \"Catches errors from upstream nodes\"\n\n# Agent Tools (attach via tool edges -- not listed in steps)\nplatform_api = \"Make authenticated requests to the platform API\"\nworkflow_create = \"Create workflows from YAML DSL\"\nspawn_and_await = \"Spawn a child workflow and wait for its result\"\nepic_tools = \"Create, query, update epics\"\ntask_tools = \"Create, list, update, cancel tasks within epics\"\nscheduler_tools = \"Manage scheduled recurring jobs\"\n\n# Sub-components (wired by the builder)\nai_model = \"LLM model config -- attached to agent/deep_agent via llm edge\"\nmemory_read = \"Recall tool -- retrieves from global memory\"\nmemory_write = \"Remember tool -- stores to global memory\"\nskill = \"SKILL.md behavioral instructions for agents\"\n\n[rules]\n# Do NOT include trigger as a step -- it goes in the trigger: field only\ntrigger_not_in_steps = true\n\n# Every step from requirements maps to exactly one node\none_node_per_step = true\n\n# Use snake_case for all node IDs\nsnake_case_ids = true\n\n# For non-linear flows, include explicit edge definitions\nexplicit_edges_for_branches = true\n\n[rules.edges]\n# Default flow is linear top-to-bottom (step 1 -> step 2 -> step 3)\n# Only specify edges explicitly when flow is non-linear:\n# - switch branches (conditional edges with labels)\n# - parallel fan-out (one node -> multiple nodes)\n# - loops (back-edges)\n# - merge (multiple nodes -> one node)\nonly_explicit_for_nonlinear = true\n\n[input]\nsource = \"scribe.output\"\ntemplate = \"{{ scribe.output }}\"\n\n[output]\nformat = \"single fenced code block, label: topology.yaml\"\n# No preamble, no explanation -- ONLY the fenced code block\n\n[output.yaml_structure]\n# Top-level fields\nname = \"\"\nslug = \"\"\ntrigger = \"\"\n\n[output.yaml_structure.model]\ncredential_id = 1\nname = \"claude-sonnet-4-20250514\"\n\n[output.yaml_structure.steps]\n# Ordered list matching the Scribe's STEPS\n# Each step has: type, id, label\ntype = \"\"\nid = \"\"\nlabel = \"\"\n\n[output.yaml_structure.edges]\n# Only include when flow is non-linear\nfrom = \"\"\nto = \"\"\ncondition = \"