diff --git a/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py b/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py new file mode 100644 index 00000000..9f67379e --- /dev/null +++ b/backend/alembic/versions/3c6a2d3df4a1_task_dependencies.py @@ -0,0 +1,80 @@ +"""task dependencies + +Revision ID: 3c6a2d3df4a1 +Revises: 1d844b04ee06 +Create Date: 2026-02-06 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "3c6a2d3df4a1" +down_revision = "1d844b04ee06" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "task_dependencies", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=False), + sa.Column("depends_on_task_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["board_id"], + ["boards.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["task_id"], + ["tasks.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["depends_on_task_id"], + ["tasks.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "task_id", + "depends_on_task_id", + name="uq_task_dependencies_task_id_depends_on_task_id", + ), + sa.CheckConstraint( + "task_id <> depends_on_task_id", + name="ck_task_dependencies_no_self", + ), + ) + + op.create_index( + "ix_task_dependencies_board_id", + "task_dependencies", + ["board_id"], + unique=False, + ) + op.create_index( + "ix_task_dependencies_task_id", + "task_dependencies", + ["task_id"], + unique=False, + ) + op.create_index( + "ix_task_dependencies_depends_on_task_id", + "task_dependencies", + ["depends_on_task_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_task_dependencies_depends_on_task_id", table_name="task_dependencies") + op.drop_index("ix_task_dependencies_task_id", table_name="task_dependencies") + op.drop_index("ix_task_dependencies_board_id", table_name="task_dependencies") + op.drop_table("task_dependencies") + diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 5c727b04..1aacb60b 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -26,8 +26,15 @@ from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway +from app.models.task_dependencies import TaskDependency from app.models.tasks import Task -from app.schemas.agents import AgentCreate, AgentHeartbeat, AgentHeartbeatCreate, AgentNudge, AgentRead +from app.schemas.agents import ( + AgentCreate, + AgentHeartbeat, + AgentHeartbeatCreate, + AgentNudge, + AgentRead, +) from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead @@ -36,6 +43,11 @@ from app.schemas.common import OkResponse from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity +from app.services.task_dependencies import ( + blocked_by_dependency_ids, + dependency_status_by_id, + validate_dependency_update, +) router = APIRouter(prefix="/agent", tags=["agent"]) @@ -131,14 +143,39 @@ async def create_task( board: Board = Depends(get_board_or_404), session: AsyncSession = Depends(get_session), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), -) -> Task: +) -> TaskRead: _guard_board_access(agent_ctx, board) if not agent_ctx.agent.is_board_lead: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) - task = Task.model_validate(payload) + data = payload.model_dump() + depends_on_task_ids = cast(list[UUID], data.pop("depends_on_task_ids", []) or []) + + task = Task.model_validate(data) task.board_id = board.id task.auto_created = True task.auto_reason = f"lead_agent:{agent_ctx.agent.id}" + + normalized_deps = await validate_dependency_update( + session, + board_id=board.id, + task_id=task.id, + depends_on_task_ids=depends_on_task_ids, + ) + dep_status = await dependency_status_by_id( + session, + board_id=board.id, + dependency_ids=normalized_deps, + ) + blocked_by = blocked_by_dependency_ids(dependency_ids=normalized_deps, status_by_id=dep_status) + + if blocked_by and (task.assigned_agent_id is not None or task.status != "inbox"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Task is blocked by incomplete dependencies.", + "blocked_by_task_ids": [str(value) for value in blocked_by], + }, + ) if task.assigned_agent_id: agent = await session.get(Agent, task.assigned_agent_id) if agent is None: @@ -151,6 +188,14 @@ async def create_task( if agent.board_id and agent.board_id != board.id: raise HTTPException(status_code=status.HTTP_409_CONFLICT) session.add(task) + for dep_id in normalized_deps: + session.add( + TaskDependency( + board_id=board.id, + task_id=task.id, + depends_on_task_id=dep_id, + ) + ) await session.commit() await session.refresh(task) record_activity( @@ -170,7 +215,13 @@ async def create_task( task=task, agent=assigned_agent, ) - return task + return TaskRead.model_validate(task, from_attributes=True).model_copy( + update={ + "depends_on_task_ids": normalized_deps, + "blocked_by_task_ids": blocked_by, + "is_blocked": bool(blocked_by), + } + ) @router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead) @@ -179,7 +230,7 @@ async def update_task( task: Task = Depends(get_task_or_404), session: AsyncSession = Depends(get_session), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), -) -> Task: +) -> TaskRead: if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) return await tasks_api.update_task( diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 2c3f78ec..7ff8e074 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -27,7 +27,6 @@ from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway from app.models.tasks import Task -from app.schemas.common import OkResponse from app.schemas.agents import ( AgentCreate, AgentHeartbeat, @@ -35,6 +34,7 @@ from app.schemas.agents import ( AgentRead, AgentUpdate, ) +from app.schemas.common import OkResponse from app.schemas.pagination import DefaultLimitOffsetPage from app.services.activity_log import record_activity from app.services.agent_provisioning import ( @@ -97,7 +97,9 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> return board -async def _require_gateway(session: AsyncSession, board: Board) -> tuple[Gateway, GatewayClientConfig]: +async def _require_gateway( + session: AsyncSession, board: Board +) -> tuple[Gateway, GatewayClientConfig]: if not board.gateway_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -155,7 +157,9 @@ async def _find_gateway_for_main_session( ) -> Gateway | None: if not session_key: return None - return (await session.exec(select(Gateway).where(Gateway.main_session_key == session_key))).first() + return ( + await session.exec(select(Gateway).where(Gateway.main_session_key == session_key)) + ).first() async def _ensure_gateway_session( @@ -211,7 +215,9 @@ def _record_heartbeat(session: AsyncSession, agent: Agent) -> None: ) -def _record_instruction_failure(session: AsyncSession, agent: Agent, error: str, action: str) -> None: +def _record_instruction_failure( + session: AsyncSession, agent: Agent, error: str, action: str +) -> None: action_label = action.replace("_", " ").capitalize() record_activity( session, @@ -275,7 +281,9 @@ async def stream_agents( break async with async_session_maker() as session: agents = await _fetch_agent_events(session, board_id, last_seen) - main_session_keys = await _get_gateway_main_session_keys(session) if agents else set() + main_session_keys = ( + await _get_gateway_main_session_keys(session) if agents else set() + ) for agent in agents: updated_at = agent.updated_at or agent.last_seen_at or utcnow() if updated_at > last_seen: diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py index 16fcf3a6..13855c6b 100644 --- a/backend/app/api/approvals.py +++ b/backend/app/api/approvals.py @@ -131,7 +131,9 @@ async def stream_approvals( ) ).one() ) - task_ids = {approval.task_id for approval in approvals if approval.task_id is not None} + task_ids = { + approval.task_id for approval in approvals if approval.task_id is not None + } counts_by_task_id: dict[UUID, tuple[int, int]] = {} if task_ids: rows = list( diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py index ee9f6513..d60f477f 100644 --- a/backend/app/api/board_memory.py +++ b/backend/app/api/board_memory.py @@ -77,17 +77,15 @@ async def _fetch_memory_events( is_chat: bool | None = None, ) -> list[BoardMemory]: statement = ( - select(BoardMemory) - .where(col(BoardMemory.board_id) == board_id) + select(BoardMemory).where(col(BoardMemory.board_id) == board_id) # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # satisfy the NonEmptyStr response schema. .where(func.length(func.trim(col(BoardMemory.content))) > 0) ) if is_chat is not None: statement = statement.where(col(BoardMemory.is_chat) == is_chat) - statement = ( - statement.where(col(BoardMemory.created_at) >= since) - .order_by(col(BoardMemory.created_at)) + statement = statement.where(col(BoardMemory.created_at) >= since).order_by( + col(BoardMemory.created_at) ) return list(await session.exec(statement)) @@ -162,8 +160,7 @@ async def list_board_memory( if actor.agent.board_id and actor.agent.board_id != board.id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) statement = ( - select(BoardMemory) - .where(col(BoardMemory.board_id) == board.id) + select(BoardMemory).where(col(BoardMemory.board_id) == board.id) # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # satisfy the NonEmptyStr response schema. .where(func.length(func.trim(col(BoardMemory.content))) > 0) diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index df4b0256..b6eb18b3 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -22,9 +22,9 @@ from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway from app.schemas.board_onboarding import ( - BoardOnboardingAnswer, BoardOnboardingAgentComplete, BoardOnboardingAgentUpdate, + BoardOnboardingAnswer, BoardOnboardingConfirm, BoardOnboardingLeadAgentDraft, BoardOnboardingRead, @@ -251,9 +251,7 @@ async def answer_onboarding( answer_text = f"{payload.answer}: {payload.other_text}" messages = list(onboarding.messages or []) - messages.append( - {"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()} - ) + messages.append({"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()}) try: await ensure_session(onboarding.session_key, config=config, label="Main Agent") diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 7a87a4b0..be50d27b 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -4,8 +4,7 @@ import re from uuid import UUID, uuid4 from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import delete -from sqlalchemy import func +from sqlalchemy import delete, func from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -31,8 +30,8 @@ from app.models.boards import Board from app.models.gateways import Gateway from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task -from app.schemas.common import OkResponse from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate +from app.schemas.common import OkResponse from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.view_models import BoardSnapshot from app.services.board_snapshot import build_board_snapshot @@ -229,10 +228,14 @@ async def delete_board( if task_ids: await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids))) - await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id)) + await session.execute( + delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id) + ) if agents: agent_ids = [agent.id for agent in agents] - await session.execute(delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))) + await session.execute( + delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)) + ) await session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids))) await session.execute(delete(Approval).where(col(Approval.board_id) == board.id)) await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id)) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index bb7960c9..3cd5ef21 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -3,16 +3,16 @@ from __future__ import annotations import asyncio import json from collections import deque -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Sequence from datetime import datetime, timezone from typing import cast from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from sqlalchemy import asc, delete, desc +from sqlalchemy import asc, delete, desc, or_ from sqlmodel import col, select -from sqlmodel.sql.expression import Select from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql.expression import Select from sse_starlette.sse import EventSourceResponse from app.api.deps import ( @@ -33,13 +33,23 @@ from app.models.agents import Agent from app.models.approvals import Approval from app.models.boards import Board from app.models.gateways import Gateway +from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task from app.schemas.common import OkResponse +from app.schemas.errors import BlockedTaskError from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.services.activity_log import record_activity from app.services.mentions import extract_mentions, matches_agent_mention +from app.services.task_dependencies import ( + blocked_by_dependency_ids, + dependency_ids_by_task_id, + dependency_status_by_id, + dependent_task_ids, + replace_task_dependencies, + validate_dependency_update, +) router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"]) @@ -60,6 +70,16 @@ def _comment_validation_error() -> HTTPException: ) +def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException: + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Task is blocked by incomplete dependencies.", + "blocked_by_task_ids": [str(value) for value in blocked_by_task_ids], + }, + ) + + async def has_valid_recent_comment( session: AsyncSession, task: Task, @@ -124,6 +144,75 @@ def _lead_created_task(task: Task, lead: Agent) -> bool: return task.auto_reason == f"lead_agent:{lead.id}" +async def _reconcile_dependents_for_dependency_toggle( + session: AsyncSession, + *, + board_id: UUID, + dependency_task: Task, + previous_status: str, + actor_agent_id: UUID | None, +) -> None: + done_toggled = (previous_status == "done") != (dependency_task.status == "done") + if not done_toggled: + return + + dependent_ids = await dependent_task_ids( + session, + board_id=board_id, + dependency_task_id=dependency_task.id, + ) + if not dependent_ids: + return + + dependents = list( + await session.exec( + select(Task) + .where(col(Task.board_id) == board_id) + .where(col(Task.id).in_(dependent_ids)) + ) + ) + reopened = previous_status == "done" and dependency_task.status != "done" + + for dependent in dependents: + if dependent.status == "done": + continue + if reopened: + should_reset = ( + dependent.status != "inbox" + or dependent.assigned_agent_id is not None + or dependent.in_progress_at is not None + ) + if should_reset: + dependent.status = "inbox" + dependent.assigned_agent_id = None + dependent.in_progress_at = None + dependent.updated_at = utcnow() + session.add(dependent) + record_activity( + session, + event_type="task.status_changed", + task_id=dependent.id, + message=f"Task returned to inbox: dependency reopened ({dependency_task.title}).", + agent_id=actor_agent_id, + ) + else: + record_activity( + session, + event_type="task.updated", + task_id=dependent.id, + message=f"Dependency completion changed: {dependency_task.title}.", + agent_id=actor_agent_id, + ) + else: + record_activity( + session, + event_type="task.updated", + task_id=dependent.id, + message=f"Dependency completion changed: {dependency_task.title}.", + agent_id=actor_agent_id, + ) + + async def _fetch_task_events( session: AsyncSession, board_id: UUID, @@ -144,12 +233,6 @@ async def _fetch_task_events( return list(await session.exec(statement)) -def _serialize_task(task: Task | None) -> dict[str, object] | None: - if task is None: - return None - return TaskRead.model_validate(task).model_dump(mode="json") - - def _serialize_comment(event: ActivityEvent) -> dict[str, object]: return TaskCommentRead.model_validate(event).model_dump(mode="json") @@ -372,8 +455,30 @@ async def stream_tasks( while True: if await request.is_disconnected(): break + deps_map: dict[UUID, list[UUID]] = {} + dep_status: dict[UUID, str] = {} async with async_session_maker() as session: rows = await _fetch_task_events(session, board.id, last_seen) + task_ids = [ + task.id + for event, task in rows + if task is not None and event.event_type != "task.comment" + ] + if task_ids: + deps_map = await dependency_ids_by_task_id( + session, + board_id=board.id, + task_ids=list({*task_ids}), + ) + dep_ids: list[UUID] = [] + for value in deps_map.values(): + dep_ids.extend(value) + if dep_ids: + dep_status = await dependency_status_by_id( + session, + board_id=board.id, + dependency_ids=list({*dep_ids}), + ) for event, task in rows: if event.id in seen_ids: continue @@ -388,7 +493,27 @@ async def stream_tasks( if event.event_type == "task.comment": payload["comment"] = _serialize_comment(event) else: - payload["task"] = _serialize_task(task) + if task is None: + payload["task"] = None + else: + dep_list = deps_map.get(task.id, []) + blocked_by = blocked_by_dependency_ids( + dependency_ids=dep_list, + status_by_id=dep_status, + ) + if task.status == "done": + blocked_by = [] + payload["task"] = ( + TaskRead.model_validate(task, from_attributes=True) + .model_copy( + update={ + "depends_on_task_ids": dep_list, + "blocked_by_task_ids": blocked_by, + "is_blocked": bool(blocked_by), + } + ) + .model_dump(mode="json") + ) yield {"event": "task", "data": json.dumps(payload)} await asyncio.sleep(2) @@ -422,21 +547,80 @@ async def list_tasks( if unassigned: statement = statement.where(col(Task.assigned_agent_id).is_(None)) statement = statement.order_by(col(Task.created_at).desc()) - return await paginate(session, statement) + + async def _transform(items: Sequence[object]) -> Sequence[object]: + tasks = cast(Sequence[Task], items) + if not tasks: + return [] + task_ids = [task.id for task in tasks] + deps_map = await dependency_ids_by_task_id(session, board_id=board.id, task_ids=task_ids) + dep_ids: list[UUID] = [] + for value in deps_map.values(): + dep_ids.extend(value) + dep_status = await dependency_status_by_id( + session, + board_id=board.id, + dependency_ids=list({*dep_ids}), + ) + + output: list[TaskRead] = [] + for task in tasks: + dep_list = deps_map.get(task.id, []) + blocked_by = blocked_by_dependency_ids(dependency_ids=dep_list, status_by_id=dep_status) + if task.status == "done": + blocked_by = [] + output.append( + TaskRead.model_validate(task, from_attributes=True).model_copy( + update={ + "depends_on_task_ids": dep_list, + "blocked_by_task_ids": blocked_by, + "is_blocked": bool(blocked_by), + } + ) + ) + return output + + return await paginate(session, statement, transformer=_transform) -@router.post("", response_model=TaskRead) +@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}}) async def create_task( payload: TaskCreate, board: Board = Depends(get_board_or_404), session: AsyncSession = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), -) -> Task: - task = Task.model_validate(payload) +) -> TaskRead: + data = payload.model_dump() + depends_on_task_ids = cast(list[UUID], data.pop("depends_on_task_ids", []) or []) + + task = Task.model_validate(data) task.board_id = board.id if task.created_by_user_id is None and auth.user is not None: task.created_by_user_id = auth.user.id + + normalized_deps = await validate_dependency_update( + session, + board_id=board.id, + task_id=task.id, + depends_on_task_ids=depends_on_task_ids, + ) + dep_status = await dependency_status_by_id( + session, + board_id=board.id, + dependency_ids=normalized_deps, + ) + blocked_by = blocked_by_dependency_ids(dependency_ids=normalized_deps, status_by_id=dep_status) + if blocked_by and (task.assigned_agent_id is not None or task.status != "inbox"): + raise _blocked_task_error(blocked_by) session.add(task) + for dep_id in normalized_deps: + session.add( + TaskDependency( + board_id=board.id, + task_id=task.id, + depends_on_task_id=dep_id, + ) + ) await session.commit() await session.refresh(task) @@ -457,59 +641,128 @@ async def create_task( task=task, agent=assigned_agent, ) - return task + return TaskRead.model_validate(task, from_attributes=True).model_copy( + update={ + "depends_on_task_ids": normalized_deps, + "blocked_by_task_ids": blocked_by, + "is_blocked": bool(blocked_by), + } + ) -@router.patch("/{task_id}", response_model=TaskRead) +@router.patch( + "/{task_id}", + response_model=TaskRead, + responses={409: {"model": BlockedTaskError}}, +) async def update_task( payload: TaskUpdate, task: Task = Depends(get_task_or_404), session: AsyncSession = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), -) -> Task: +) -> TaskRead: + if task.board_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task board_id is required.", + ) + board_id = task.board_id + previous_status = task.status previous_assigned = task.assigned_agent_id updates = payload.model_dump(exclude_unset=True) comment = updates.pop("comment", None) + depends_on_task_ids = cast(list[UUID] | None, updates.pop("depends_on_task_ids", None)) + requested_fields = set(updates) + if comment is not None: + requested_fields.add("comment") + if depends_on_task_ids is not None: + requested_fields.add("depends_on_task_ids") + + async def _current_dep_ids() -> list[UUID]: + deps_map = await dependency_ids_by_task_id(session, board_id=board_id, task_ids=[task.id]) + return deps_map.get(task.id, []) + + async def _blocked_by(dep_ids: Sequence[UUID]) -> list[UUID]: + if not dep_ids: + return [] + dep_status = await dependency_status_by_id( + session, + board_id=board_id, + dependency_ids=list(dep_ids), + ) + return blocked_by_dependency_ids(dependency_ids=list(dep_ids), status_by_id=dep_status) + + # Lead agent: delegation only (assign/unassign, resolve review, manage dependencies). if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead: - allowed_fields = {"assigned_agent_id", "status"} - if comment is not None or not set(updates).issubset(allowed_fields): + allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids"} + if comment is not None or not requested_fields.issubset(allowed_fields): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads can only assign or unassign tasks.", + detail=( + "Board leads can only assign/unassign tasks, update dependencies, or resolve review tasks." + ), ) - if "assigned_agent_id" in updates: - assigned_id = updates["assigned_agent_id"] - if assigned_id: - agent = await session.get(Agent, assigned_id) - if agent is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if agent.is_board_lead: + + normalized_deps: list[UUID] | None = None + if depends_on_task_ids is not None: + if task.status == "done": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=("Cannot change task dependencies after a task is done."), + ) + normalized_deps = await replace_task_dependencies( + session, + board_id=board_id, + task_id=task.id, + depends_on_task_ids=depends_on_task_ids, + ) + + effective_deps = ( + normalized_deps if normalized_deps is not None else await _current_dep_ids() + ) + blocked_by = await _blocked_by(effective_deps) + + # Blocked tasks cannot be assigned or moved out of inbox (unless already done). + if blocked_by and task.status != "done": + task.status = "inbox" + task.assigned_agent_id = None + task.in_progress_at = None + else: + if "assigned_agent_id" in updates: + assigned_id = updates["assigned_agent_id"] + if assigned_id: + agent = await session.get(Agent, assigned_id) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if agent.is_board_lead: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads cannot assign tasks to themselves.", + ) + if agent.board_id and task.board_id and agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + task.assigned_agent_id = agent.id + else: + task.assigned_agent_id = None + + if "status" in updates: + if task.status != "review": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads cannot assign tasks to themselves.", + detail="Board leads can only change status when a task is in review.", ) - if agent.board_id and task.board_id and agent.board_id != task.board_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT) - task.assigned_agent_id = agent.id - else: - task.assigned_agent_id = None - if "status" in updates: - if task.status != "review": - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads can only change status when a task is in review.", - ) - if updates["status"] not in {"done", "inbox"}: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Board leads can only move review tasks to done or inbox.", - ) - if updates["status"] == "inbox": - task.assigned_agent_id = None - task.in_progress_at = None - task.status = updates["status"] + if updates["status"] not in {"done", "inbox"}: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Board leads can only move review tasks to done or inbox.", + ) + if updates["status"] == "inbox": + task.assigned_agent_id = None + task.in_progress_at = None + task.status = updates["status"] + task.updated_at = utcnow() session.add(task) if task.status != previous_status: @@ -525,12 +778,17 @@ async def update_task( message=message, agent_id=actor.agent.id, ) + await _reconcile_dependents_for_dependency_toggle( + session, + board_id=board_id, + dependency_task=task, + previous_status=previous_status, + actor_agent_id=actor.agent.id, + ) await session.commit() await session.refresh(task) if task.assigned_agent_id and task.assigned_agent_id != previous_assigned: - if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id: - return task assigned_agent = await session.get(Agent, task.assigned_agent_id) if assigned_agent: board = await session.get(Board, task.board_id) if task.board_id else None @@ -541,15 +799,33 @@ async def update_task( task=task, agent=assigned_agent, ) - return task + + dep_ids = await _current_dep_ids() + blocked_ids = await _blocked_by(dep_ids) + if task.status == "done": + blocked_ids = [] + return TaskRead.model_validate(task, from_attributes=True).model_copy( + update={ + "depends_on_task_ids": dep_ids, + "blocked_by_task_ids": blocked_ids, + "is_blocked": bool(blocked_ids), + } + ) + + # Non-lead agent: can only change status + comment, and cannot start blocked tasks. if actor.actor_type == "agent": if actor.agent and actor.agent.board_id and task.board_id: if actor.agent.board_id != task.board_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) allowed_fields = {"status", "comment"} - if not set(updates).issubset(allowed_fields): + if depends_on_task_ids is not None or not set(updates).issubset(allowed_fields): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) if "status" in updates: + if updates["status"] != "inbox": + dep_ids = await _current_dep_ids() + blocked_ids = await _blocked_by(dep_ids) + if blocked_ids: + raise _blocked_task_error(blocked_ids) if updates["status"] == "inbox": task.assigned_agent_id = None task.in_progress_at = None @@ -557,18 +833,51 @@ async def update_task( task.assigned_agent_id = actor.agent.id if actor.agent else None if updates["status"] == "in_progress": task.in_progress_at = utcnow() - elif "status" in updates: - if updates["status"] == "inbox": + else: + # Admin user: dependencies can be edited until the task is done. + admin_normalized_deps: list[UUID] | None = None + if depends_on_task_ids is not None: + if task.status == "done": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=("Cannot change task dependencies after a task is done."), + ) + admin_normalized_deps = await replace_task_dependencies( + session, + board_id=board_id, + task_id=task.id, + depends_on_task_ids=depends_on_task_ids, + ) + + effective_deps = ( + admin_normalized_deps if admin_normalized_deps is not None else await _current_dep_ids() + ) + blocked_ids = await _blocked_by(effective_deps) + + target_status = cast(str, updates.get("status", task.status)) + if blocked_ids and not (task.status == "done" and target_status == "done"): + # Blocked tasks cannot be assigned or moved out of inbox. If the task is already in + # flight, force it back to inbox and unassign it. + task.status = "inbox" task.assigned_agent_id = None task.in_progress_at = None - elif updates["status"] == "in_progress": - task.in_progress_at = utcnow() - if "assigned_agent_id" in updates and updates["assigned_agent_id"]: - agent = await session.get(Agent, updates["assigned_agent_id"]) - if agent is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if agent.board_id and task.board_id and agent.board_id != task.board_id: - raise HTTPException(status_code=status.HTTP_409_CONFLICT) + updates["status"] = "inbox" + updates["assigned_agent_id"] = None + + if "status" in updates: + if updates["status"] == "inbox": + task.assigned_agent_id = None + task.in_progress_at = None + elif updates["status"] == "in_progress": + task.in_progress_at = utcnow() + + if "assigned_agent_id" in updates and updates["assigned_agent_id"]: + agent = await session.get(Agent, updates["assigned_agent_id"]) + if agent is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + if agent.board_id and task.board_id and agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_409_CONFLICT) + for key, value in updates.items(): setattr(task, key, value) task.updated_at = utcnow() @@ -606,14 +915,23 @@ async def update_task( else: event_type = "task.updated" message = f"Task updated: {task.title}." + actor_agent_id = actor.agent.id if actor.actor_type == "agent" and actor.agent else None record_activity( session, event_type=event_type, task_id=task.id, message=message, - agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None, + agent_id=actor_agent_id, + ) + await _reconcile_dependents_for_dependency_toggle( + session, + board_id=board_id, + dependency_task=task, + previous_status=previous_status, + actor_agent_id=actor_agent_id, ) await session.commit() + if task.status == "inbox" and task.assigned_agent_id is None: if previous_status != "inbox" or previous_assigned is not None: board = await session.get(Board, task.board_id) if task.board_id else None @@ -625,18 +943,31 @@ async def update_task( ) if task.assigned_agent_id and task.assigned_agent_id != previous_assigned: if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id: - return task - assigned_agent = await session.get(Agent, task.assigned_agent_id) - if assigned_agent: - board = await session.get(Board, task.board_id) if task.board_id else None - if board: - await _notify_agent_on_task_assign( - session=session, - board=board, - task=task, - agent=assigned_agent, - ) - return task + # Don't notify the actor about their own assignment. + pass + else: + assigned_agent = await session.get(Agent, task.assigned_agent_id) + if assigned_agent: + board = await session.get(Board, task.board_id) if task.board_id else None + if board: + await _notify_agent_on_task_assign( + session=session, + board=board, + task=task, + agent=assigned_agent, + ) + + dep_ids = await _current_dep_ids() + blocked_ids = await _blocked_by(dep_ids) + if task.status == "done": + blocked_ids = [] + return TaskRead.model_validate(task, from_attributes=True).model_copy( + update={ + "depends_on_task_ids": dep_ids, + "blocked_by_task_ids": blocked_ids, + "is_blocked": bool(blocked_ids), + } + ) @router.delete("/{task_id}", response_model=OkResponse) @@ -648,6 +979,14 @@ async def delete_task( await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id)) await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id)) await session.execute(delete(Approval).where(col(Approval.task_id) == task.id)) + await session.execute( + delete(TaskDependency).where( + or_( + col(TaskDependency.task_id) == task.id, + col(TaskDependency.depends_on_task_id) == task.id, + ) + ) + ) await session.delete(task) await session.commit() return OkResponse() diff --git a/backend/app/core/agent_auth.py b/backend/app/core/agent_auth.py index b49bf181..8f69574c 100644 --- a/backend/app/core/agent_auth.py +++ b/backend/app/core/agent_auth.py @@ -22,9 +22,7 @@ class AgentAuthContext: async def _find_agent_for_token(session: AsyncSession, token: str) -> Agent | None: - agents = list( - await session.exec(select(Agent).where(col(Agent.agent_token_hash).is_not(None))) - ) + agents = list(await session.exec(select(Agent).where(col(Agent.agent_token_hash).is_not(None)))) for agent in agents: if agent.agent_token_hash and verify_agent_token(token, agent.agent_token_hash): return agent diff --git a/backend/app/core/durations.py b/backend/app/core/durations.py index 00eda9f1..f695bdbf 100644 --- a/backend/app/core/durations.py +++ b/backend/app/core/durations.py @@ -34,4 +34,3 @@ def parse_every_to_seconds(value: str) -> int: if seconds > 60 * 60 * 24 * 365 * 10: raise ValueError("Schedule is too large (max 10 years).") return seconds - diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3b0adf03..7fc741c2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway +from app.models.task_dependencies import TaskDependency from app.models.task_fingerprints import TaskFingerprint from app.models.tasks import Task from app.models.users import User @@ -17,6 +18,7 @@ __all__ = [ "BoardOnboardingSession", "Board", "Gateway", + "TaskDependency", "Task", "TaskFingerprint", "User", diff --git a/backend/app/models/task_dependencies.py b/backend/app/models/task_dependencies.py new file mode 100644 index 00000000..f9cb494a --- /dev/null +++ b/backend/app/models/task_dependencies.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import CheckConstraint, UniqueConstraint +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.tenancy import TenantScoped + + +class TaskDependency(TenantScoped, table=True): + __tablename__ = "task_dependencies" + __table_args__ = ( + UniqueConstraint( + "task_id", + "depends_on_task_id", + name="uq_task_dependencies_task_id_depends_on_task_id", + ), + CheckConstraint( + "task_id <> depends_on_task_id", + name="ck_task_dependencies_no_self", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + task_id: UUID = Field(foreign_key="tasks.id", index=True) + depends_on_task_id: UUID = Field(foreign_key="tasks.id", index=True) + created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py index a1c2b2ec..e431e4b0 100644 --- a/backend/app/models/tasks.py +++ b/backend/app/models/tasks.py @@ -5,8 +5,8 @@ from uuid import UUID, uuid4 from sqlmodel import Field -from app.models.tenancy import TenantScoped from app.core.time import utcnow +from app.models.tenancy import TenantScoped class Task(TenantScoped, table=True): diff --git a/backend/app/schemas/approvals.py b/backend/app/schemas/approvals.py index bb4df773..2df9ed92 100644 --- a/backend/app/schemas/approvals.py +++ b/backend/app/schemas/approvals.py @@ -7,7 +7,6 @@ from uuid import UUID from pydantic import model_validator from sqlmodel import SQLModel - ApprovalStatus = Literal["pending", "approved", "rejected"] diff --git a/backend/app/schemas/errors.py b/backend/app/schemas/errors.py new file mode 100644 index 00000000..35da6cdd --- /dev/null +++ b/backend/app/schemas/errors.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from sqlmodel import Field, SQLModel + + +class BlockedTaskDetail(SQLModel): + message: str + blocked_by_task_ids: list[str] = Field(default_factory=list) + + +class BlockedTaskError(SQLModel): + detail: BlockedTaskDetail diff --git a/backend/app/schemas/tasks.py b/backend/app/schemas/tasks.py index ae0cf30c..da390146 100644 --- a/backend/app/schemas/tasks.py +++ b/backend/app/schemas/tasks.py @@ -5,11 +5,10 @@ from typing import Any, Literal, Self from uuid import UUID from pydantic import field_validator, model_validator -from sqlmodel import SQLModel +from sqlmodel import Field, SQLModel from app.schemas.common import NonEmptyStr - TaskStatus = Literal["inbox", "in_progress", "review", "done"] @@ -20,6 +19,7 @@ class TaskBase(SQLModel): priority: str = "medium" due_at: datetime | None = None assigned_agent_id: UUID | None = None + depends_on_task_ids: list[UUID] = Field(default_factory=list) class TaskCreate(TaskBase): @@ -33,6 +33,7 @@ class TaskUpdate(SQLModel): priority: str | None = None due_at: datetime | None = None assigned_agent_id: UUID | None = None + depends_on_task_ids: list[UUID] | None = None comment: NonEmptyStr | None = None @field_validator("comment", mode="before") @@ -58,6 +59,8 @@ class TaskRead(TaskBase): in_progress_at: datetime | None created_at: datetime updated_at: datetime + blocked_by_task_ids: list[UUID] = Field(default_factory=list) + is_blocked: bool = False class TaskCommentCreate(SQLModel): diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index d76b12eb..6b511ef9 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -338,9 +338,7 @@ def _render_agent_files( rendered[name] = env.from_string(override).render(**context).strip() continue template_name = ( - template_overrides[name] - if template_overrides and name in template_overrides - else name + template_overrides[name] if template_overrides and name in template_overrides else name ) path = _templates_root() / template_name if path.exists(): diff --git a/backend/app/services/board_snapshot.py b/backend/app/services/board_snapshot.py index b091e36c..f9c1582d 100644 --- a/backend/app/services/board_snapshot.py +++ b/backend/app/services/board_snapshot.py @@ -19,6 +19,11 @@ from app.schemas.approvals import ApprovalRead from app.schemas.board_memory import BoardMemoryRead from app.schemas.boards import BoardRead from app.schemas.view_models import BoardSnapshot, TaskCardRead +from app.services.task_dependencies import ( + blocked_by_dependency_ids, + dependency_ids_by_task_id, + dependency_status_by_id, +) OFFLINE_AFTER = timedelta(minutes=10) @@ -42,7 +47,9 @@ async def _gateway_main_session_keys(session: AsyncSession) -> set[str]: def _agent_to_read(agent: Agent, main_session_keys: set[str]) -> AgentRead: model = AgentRead.model_validate(agent, from_attributes=True) computed_status = _computed_agent_status(agent) - is_gateway_main = bool(agent.openclaw_session_id and agent.openclaw_session_id in main_session_keys) + is_gateway_main = bool( + agent.openclaw_session_id and agent.openclaw_session_id in main_session_keys + ) return model.model_copy(update={"status": computed_status, "is_gateway_main": is_gateway_main}) @@ -59,17 +66,29 @@ def _task_to_card( *, agent_name_by_id: dict[UUID, str], counts_by_task_id: dict[UUID, tuple[int, int]], + deps_by_task_id: dict[UUID, list[UUID]], + dependency_status_by_id_map: dict[UUID, str], ) -> TaskCardRead: card = TaskCardRead.model_validate(task, from_attributes=True) approvals_count, approvals_pending_count = counts_by_task_id.get(task.id, (0, 0)) assignee = ( agent_name_by_id.get(task.assigned_agent_id) if task.assigned_agent_id is not None else None ) + depends_on_task_ids = deps_by_task_id.get(task.id, []) + blocked_by_task_ids = blocked_by_dependency_ids( + dependency_ids=depends_on_task_ids, + status_by_id=dependency_status_by_id_map, + ) + if task.status == "done": + blocked_by_task_ids = [] return card.model_copy( update={ "assignee": assignee, "approvals_count": approvals_count, "approvals_pending_count": approvals_pending_count, + "depends_on_task_ids": depends_on_task_ids, + "blocked_by_task_ids": blocked_by_task_ids, + "is_blocked": bool(blocked_by_task_ids), } ) @@ -82,22 +101,37 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap select(Task).where(col(Task.board_id) == board.id).order_by(col(Task.created_at).desc()) ) ) + task_ids = [task.id for task in tasks] + + deps_by_task_id = await dependency_ids_by_task_id(session, board_id=board.id, task_ids=task_ids) + all_dependency_ids: list[UUID] = [] + for values in deps_by_task_id.values(): + all_dependency_ids.extend(values) + dependency_status_by_id_map = await dependency_status_by_id( + session, + board_id=board.id, + dependency_ids=list({*all_dependency_ids}), + ) main_session_keys = await _gateway_main_session_keys(session) agents = list( await session.exec( - select(Agent).where(col(Agent.board_id) == board.id).order_by(col(Agent.created_at).desc()) + select(Agent) + .where(col(Agent.board_id) == board.id) + .order_by(col(Agent.created_at).desc()) ) ) agent_reads = [_agent_to_read(agent, main_session_keys) for agent in agents] agent_name_by_id = {agent.id: agent.name for agent in agents} pending_approvals_count = int( - (await session.exec( - select(func.count(col(Approval.id))) - .where(col(Approval.board_id) == board.id) - .where(col(Approval.status) == "pending") - )).one() + ( + await session.exec( + select(func.count(col(Approval.id))) + .where(col(Approval.board_id) == board.id) + .where(col(Approval.status) == "pending") + ) + ).one() ) approvals = list( @@ -129,7 +163,13 @@ async def build_board_snapshot(session: AsyncSession, board: Board) -> BoardSnap counts_by_task_id[task_id] = (int(total or 0), int(pending or 0)) task_cards = [ - _task_to_card(task, agent_name_by_id=agent_name_by_id, counts_by_task_id=counts_by_task_id) + _task_to_card( + task, + agent_name_by_id=agent_name_by_id, + counts_by_task_id=counts_by_task_id, + deps_by_task_id=deps_by_task_id, + dependency_status_by_id_map=dependency_status_by_id_map, + ) for task in tasks ] diff --git a/backend/app/services/task_dependencies.py b/backend/app/services/task_dependencies.py new file mode 100644 index 00000000..8494a4a5 --- /dev/null +++ b/backend/app/services/task_dependencies.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping, Sequence +from typing import Final +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy import delete +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from app.models.task_dependencies import TaskDependency +from app.models.tasks import Task + +DONE_STATUS: Final[str] = "done" + + +def _dedupe_uuid_list(values: Sequence[UUID]) -> list[UUID]: + # Preserve order; remove duplicates. + seen: set[UUID] = set() + output: list[UUID] = [] + for value in values: + if value in seen: + continue + seen.add(value) + output.append(value) + return output + + +async def dependency_ids_by_task_id( + session: AsyncSession, + *, + board_id: UUID, + task_ids: Sequence[UUID], +) -> dict[UUID, list[UUID]]: + if not task_ids: + return {} + rows = list( + await session.exec( + select(col(TaskDependency.task_id), col(TaskDependency.depends_on_task_id)) + .where(col(TaskDependency.board_id) == board_id) + .where(col(TaskDependency.task_id).in_(task_ids)) + .order_by(col(TaskDependency.created_at).asc()) + ) + ) + mapping: dict[UUID, list[UUID]] = defaultdict(list) + for task_id, depends_on_task_id in rows: + mapping[task_id].append(depends_on_task_id) + return dict(mapping) + + +async def dependency_status_by_id( + session: AsyncSession, + *, + board_id: UUID, + dependency_ids: Sequence[UUID], +) -> dict[UUID, str]: + if not dependency_ids: + return {} + rows = list( + await session.exec( + select(col(Task.id), col(Task.status)) + .where(col(Task.board_id) == board_id) + .where(col(Task.id).in_(dependency_ids)) + ) + ) + return {task_id: status_value for task_id, status_value in rows} + + +def blocked_by_dependency_ids( + *, + dependency_ids: Sequence[UUID], + status_by_id: Mapping[UUID, str], +) -> list[UUID]: + blocked: list[UUID] = [] + for dep_id in dependency_ids: + if status_by_id.get(dep_id) != DONE_STATUS: + blocked.append(dep_id) + return blocked + + +async def blocked_by_for_task( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, + dependency_ids: Sequence[UUID] | None = None, +) -> list[UUID]: + dep_ids = list(dependency_ids or []) + if dependency_ids is None: + deps_map = await dependency_ids_by_task_id( + session, + board_id=board_id, + task_ids=[task_id], + ) + dep_ids = deps_map.get(task_id, []) + if not dep_ids: + return [] + status_by_id = await dependency_status_by_id(session, board_id=board_id, dependency_ids=dep_ids) + return blocked_by_dependency_ids(dependency_ids=dep_ids, status_by_id=status_by_id) + + +def _has_cycle(nodes: Sequence[UUID], edges: Mapping[UUID, set[UUID]]) -> bool: + visited: set[UUID] = set() + in_stack: set[UUID] = set() + + def dfs(node: UUID) -> bool: + if node in in_stack: + return True + if node in visited: + return False + visited.add(node) + in_stack.add(node) + for nxt in edges.get(node, set()): + if dfs(nxt): + return True + in_stack.remove(node) + return False + + for node in nodes: + if dfs(node): + return True + return False + + +async def validate_dependency_update( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, + depends_on_task_ids: Sequence[UUID], +) -> list[UUID]: + normalized = _dedupe_uuid_list(depends_on_task_ids) + if task_id in normalized: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Task cannot depend on itself.", + ) + if not normalized: + return [] + + # Ensure all dependency tasks exist on this board. + existing_ids = set( + await session.exec( + select(col(Task.id)) + .where(col(Task.board_id) == board_id) + .where(col(Task.id).in_(normalized)) + ) + ) + missing = [dep_id for dep_id in normalized if dep_id not in existing_ids] + if missing: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "message": "One or more dependency tasks were not found on this board.", + "missing_task_ids": [str(value) for value in missing], + }, + ) + + # Ensure the dependency graph is acyclic after applying the update. + task_ids = list(await session.exec(select(col(Task.id)).where(col(Task.board_id) == board_id))) + rows = list( + await session.exec( + select(col(TaskDependency.task_id), col(TaskDependency.depends_on_task_id)).where( + col(TaskDependency.board_id) == board_id + ) + ) + ) + edges: dict[UUID, set[UUID]] = defaultdict(set) + for src, dst in rows: + edges[src].add(dst) + edges[task_id] = set(normalized) + + if _has_cycle(task_ids, edges): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Dependency cycle detected. Remove the cycle before saving.", + ) + + return normalized + + +async def replace_task_dependencies( + session: AsyncSession, + *, + board_id: UUID, + task_id: UUID, + depends_on_task_ids: Sequence[UUID], +) -> list[UUID]: + normalized = await validate_dependency_update( + session, + board_id=board_id, + task_id=task_id, + depends_on_task_ids=depends_on_task_ids, + ) + await session.execute( + delete(TaskDependency) + .where(col(TaskDependency.board_id) == board_id) + .where(col(TaskDependency.task_id) == task_id) + ) + for dep_id in normalized: + session.add( + TaskDependency( + board_id=board_id, + task_id=task_id, + depends_on_task_id=dep_id, + ) + ) + return normalized + + +async def dependent_task_ids( + session: AsyncSession, + *, + board_id: UUID, + dependency_task_id: UUID, +) -> list[UUID]: + rows = await session.exec( + select(col(TaskDependency.task_id)) + .where(col(TaskDependency.board_id) == board_id) + .where(col(TaskDependency.depends_on_task_id) == dependency_task_id) + ) + return list(rows) diff --git a/backend/scripts/export_openapi.py b/backend/scripts/export_openapi.py index 182152d2..8bd53efb 100644 --- a/backend/scripts/export_openapi.py +++ b/backend/scripts/export_openapi.py @@ -1,8 +1,8 @@ from __future__ import annotations import json -from pathlib import Path import sys +from pathlib import Path BACKEND_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(BACKEND_ROOT)) diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index cf800eee..6e393bb3 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -1,6 +1,7 @@ -import pytest from uuid import uuid4 +import pytest + from app.schemas.board_onboarding import BoardOnboardingConfirm from app.schemas.boards import BoardCreate diff --git a/backend/tests/test_mentions.py b/backend/tests/test_mentions.py index 739a8e31..66c283d3 100644 --- a/backend/tests/test_mentions.py +++ b/backend/tests/test_mentions.py @@ -17,4 +17,3 @@ def test_matches_agent_mention_supports_reserved_lead_shortcut(): other = Agent(name="Lead", is_board_lead=False) assert matches_agent_mention(lead, {"lead"}) is True assert matches_agent_mention(other, {"lead"}) is False - diff --git a/backend/typings/fastapi_clerk_auth/__init__.pyi b/backend/typings/fastapi_clerk_auth/__init__.pyi index 2d81b5cb..44537030 100644 --- a/backend/typings/fastapi_clerk_auth/__init__.pyi +++ b/backend/typings/fastapi_clerk_auth/__init__.pyi @@ -4,14 +4,12 @@ from dataclasses import dataclass from starlette.requests import Request - @dataclass class ClerkConfig: jwks_url: str verify_iat: bool = ... leeway: float = ... - class HTTPAuthorizationCredentials: scheme: str credentials: str @@ -24,7 +22,6 @@ class HTTPAuthorizationCredentials: decoded: dict[str, object] | None = ..., ) -> None: ... - class ClerkHTTPBearer: def __init__( self, @@ -32,6 +29,4 @@ class ClerkHTTPBearer: auto_error: bool = ..., add_state: bool = ..., ) -> None: ... - async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: ... - diff --git a/frontend/src/api/generated/model/blockedTaskDetail.ts b/frontend/src/api/generated/model/blockedTaskDetail.ts new file mode 100644 index 00000000..50d26832 --- /dev/null +++ b/frontend/src/api/generated/model/blockedTaskDetail.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ + +export interface BlockedTaskDetail { + blocked_by_task_ids?: string[]; + message: string; +} diff --git a/frontend/src/api/generated/model/blockedTaskError.ts b/frontend/src/api/generated/model/blockedTaskError.ts new file mode 100644 index 00000000..e8294c59 --- /dev/null +++ b/frontend/src/api/generated/model/blockedTaskError.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v8.2.0 🍺 + * Do not edit manually. + * Mission Control API + * OpenAPI spec version: 0.1.0 + */ +import type { BlockedTaskDetail } from "./blockedTaskDetail"; + +export interface BlockedTaskError { + detail: BlockedTaskDetail; +} diff --git a/frontend/src/api/generated/model/index.ts b/frontend/src/api/generated/model/index.ts index 70886b64..c2f9c8be 100644 --- a/frontend/src/api/generated/model/index.ts +++ b/frontend/src/api/generated/model/index.ts @@ -29,6 +29,8 @@ export * from "./approvalReadPayload"; export * from "./approvalReadRubricScores"; export * from "./approvalReadStatus"; export * from "./approvalUpdate"; +export * from "./blockedTaskDetail"; +export * from "./blockedTaskError"; export * from "./boardCreate"; export * from "./boardCreateSuccessMetrics"; export * from "./boardMemoryCreate"; diff --git a/frontend/src/api/generated/model/taskCardRead.ts b/frontend/src/api/generated/model/taskCardRead.ts index 323eb22d..1abc9ca7 100644 --- a/frontend/src/api/generated/model/taskCardRead.ts +++ b/frontend/src/api/generated/model/taskCardRead.ts @@ -11,13 +11,16 @@ export interface TaskCardRead { approvals_pending_count?: number; assigned_agent_id?: string | null; assignee?: string | null; + blocked_by_task_ids?: string[]; board_id: string | null; created_at: string; created_by_user_id: string | null; + depends_on_task_ids?: string[]; description?: string | null; due_at?: string | null; id: string; in_progress_at: string | null; + is_blocked?: boolean; priority?: string; status?: TaskCardReadStatus; title: string; diff --git a/frontend/src/api/generated/model/taskCreate.ts b/frontend/src/api/generated/model/taskCreate.ts index df41dc66..62d1bcb7 100644 --- a/frontend/src/api/generated/model/taskCreate.ts +++ b/frontend/src/api/generated/model/taskCreate.ts @@ -9,6 +9,7 @@ import type { TaskCreateStatus } from "./taskCreateStatus"; export interface TaskCreate { assigned_agent_id?: string | null; created_by_user_id?: string | null; + depends_on_task_ids?: string[]; description?: string | null; due_at?: string | null; priority?: string; diff --git a/frontend/src/api/generated/model/taskRead.ts b/frontend/src/api/generated/model/taskRead.ts index 6a39611c..c8ef5050 100644 --- a/frontend/src/api/generated/model/taskRead.ts +++ b/frontend/src/api/generated/model/taskRead.ts @@ -8,13 +8,16 @@ import type { TaskReadStatus } from "./taskReadStatus"; export interface TaskRead { assigned_agent_id?: string | null; + blocked_by_task_ids?: string[]; board_id: string | null; created_at: string; created_by_user_id: string | null; + depends_on_task_ids?: string[]; description?: string | null; due_at?: string | null; id: string; in_progress_at: string | null; + is_blocked?: boolean; priority?: string; status?: TaskReadStatus; title: string; diff --git a/frontend/src/api/generated/model/taskUpdate.ts b/frontend/src/api/generated/model/taskUpdate.ts index faabf813..504507c5 100644 --- a/frontend/src/api/generated/model/taskUpdate.ts +++ b/frontend/src/api/generated/model/taskUpdate.ts @@ -8,6 +8,7 @@ export interface TaskUpdate { assigned_agent_id?: string | null; comment?: string | null; + depends_on_task_ids?: string[] | null; description?: string | null; due_at?: string | null; priority?: string | null; diff --git a/frontend/src/api/generated/tasks/tasks.ts b/frontend/src/api/generated/tasks/tasks.ts index 87729ab9..f63e69c9 100644 --- a/frontend/src/api/generated/tasks/tasks.ts +++ b/frontend/src/api/generated/tasks/tasks.ts @@ -21,6 +21,7 @@ import type { } from "@tanstack/react-query"; import type { + BlockedTaskError, HTTPValidationError, LimitOffsetPageTypeVarCustomizedTaskCommentRead, LimitOffsetPageTypeVarCustomizedTaskRead, @@ -278,6 +279,11 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponse200 = { status: 200; }; +export type createTaskApiV1BoardsBoardIdTasksPostResponse409 = { + data: BlockedTaskError; + status: 409; +}; + export type createTaskApiV1BoardsBoardIdTasksPostResponse422 = { data: HTTPValidationError; status: 422; @@ -287,10 +293,12 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponseSuccess = createTaskApiV1BoardsBoardIdTasksPostResponse200 & { headers: Headers; }; -export type createTaskApiV1BoardsBoardIdTasksPostResponseError = - createTaskApiV1BoardsBoardIdTasksPostResponse422 & { - headers: Headers; - }; +export type createTaskApiV1BoardsBoardIdTasksPostResponseError = ( + | createTaskApiV1BoardsBoardIdTasksPostResponse409 + | createTaskApiV1BoardsBoardIdTasksPostResponse422 +) & { + headers: Headers; +}; export type createTaskApiV1BoardsBoardIdTasksPostResponse = | createTaskApiV1BoardsBoardIdTasksPostResponseSuccess @@ -319,7 +327,7 @@ export const createTaskApiV1BoardsBoardIdTasksPost = async ( }; export const getCreateTaskApiV1BoardsBoardIdTasksPostMutationOptions = < - TError = HTTPValidationError, + TError = BlockedTaskError | HTTPValidationError, TContext = unknown, >(options?: { mutation?: UseMutationOptions< @@ -361,13 +369,14 @@ export type CreateTaskApiV1BoardsBoardIdTasksPostMutationResult = NonNullable< >; export type CreateTaskApiV1BoardsBoardIdTasksPostMutationBody = TaskCreate; export type CreateTaskApiV1BoardsBoardIdTasksPostMutationError = - HTTPValidationError; + | BlockedTaskError + | HTTPValidationError; /** * @summary Create Task */ export const useCreateTaskApiV1BoardsBoardIdTasksPost = < - TError = HTTPValidationError, + TError = BlockedTaskError | HTTPValidationError, TContext = unknown, >( options?: { @@ -776,6 +785,11 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 = { status: 200; }; +export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409 = { + data: BlockedTaskError; + status: 409; +}; + export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 = { data: HTTPValidationError; status: 422; @@ -785,10 +799,12 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess = updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 & { headers: Headers; }; -export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = - updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 & { - headers: Headers; - }; +export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = ( + | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409 + | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 +) & { + headers: Headers; +}; export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse = | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess @@ -819,7 +835,7 @@ export const updateTaskApiV1BoardsBoardIdTasksTaskIdPatch = async ( }; export const getUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationOptions = < - TError = HTTPValidationError, + TError = BlockedTaskError | HTTPValidationError, TContext = unknown, >(options?: { mutation?: UseMutationOptions< @@ -868,13 +884,14 @@ export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationResult = export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationBody = TaskUpdate; export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationError = - HTTPValidationError; + | BlockedTaskError + | HTTPValidationError; /** * @summary Update Task */ export const useUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatch = < - TError = HTTPValidationError, + TError = BlockedTaskError | HTTPValidationError, TContext = unknown, >( options?: { diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 8d7f33aa..110660a3 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -23,6 +23,9 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import DropdownSelect, { + type DropdownSelectOption, +} from "@/components/ui/dropdown-select"; import { Select, SelectContent, @@ -406,6 +409,9 @@ export default function BoardDetailPage() { const [editStatus, setEditStatus] = useState("inbox"); const [editPriority, setEditPriority] = useState("medium"); const [editAssigneeId, setEditAssigneeId] = useState(""); + const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState( + [], + ); const [isSavingTask, setIsSavingTask] = useState(false); const [saveTaskError, setSaveTaskError] = useState(null); @@ -796,6 +802,7 @@ export default function BoardDetailPage() { setEditStatus("inbox"); setEditPriority("medium"); setEditAssigneeId(""); + setEditDependsOnTaskIds([]); setSaveTaskError(null); return; } @@ -804,6 +811,7 @@ export default function BoardDetailPage() { setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); + setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setSaveTaskError(null); }, [selectedTask]); @@ -1165,6 +1173,14 @@ export default function BoardDetailPage() { return map; }, [tasks]); + const taskById = useMemo(() => { + const map = new Map(); + tasks.forEach((task) => { + map.set(task.id, task); + }); + return map; + }, [tasks]); + const orderedLiveFeed = useMemo(() => { return [...liveFeed].sort((a, b) => { const aTime = new Date(a.created_at).getTime(); @@ -1178,21 +1194,51 @@ export default function BoardDetailPage() { [agents], ); + const dependencyOptions = useMemo(() => { + if (!selectedTask) return []; + const alreadySelected = new Set(editDependsOnTaskIds); + return tasks + .filter((task) => task.id !== selectedTask.id) + .map((task) => ({ + value: task.id, + label: `${task.title} (${task.status.replace(/_/g, " ")})`, + disabled: alreadySelected.has(task.id), + })); + }, [editDependsOnTaskIds, selectedTask, tasks]); + + const addTaskDependency = useCallback((dependencyId: string) => { + setEditDependsOnTaskIds((prev) => + prev.includes(dependencyId) ? prev : [...prev, dependencyId], + ); + }, []); + + const removeTaskDependency = useCallback((dependencyId: string) => { + setEditDependsOnTaskIds((prev) => + prev.filter((value) => value !== dependencyId), + ); + }, []); + const hasTaskChanges = useMemo(() => { if (!selectedTask) return false; const normalizedTitle = editTitle.trim(); const normalizedDescription = editDescription.trim(); const currentDescription = (selectedTask.description ?? "").trim(); const currentAssignee = selectedTask.assigned_agent_id ?? ""; + const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])] + .sort() + .join("|"); + const nextDeps = [...editDependsOnTaskIds].sort().join("|"); return ( normalizedTitle !== selectedTask.title || normalizedDescription !== currentDescription || editStatus !== selectedTask.status || editPriority !== selectedTask.priority || - editAssigneeId !== currentAssignee + editAssigneeId !== currentAssignee || + currentDeps !== nextDeps ); }, [ editAssigneeId, + editDependsOnTaskIds, editDescription, editPriority, editStatus, @@ -1348,18 +1394,49 @@ export default function BoardDetailPage() { setIsSavingTask(true); setSaveTaskError(null); try { + const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])] + .sort() + .join("|"); + const nextDeps = [...editDependsOnTaskIds].sort().join("|"); + const depsChanged = currentDeps !== nextDeps; + + const updatePayload: Parameters< + typeof updateTaskApiV1BoardsBoardIdTasksTaskIdPatch + >[2] = { + title: trimmedTitle, + description: editDescription.trim() || null, + status: editStatus, + priority: editPriority, + assigned_agent_id: editAssigneeId || null, + }; + + if (depsChanged && selectedTask.status !== "done") { + updatePayload.depends_on_task_ids = editDependsOnTaskIds; + } + const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch( boardId, selectedTask.id, - { - title: trimmedTitle, - description: editDescription.trim() || null, - status: editStatus, - priority: editPriority, - assigned_agent_id: editAssigneeId || null, - }, + updatePayload, ); - if (result.status !== 200) throw new Error("Unable to update task."); + if (result.status === 409) { + const blockedIds = result.data.detail.blocked_by_task_ids ?? []; + const blockedTitles = blockedIds + .map((id) => taskTitleById.get(id) ?? id) + .join(", "); + setSaveTaskError( + blockedTitles + ? `${result.data.detail.message} Blocked by: ${blockedTitles}` + : result.data.detail.message, + ); + return; + } + if (result.status === 422) { + setSaveTaskError( + result.data.detail?.[0]?.msg ?? "Validation error while saving task.", + ); + return; + } const previous = tasksRef.current.find((task) => task.id === selectedTask.id) ?? selectedTask; @@ -1393,6 +1470,7 @@ export default function BoardDetailPage() { setEditStatus(selectedTask.status); setEditPriority(selectedTask.priority); setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); + setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []); setSaveTaskError(null); }; @@ -1422,6 +1500,10 @@ export default function BoardDetailPage() { if (!isSignedIn || !boardId) return; const currentTask = tasksRef.current.find((task) => task.id === taskId); if (!currentTask || currentTask.status === status) return; + if (currentTask.is_blocked && status !== "inbox") { + setError("Task is blocked by incomplete dependencies."); + return; + } const previousTasks = tasksRef.current; setTasks((prev) => prev.map((task) => @@ -1442,7 +1524,22 @@ export default function BoardDetailPage() { taskId, { status }, ); - if (result.status !== 200) throw new Error("Unable to move task."); + if (result.status === 409) { + const blockedIds = result.data.detail.blocked_by_task_ids ?? []; + const blockedTitles = blockedIds + .map((id) => taskTitleById.get(id) ?? id) + .join(", "); + throw new Error( + blockedTitles + ? `${result.data.detail.message} Blocked by: ${blockedTitles}` + : result.data.detail.message, + ); + } + if (result.status === 422) { + throw new Error( + result.data.detail?.[0]?.msg ?? "Validation error while moving task.", + ); + } const assignee = result.data.assigned_agent_id ? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id) ?.name ?? null @@ -1461,7 +1558,7 @@ export default function BoardDetailPage() { setTasks(previousTasks); setError(err instanceof Error ? err.message : "Unable to move task."); } - }, [boardId, isSignedIn]); + }, [boardId, isSignedIn, taskTitleById]); const agentInitials = (agent: Agent) => agent.name @@ -1980,6 +2077,68 @@ export default function BoardDetailPage() {

No description provided.

)} +
+

+ Dependencies +

+ {selectedTask?.depends_on_task_ids?.length ? ( +
+ {selectedTask.depends_on_task_ids.map((depId) => { + const depTask = taskById.get(depId); + const title = depTask?.title ?? depId; + const statusLabel = depTask?.status + ? depTask.status.replace(/_/g, " ") + : "unknown"; + const isDone = depTask?.status === "done"; + const isBlocking = ( + selectedTask.blocked_by_task_ids ?? [] + ).includes(depId); + return ( + + ); + })} +
+ ) : ( +

No dependencies.

+ )} + {selectedTask?.is_blocked ? ( +
+ Blocked by incomplete dependencies. +
+ ) : null} +

@@ -2333,6 +2492,73 @@ export default function BoardDetailPage() {

) : null}
+
+ +

+ Tasks stay blocked until every dependency is marked done. +

+ + {selectedTask?.status === "done" ? ( +

+ Dependencies can only be edited until the task is done. +

+ ) : null} + {editDependsOnTaskIds.length === 0 ? ( +

No dependencies.

+ ) : ( +
+ {editDependsOnTaskIds.map((depId) => { + const depTask = taskById.get(depId); + const label = depTask?.title ?? depId; + const statusLabel = depTask?.status + ? depTask.status.replace(/_/g, " ") + : null; + const isDone = depTask?.status === "done"; + return ( + + {label} + {statusLabel ? ( + + {statusLabel} + + ) : null} + {selectedTask?.status !== "done" ? ( + + ) : null} + + ); + })} +
+ )} +
{saveTaskError ? (
{saveTaskError} diff --git a/frontend/src/components/molecules/TaskCard.tsx b/frontend/src/components/molecules/TaskCard.tsx index 24b5a9af..db1ab474 100644 --- a/frontend/src/components/molecules/TaskCard.tsx +++ b/frontend/src/components/molecules/TaskCard.tsx @@ -8,6 +8,8 @@ interface TaskCardProps { assignee?: string; due?: string; approvalsPendingCount?: number; + isBlocked?: boolean; + blockedByCount?: number; onClick?: () => void; draggable?: boolean; isDragging?: boolean; @@ -21,6 +23,8 @@ export function TaskCard({ assignee, due, approvalsPendingCount = 0, + isBlocked = false, + blockedByCount = 0, onClick, draggable = false, isDragging = false, @@ -28,6 +32,11 @@ export function TaskCard({ onDragEnd, }: TaskCardProps) { const hasPendingApproval = approvalsPendingCount > 0; + const leftBarClassName = isBlocked + ? "bg-rose-400" + : hasPendingApproval + ? "bg-amber-400" + : null; const priorityBadge = (value?: string) => { if (!value) return null; const normalized = value.toLowerCase(); @@ -51,6 +60,7 @@ export function TaskCard({ "group relative cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md", isDragging && "opacity-60 shadow-none", hasPendingApproval && "border-amber-200 bg-amber-50/40", + isBlocked && "border-rose-200 bg-rose-50/50", )} draggable={draggable} onDragStart={onDragStart} @@ -65,12 +75,23 @@ export function TaskCard({ } }} > - {hasPendingApproval ? ( - + {leftBarClassName ? ( + ) : null}

{title}

+ {isBlocked ? ( +
+ + Blocked{blockedByCount > 0 ? ` · ${blockedByCount}` : ""} +
+ ) : null} {hasPendingApproval ? (
diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index 5c0cbcf7..e9aeb31d 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -17,6 +17,9 @@ type Task = { assigned_agent_id?: string | null; assignee?: string | null; approvals_pending_count?: number; + depends_on_task_ids?: string[]; + blocked_by_task_ids?: string[]; + is_blocked?: boolean; }; type TaskBoardProps = { @@ -253,6 +256,10 @@ export const TaskBoard = memo(function TaskBoard({ const handleDragStart = (task: Task) => (event: React.DragEvent) => { + if (task.is_blocked) { + event.preventDefault(); + return; + } setDraggingId(task.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData( @@ -342,8 +349,10 @@ export const TaskBoard = memo(function TaskBoard({ assignee={task.assignee ?? undefined} due={formatDueDate(task.due_at)} approvalsPendingCount={task.approvals_pending_count} + isBlocked={task.is_blocked} + blockedByCount={task.blocked_by_task_ids?.length ?? 0} onClick={() => onTaskSelect?.(task)} - draggable + draggable={!task.is_blocked} isDragging={draggingId === task.id} onDragStart={handleDragStart(task)} onDragEnd={handleDragEnd}