feat: implement task dependencies with validation and update handling

This commit is contained in:
Abhimanyu Saharan
2026-02-07 00:21:44 +05:30
parent 8970ee6742
commit 4bab455912
34 changed files with 1241 additions and 157 deletions

View File

@@ -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")

View File

@@ -26,8 +26,15 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.task_dependencies import TaskDependency
from app.models.tasks import Task 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.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboardingRead 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.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity 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"]) router = APIRouter(prefix="/agent", tags=["agent"])
@@ -131,14 +143,39 @@ async def create_task(
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> Task: ) -> TaskRead:
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead: if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) 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.board_id = board.id
task.auto_created = True task.auto_created = True
task.auto_reason = f"lead_agent:{agent_ctx.agent.id}" 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: if task.assigned_agent_id:
agent = await session.get(Agent, task.assigned_agent_id) agent = await session.get(Agent, task.assigned_agent_id)
if agent is None: if agent is None:
@@ -151,6 +188,14 @@ async def create_task(
if agent.board_id and agent.board_id != board.id: if agent.board_id and agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
session.add(task) 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.commit()
await session.refresh(task) await session.refresh(task)
record_activity( record_activity(
@@ -170,7 +215,13 @@ async def create_task(
task=task, task=task,
agent=assigned_agent, 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) @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), task: Task = Depends(get_task_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), 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: 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) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await tasks_api.update_task( return await tasks_api.update_task(

View File

@@ -27,7 +27,6 @@ from app.models.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.common import OkResponse
from app.schemas.agents import ( from app.schemas.agents import (
AgentCreate, AgentCreate,
AgentHeartbeat, AgentHeartbeat,
@@ -35,6 +34,7 @@ from app.schemas.agents import (
AgentRead, AgentRead,
AgentUpdate, AgentUpdate,
) )
from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.agent_provisioning import ( from app.services.agent_provisioning import (
@@ -97,7 +97,9 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) ->
return board 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: if not board.gateway_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -155,7 +157,9 @@ async def _find_gateway_for_main_session(
) -> Gateway | None: ) -> Gateway | None:
if not session_key: if not session_key:
return None 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( 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() action_label = action.replace("_", " ").capitalize()
record_activity( record_activity(
session, session,
@@ -275,7 +281,9 @@ async def stream_agents(
break break
async with async_session_maker() as session: async with async_session_maker() as session:
agents = await _fetch_agent_events(session, board_id, last_seen) 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: for agent in agents:
updated_at = agent.updated_at or agent.last_seen_at or utcnow() updated_at = agent.updated_at or agent.last_seen_at or utcnow()
if updated_at > last_seen: if updated_at > last_seen:

View File

@@ -131,7 +131,9 @@ async def stream_approvals(
) )
).one() ).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]] = {} counts_by_task_id: dict[UUID, tuple[int, int]] = {}
if task_ids: if task_ids:
rows = list( rows = list(

View File

@@ -77,17 +77,15 @@ async def _fetch_memory_events(
is_chat: bool | None = None, is_chat: bool | None = None,
) -> list[BoardMemory]: ) -> list[BoardMemory]:
statement = ( statement = (
select(BoardMemory) select(BoardMemory).where(col(BoardMemory.board_id) == board_id)
.where(col(BoardMemory.board_id) == board_id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
# satisfy the NonEmptyStr response schema. # satisfy the NonEmptyStr response schema.
.where(func.length(func.trim(col(BoardMemory.content))) > 0) .where(func.length(func.trim(col(BoardMemory.content))) > 0)
) )
if is_chat is not None: if is_chat is not None:
statement = statement.where(col(BoardMemory.is_chat) == is_chat) statement = statement.where(col(BoardMemory.is_chat) == is_chat)
statement = ( statement = statement.where(col(BoardMemory.created_at) >= since).order_by(
statement.where(col(BoardMemory.created_at) >= since) col(BoardMemory.created_at)
.order_by(col(BoardMemory.created_at))
) )
return list(await session.exec(statement)) 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: if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = ( statement = (
select(BoardMemory) select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
.where(col(BoardMemory.board_id) == board.id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to # Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
# satisfy the NonEmptyStr response schema. # satisfy the NonEmptyStr response schema.
.where(func.length(func.trim(col(BoardMemory.content))) > 0) .where(func.length(func.trim(col(BoardMemory.content))) > 0)

View File

@@ -22,9 +22,9 @@ from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.schemas.board_onboarding import ( from app.schemas.board_onboarding import (
BoardOnboardingAnswer,
BoardOnboardingAgentComplete, BoardOnboardingAgentComplete,
BoardOnboardingAgentUpdate, BoardOnboardingAgentUpdate,
BoardOnboardingAnswer,
BoardOnboardingConfirm, BoardOnboardingConfirm,
BoardOnboardingLeadAgentDraft, BoardOnboardingLeadAgentDraft,
BoardOnboardingRead, BoardOnboardingRead,
@@ -251,9 +251,7 @@ async def answer_onboarding(
answer_text = f"{payload.answer}: {payload.other_text}" answer_text = f"{payload.answer}: {payload.other_text}"
messages = list(onboarding.messages or []) messages = list(onboarding.messages or [])
messages.append( messages.append({"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()})
{"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()}
)
try: try:
await ensure_session(onboarding.session_key, config=config, label="Main Agent") await ensure_session(onboarding.session_key, config=config, label="Main Agent")

View File

@@ -4,8 +4,7 @@ import re
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete from sqlalchemy import delete, func
from sqlalchemy import func
from sqlmodel import col, select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession 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.gateways import Gateway
from app.models.task_fingerprints import TaskFingerprint from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.common import OkResponse
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardSnapshot from app.schemas.view_models import BoardSnapshot
from app.services.board_snapshot import build_board_snapshot from app.services.board_snapshot import build_board_snapshot
@@ -229,10 +228,14 @@ async def delete_board(
if task_ids: if task_ids:
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(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: if agents:
agent_ids = [agent.id for agent in 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(Agent).where(col(Agent.id).in_(agent_ids)))
await session.execute(delete(Approval).where(col(Approval.board_id) == board.id)) await session.execute(delete(Approval).where(col(Approval.board_id) == board.id))
await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id)) await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id))

View File

@@ -3,16 +3,16 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from collections import deque from collections import deque
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Sequence
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import cast from typing import cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status 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 import col, select
from sqlmodel.sql.expression import Select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql.expression import Select
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from app.api.deps import ( from app.api.deps import (
@@ -33,13 +33,23 @@ from app.models.agents import Agent
from app.models.approvals import Approval from app.models.approvals import Approval
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task from app.models.tasks import Task
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
from app.schemas.errors import BlockedTaskError
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention 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"]) 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( async def has_valid_recent_comment(
session: AsyncSession, session: AsyncSession,
task: Task, task: Task,
@@ -124,6 +144,75 @@ def _lead_created_task(task: Task, lead: Agent) -> bool:
return task.auto_reason == f"lead_agent:{lead.id}" 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( async def _fetch_task_events(
session: AsyncSession, session: AsyncSession,
board_id: UUID, board_id: UUID,
@@ -144,12 +233,6 @@ async def _fetch_task_events(
return list(await session.exec(statement)) 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]: def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
return TaskCommentRead.model_validate(event).model_dump(mode="json") return TaskCommentRead.model_validate(event).model_dump(mode="json")
@@ -372,8 +455,30 @@ async def stream_tasks(
while True: while True:
if await request.is_disconnected(): if await request.is_disconnected():
break break
deps_map: dict[UUID, list[UUID]] = {}
dep_status: dict[UUID, str] = {}
async with async_session_maker() as session: async with async_session_maker() as session:
rows = await _fetch_task_events(session, board.id, last_seen) 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: for event, task in rows:
if event.id in seen_ids: if event.id in seen_ids:
continue continue
@@ -388,7 +493,27 @@ async def stream_tasks(
if event.event_type == "task.comment": if event.event_type == "task.comment":
payload["comment"] = _serialize_comment(event) payload["comment"] = _serialize_comment(event)
else: 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)} yield {"event": "task", "data": json.dumps(payload)}
await asyncio.sleep(2) await asyncio.sleep(2)
@@ -422,21 +547,80 @@ async def list_tasks(
if unassigned: if unassigned:
statement = statement.where(col(Task.assigned_agent_id).is_(None)) statement = statement.where(col(Task.assigned_agent_id).is_(None))
statement = statement.order_by(col(Task.created_at).desc()) 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( async def create_task(
payload: TaskCreate, payload: TaskCreate,
board: Board = Depends(get_board_or_404), board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth), auth: AuthContext = Depends(require_admin_auth),
) -> Task: ) -> TaskRead:
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.board_id = board.id
if task.created_by_user_id is None and auth.user is not None: if task.created_by_user_id is None and auth.user is not None:
task.created_by_user_id = auth.user.id 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) 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.commit()
await session.refresh(task) await session.refresh(task)
@@ -457,59 +641,128 @@ async def create_task(
task=task, task=task,
agent=assigned_agent, 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( async def update_task(
payload: TaskUpdate, payload: TaskUpdate,
task: Task = Depends(get_task_or_404), task: Task = Depends(get_task_or_404),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent), 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_status = task.status
previous_assigned = task.assigned_agent_id previous_assigned = task.assigned_agent_id
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None) 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: if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
allowed_fields = {"assigned_agent_id", "status"} allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids"}
if comment is not None or not set(updates).issubset(allowed_fields): if comment is not None or not requested_fields.issubset(allowed_fields):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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"] normalized_deps: list[UUID] | None = None
if assigned_id: if depends_on_task_ids is not None:
agent = await session.get(Agent, assigned_id) if task.status == "done":
if agent is None: raise HTTPException(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) status_code=status.HTTP_409_CONFLICT,
if agent.is_board_lead: 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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: if updates["status"] not in {"done", "inbox"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(
task.assigned_agent_id = agent.id status_code=status.HTTP_403_FORBIDDEN,
else: detail="Board leads can only move review tasks to done or inbox.",
task.assigned_agent_id = None )
if "status" in updates: if updates["status"] == "inbox":
if task.status != "review": task.assigned_agent_id = None
raise HTTPException( task.in_progress_at = None
status_code=status.HTTP_403_FORBIDDEN, task.status = updates["status"]
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"]
task.updated_at = utcnow() task.updated_at = utcnow()
session.add(task) session.add(task)
if task.status != previous_status: if task.status != previous_status:
@@ -525,12 +778,17 @@ async def update_task(
message=message, message=message,
agent_id=actor.agent.id, 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.commit()
await session.refresh(task) await session.refresh(task)
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned: 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) assigned_agent = await session.get(Agent, task.assigned_agent_id)
if assigned_agent: if assigned_agent:
board = await session.get(Board, task.board_id) if task.board_id else None board = await session.get(Board, task.board_id) if task.board_id else None
@@ -541,15 +799,33 @@ async def update_task(
task=task, task=task,
agent=assigned_agent, 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.actor_type == "agent":
if actor.agent and actor.agent.board_id and task.board_id: if actor.agent and actor.agent.board_id and task.board_id:
if actor.agent.board_id != task.board_id: if actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_fields = {"status", "comment"} 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) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates: 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": if updates["status"] == "inbox":
task.assigned_agent_id = None task.assigned_agent_id = None
task.in_progress_at = 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 task.assigned_agent_id = actor.agent.id if actor.agent else None
if updates["status"] == "in_progress": if updates["status"] == "in_progress":
task.in_progress_at = utcnow() task.in_progress_at = utcnow()
elif "status" in updates: else:
if updates["status"] == "inbox": # 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.assigned_agent_id = None
task.in_progress_at = None task.in_progress_at = None
elif updates["status"] == "in_progress": updates["status"] = "inbox"
task.in_progress_at = utcnow() updates["assigned_agent_id"] = None
if "assigned_agent_id" in updates and updates["assigned_agent_id"]:
agent = await session.get(Agent, updates["assigned_agent_id"]) if "status" in updates:
if agent is None: if updates["status"] == "inbox":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) task.assigned_agent_id = None
if agent.board_id and task.board_id and agent.board_id != task.board_id: task.in_progress_at = None
raise HTTPException(status_code=status.HTTP_409_CONFLICT) 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(): for key, value in updates.items():
setattr(task, key, value) setattr(task, key, value)
task.updated_at = utcnow() task.updated_at = utcnow()
@@ -606,14 +915,23 @@ async def update_task(
else: else:
event_type = "task.updated" event_type = "task.updated"
message = f"Task updated: {task.title}." message = f"Task updated: {task.title}."
actor_agent_id = actor.agent.id if actor.actor_type == "agent" and actor.agent else None
record_activity( record_activity(
session, session,
event_type=event_type, event_type=event_type,
task_id=task.id, task_id=task.id,
message=message, 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() await session.commit()
if task.status == "inbox" and task.assigned_agent_id is None: if task.status == "inbox" and task.assigned_agent_id is None:
if previous_status != "inbox" or previous_assigned is not 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 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 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: if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id:
return task # Don't notify the actor about their own assignment.
assigned_agent = await session.get(Agent, task.assigned_agent_id) pass
if assigned_agent: else:
board = await session.get(Board, task.board_id) if task.board_id else None assigned_agent = await session.get(Agent, task.assigned_agent_id)
if board: if assigned_agent:
await _notify_agent_on_task_assign( board = await session.get(Board, task.board_id) if task.board_id else None
session=session, if board:
board=board, await _notify_agent_on_task_assign(
task=task, session=session,
agent=assigned_agent, board=board,
) task=task,
return 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) @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(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.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(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.delete(task)
await session.commit() await session.commit()
return OkResponse() return OkResponse()

View File

@@ -22,9 +22,7 @@ class AgentAuthContext:
async def _find_agent_for_token(session: AsyncSession, token: str) -> Agent | None: async def _find_agent_for_token(session: AsyncSession, token: str) -> Agent | None:
agents = list( agents = list(await session.exec(select(Agent).where(col(Agent.agent_token_hash).is_not(None))))
await session.exec(select(Agent).where(col(Agent.agent_token_hash).is_not(None)))
)
for agent in agents: for agent in agents:
if agent.agent_token_hash and verify_agent_token(token, agent.agent_token_hash): if agent.agent_token_hash and verify_agent_token(token, agent.agent_token_hash):
return agent return agent

View File

@@ -34,4 +34,3 @@ def parse_every_to_seconds(value: str) -> int:
if seconds > 60 * 60 * 24 * 365 * 10: if seconds > 60 * 60 * 24 * 365 * 10:
raise ValueError("Schedule is too large (max 10 years).") raise ValueError("Schedule is too large (max 10 years).")
return seconds return seconds

View File

@@ -5,6 +5,7 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task from app.models.tasks import Task
from app.models.users import User from app.models.users import User
@@ -17,6 +18,7 @@ __all__ = [
"BoardOnboardingSession", "BoardOnboardingSession",
"Board", "Board",
"Gateway", "Gateway",
"TaskDependency",
"Task", "Task",
"TaskFingerprint", "TaskFingerprint",
"User", "User",

View File

@@ -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)

View File

@@ -5,8 +5,8 @@ from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
from app.models.tenancy import TenantScoped
from app.core.time import utcnow from app.core.time import utcnow
from app.models.tenancy import TenantScoped
class Task(TenantScoped, table=True): class Task(TenantScoped, table=True):

View File

@@ -7,7 +7,6 @@ from uuid import UUID
from pydantic import model_validator from pydantic import model_validator
from sqlmodel import SQLModel from sqlmodel import SQLModel
ApprovalStatus = Literal["pending", "approved", "rejected"] ApprovalStatus = Literal["pending", "approved", "rejected"]

View File

@@ -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

View File

@@ -5,11 +5,10 @@ from typing import Any, Literal, Self
from uuid import UUID from uuid import UUID
from pydantic import field_validator, model_validator from pydantic import field_validator, model_validator
from sqlmodel import SQLModel from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr from app.schemas.common import NonEmptyStr
TaskStatus = Literal["inbox", "in_progress", "review", "done"] TaskStatus = Literal["inbox", "in_progress", "review", "done"]
@@ -20,6 +19,7 @@ class TaskBase(SQLModel):
priority: str = "medium" priority: str = "medium"
due_at: datetime | None = None due_at: datetime | None = None
assigned_agent_id: UUID | None = None assigned_agent_id: UUID | None = None
depends_on_task_ids: list[UUID] = Field(default_factory=list)
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
@@ -33,6 +33,7 @@ class TaskUpdate(SQLModel):
priority: str | None = None priority: str | None = None
due_at: datetime | None = None due_at: datetime | None = None
assigned_agent_id: UUID | None = None assigned_agent_id: UUID | None = None
depends_on_task_ids: list[UUID] | None = None
comment: NonEmptyStr | None = None comment: NonEmptyStr | None = None
@field_validator("comment", mode="before") @field_validator("comment", mode="before")
@@ -58,6 +59,8 @@ class TaskRead(TaskBase):
in_progress_at: datetime | None in_progress_at: datetime | None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
blocked_by_task_ids: list[UUID] = Field(default_factory=list)
is_blocked: bool = False
class TaskCommentCreate(SQLModel): class TaskCommentCreate(SQLModel):

View File

@@ -338,9 +338,7 @@ def _render_agent_files(
rendered[name] = env.from_string(override).render(**context).strip() rendered[name] = env.from_string(override).render(**context).strip()
continue continue
template_name = ( template_name = (
template_overrides[name] template_overrides[name] if template_overrides and name in template_overrides else name
if template_overrides and name in template_overrides
else name
) )
path = _templates_root() / template_name path = _templates_root() / template_name
if path.exists(): if path.exists():

View File

@@ -19,6 +19,11 @@ from app.schemas.approvals import ApprovalRead
from app.schemas.board_memory import BoardMemoryRead from app.schemas.board_memory import BoardMemoryRead
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.view_models import BoardSnapshot, TaskCardRead 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) 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: def _agent_to_read(agent: Agent, main_session_keys: set[str]) -> AgentRead:
model = AgentRead.model_validate(agent, from_attributes=True) model = AgentRead.model_validate(agent, from_attributes=True)
computed_status = _computed_agent_status(agent) 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}) 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], agent_name_by_id: dict[UUID, str],
counts_by_task_id: dict[UUID, tuple[int, int]], 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: ) -> TaskCardRead:
card = TaskCardRead.model_validate(task, from_attributes=True) card = TaskCardRead.model_validate(task, from_attributes=True)
approvals_count, approvals_pending_count = counts_by_task_id.get(task.id, (0, 0)) approvals_count, approvals_pending_count = counts_by_task_id.get(task.id, (0, 0))
assignee = ( assignee = (
agent_name_by_id.get(task.assigned_agent_id) if task.assigned_agent_id is not None else None 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( return card.model_copy(
update={ update={
"assignee": assignee, "assignee": assignee,
"approvals_count": approvals_count, "approvals_count": approvals_count,
"approvals_pending_count": approvals_pending_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()) 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) main_session_keys = await _gateway_main_session_keys(session)
agents = list( agents = list(
await session.exec( 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_reads = [_agent_to_read(agent, main_session_keys) for agent in agents]
agent_name_by_id = {agent.id: agent.name for agent in agents} agent_name_by_id = {agent.id: agent.name for agent in agents}
pending_approvals_count = int( pending_approvals_count = int(
(await session.exec( (
select(func.count(col(Approval.id))) await session.exec(
.where(col(Approval.board_id) == board.id) select(func.count(col(Approval.id)))
.where(col(Approval.status) == "pending") .where(col(Approval.board_id) == board.id)
)).one() .where(col(Approval.status) == "pending")
)
).one()
) )
approvals = list( 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)) counts_by_task_id[task_id] = (int(total or 0), int(pending or 0))
task_cards = [ 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 for task in tasks
] ]

View File

@@ -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)

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
import sys import sys
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1] BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))

View File

@@ -1,6 +1,7 @@
import pytest
from uuid import uuid4 from uuid import uuid4
import pytest
from app.schemas.board_onboarding import BoardOnboardingConfirm from app.schemas.board_onboarding import BoardOnboardingConfirm
from app.schemas.boards import BoardCreate from app.schemas.boards import BoardCreate

View File

@@ -17,4 +17,3 @@ def test_matches_agent_mention_supports_reserved_lead_shortcut():
other = Agent(name="Lead", is_board_lead=False) other = Agent(name="Lead", is_board_lead=False)
assert matches_agent_mention(lead, {"lead"}) is True assert matches_agent_mention(lead, {"lead"}) is True
assert matches_agent_mention(other, {"lead"}) is False assert matches_agent_mention(other, {"lead"}) is False

View File

@@ -4,14 +4,12 @@ from dataclasses import dataclass
from starlette.requests import Request from starlette.requests import Request
@dataclass @dataclass
class ClerkConfig: class ClerkConfig:
jwks_url: str jwks_url: str
verify_iat: bool = ... verify_iat: bool = ...
leeway: float = ... leeway: float = ...
class HTTPAuthorizationCredentials: class HTTPAuthorizationCredentials:
scheme: str scheme: str
credentials: str credentials: str
@@ -24,7 +22,6 @@ class HTTPAuthorizationCredentials:
decoded: dict[str, object] | None = ..., decoded: dict[str, object] | None = ...,
) -> None: ... ) -> None: ...
class ClerkHTTPBearer: class ClerkHTTPBearer:
def __init__( def __init__(
self, self,
@@ -32,6 +29,4 @@ class ClerkHTTPBearer:
auto_error: bool = ..., auto_error: bool = ...,
add_state: bool = ..., add_state: bool = ...,
) -> None: ... ) -> None: ...
async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: ... async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: ...

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -29,6 +29,8 @@ export * from "./approvalReadPayload";
export * from "./approvalReadRubricScores"; export * from "./approvalReadRubricScores";
export * from "./approvalReadStatus"; export * from "./approvalReadStatus";
export * from "./approvalUpdate"; export * from "./approvalUpdate";
export * from "./blockedTaskDetail";
export * from "./blockedTaskError";
export * from "./boardCreate"; export * from "./boardCreate";
export * from "./boardCreateSuccessMetrics"; export * from "./boardCreateSuccessMetrics";
export * from "./boardMemoryCreate"; export * from "./boardMemoryCreate";

View File

@@ -11,13 +11,16 @@ export interface TaskCardRead {
approvals_pending_count?: number; approvals_pending_count?: number;
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
assignee?: string | null; assignee?: string | null;
blocked_by_task_ids?: string[];
board_id: string | null; board_id: string | null;
created_at: string; created_at: string;
created_by_user_id: string | null; created_by_user_id: string | null;
depends_on_task_ids?: string[];
description?: string | null; description?: string | null;
due_at?: string | null; due_at?: string | null;
id: string; id: string;
in_progress_at: string | null; in_progress_at: string | null;
is_blocked?: boolean;
priority?: string; priority?: string;
status?: TaskCardReadStatus; status?: TaskCardReadStatus;
title: string; title: string;

View File

@@ -9,6 +9,7 @@ import type { TaskCreateStatus } from "./taskCreateStatus";
export interface TaskCreate { export interface TaskCreate {
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
created_by_user_id?: string | null; created_by_user_id?: string | null;
depends_on_task_ids?: string[];
description?: string | null; description?: string | null;
due_at?: string | null; due_at?: string | null;
priority?: string; priority?: string;

View File

@@ -8,13 +8,16 @@ import type { TaskReadStatus } from "./taskReadStatus";
export interface TaskRead { export interface TaskRead {
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
blocked_by_task_ids?: string[];
board_id: string | null; board_id: string | null;
created_at: string; created_at: string;
created_by_user_id: string | null; created_by_user_id: string | null;
depends_on_task_ids?: string[];
description?: string | null; description?: string | null;
due_at?: string | null; due_at?: string | null;
id: string; id: string;
in_progress_at: string | null; in_progress_at: string | null;
is_blocked?: boolean;
priority?: string; priority?: string;
status?: TaskReadStatus; status?: TaskReadStatus;
title: string; title: string;

View File

@@ -8,6 +8,7 @@
export interface TaskUpdate { export interface TaskUpdate {
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
comment?: string | null; comment?: string | null;
depends_on_task_ids?: string[] | null;
description?: string | null; description?: string | null;
due_at?: string | null; due_at?: string | null;
priority?: string | null; priority?: string | null;

View File

@@ -21,6 +21,7 @@ import type {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import type { import type {
BlockedTaskError,
HTTPValidationError, HTTPValidationError,
LimitOffsetPageTypeVarCustomizedTaskCommentRead, LimitOffsetPageTypeVarCustomizedTaskCommentRead,
LimitOffsetPageTypeVarCustomizedTaskRead, LimitOffsetPageTypeVarCustomizedTaskRead,
@@ -278,6 +279,11 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponse200 = {
status: 200; status: 200;
}; };
export type createTaskApiV1BoardsBoardIdTasksPostResponse409 = {
data: BlockedTaskError;
status: 409;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponse422 = { export type createTaskApiV1BoardsBoardIdTasksPostResponse422 = {
data: HTTPValidationError; data: HTTPValidationError;
status: 422; status: 422;
@@ -287,10 +293,12 @@ export type createTaskApiV1BoardsBoardIdTasksPostResponseSuccess =
createTaskApiV1BoardsBoardIdTasksPostResponse200 & { createTaskApiV1BoardsBoardIdTasksPostResponse200 & {
headers: Headers; headers: Headers;
}; };
export type createTaskApiV1BoardsBoardIdTasksPostResponseError = export type createTaskApiV1BoardsBoardIdTasksPostResponseError = (
createTaskApiV1BoardsBoardIdTasksPostResponse422 & { | createTaskApiV1BoardsBoardIdTasksPostResponse409
headers: Headers; | createTaskApiV1BoardsBoardIdTasksPostResponse422
}; ) & {
headers: Headers;
};
export type createTaskApiV1BoardsBoardIdTasksPostResponse = export type createTaskApiV1BoardsBoardIdTasksPostResponse =
| createTaskApiV1BoardsBoardIdTasksPostResponseSuccess | createTaskApiV1BoardsBoardIdTasksPostResponseSuccess
@@ -319,7 +327,7 @@ export const createTaskApiV1BoardsBoardIdTasksPost = async (
}; };
export const getCreateTaskApiV1BoardsBoardIdTasksPostMutationOptions = < export const getCreateTaskApiV1BoardsBoardIdTasksPostMutationOptions = <
TError = HTTPValidationError, TError = BlockedTaskError | HTTPValidationError,
TContext = unknown, TContext = unknown,
>(options?: { >(options?: {
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
@@ -361,13 +369,14 @@ export type CreateTaskApiV1BoardsBoardIdTasksPostMutationResult = NonNullable<
>; >;
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationBody = TaskCreate; export type CreateTaskApiV1BoardsBoardIdTasksPostMutationBody = TaskCreate;
export type CreateTaskApiV1BoardsBoardIdTasksPostMutationError = export type CreateTaskApiV1BoardsBoardIdTasksPostMutationError =
HTTPValidationError; | BlockedTaskError
| HTTPValidationError;
/** /**
* @summary Create Task * @summary Create Task
*/ */
export const useCreateTaskApiV1BoardsBoardIdTasksPost = < export const useCreateTaskApiV1BoardsBoardIdTasksPost = <
TError = HTTPValidationError, TError = BlockedTaskError | HTTPValidationError,
TContext = unknown, TContext = unknown,
>( >(
options?: { options?: {
@@ -776,6 +785,11 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 = {
status: 200; status: 200;
}; };
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409 = {
data: BlockedTaskError;
status: 409;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 = { export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 = {
data: HTTPValidationError; data: HTTPValidationError;
status: 422; status: 422;
@@ -785,10 +799,12 @@ export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess =
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 & { updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse200 & {
headers: Headers; headers: Headers;
}; };
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseError = (
updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422 & { | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse409
headers: Headers; | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse422
}; ) & {
headers: Headers;
};
export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse = export type updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponse =
| updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess | updateTaskApiV1BoardsBoardIdTasksTaskIdPatchResponseSuccess
@@ -819,7 +835,7 @@ export const updateTaskApiV1BoardsBoardIdTasksTaskIdPatch = async (
}; };
export const getUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationOptions = < export const getUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationOptions = <
TError = HTTPValidationError, TError = BlockedTaskError | HTTPValidationError,
TContext = unknown, TContext = unknown,
>(options?: { >(options?: {
mutation?: UseMutationOptions< mutation?: UseMutationOptions<
@@ -868,13 +884,14 @@ export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationResult =
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationBody = export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationBody =
TaskUpdate; TaskUpdate;
export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationError = export type UpdateTaskApiV1BoardsBoardIdTasksTaskIdPatchMutationError =
HTTPValidationError; | BlockedTaskError
| HTTPValidationError;
/** /**
* @summary Update Task * @summary Update Task
*/ */
export const useUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatch = < export const useUpdateTaskApiV1BoardsBoardIdTasksTaskIdPatch = <
TError = HTTPValidationError, TError = BlockedTaskError | HTTPValidationError,
TContext = unknown, TContext = unknown,
>( >(
options?: { options?: {

View File

@@ -23,6 +23,9 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import DropdownSelect, {
type DropdownSelectOption,
} from "@/components/ui/dropdown-select";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -406,6 +409,9 @@ export default function BoardDetailPage() {
const [editStatus, setEditStatus] = useState<TaskStatus>("inbox"); const [editStatus, setEditStatus] = useState<TaskStatus>("inbox");
const [editPriority, setEditPriority] = useState("medium"); const [editPriority, setEditPriority] = useState("medium");
const [editAssigneeId, setEditAssigneeId] = useState(""); const [editAssigneeId, setEditAssigneeId] = useState("");
const [editDependsOnTaskIds, setEditDependsOnTaskIds] = useState<string[]>(
[],
);
const [isSavingTask, setIsSavingTask] = useState(false); const [isSavingTask, setIsSavingTask] = useState(false);
const [saveTaskError, setSaveTaskError] = useState<string | null>(null); const [saveTaskError, setSaveTaskError] = useState<string | null>(null);
@@ -796,6 +802,7 @@ export default function BoardDetailPage() {
setEditStatus("inbox"); setEditStatus("inbox");
setEditPriority("medium"); setEditPriority("medium");
setEditAssigneeId(""); setEditAssigneeId("");
setEditDependsOnTaskIds([]);
setSaveTaskError(null); setSaveTaskError(null);
return; return;
} }
@@ -804,6 +811,7 @@ export default function BoardDetailPage() {
setEditStatus(selectedTask.status); setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority); setEditPriority(selectedTask.priority);
setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
setSaveTaskError(null); setSaveTaskError(null);
}, [selectedTask]); }, [selectedTask]);
@@ -1165,6 +1173,14 @@ export default function BoardDetailPage() {
return map; return map;
}, [tasks]); }, [tasks]);
const taskById = useMemo(() => {
const map = new Map<string, Task>();
tasks.forEach((task) => {
map.set(task.id, task);
});
return map;
}, [tasks]);
const orderedLiveFeed = useMemo(() => { const orderedLiveFeed = useMemo(() => {
return [...liveFeed].sort((a, b) => { return [...liveFeed].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = new Date(a.created_at).getTime();
@@ -1178,21 +1194,51 @@ export default function BoardDetailPage() {
[agents], [agents],
); );
const dependencyOptions = useMemo<DropdownSelectOption[]>(() => {
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(() => { const hasTaskChanges = useMemo(() => {
if (!selectedTask) return false; if (!selectedTask) return false;
const normalizedTitle = editTitle.trim(); const normalizedTitle = editTitle.trim();
const normalizedDescription = editDescription.trim(); const normalizedDescription = editDescription.trim();
const currentDescription = (selectedTask.description ?? "").trim(); const currentDescription = (selectedTask.description ?? "").trim();
const currentAssignee = selectedTask.assigned_agent_id ?? ""; const currentAssignee = selectedTask.assigned_agent_id ?? "";
const currentDeps = [...(selectedTask.depends_on_task_ids ?? [])]
.sort()
.join("|");
const nextDeps = [...editDependsOnTaskIds].sort().join("|");
return ( return (
normalizedTitle !== selectedTask.title || normalizedTitle !== selectedTask.title ||
normalizedDescription !== currentDescription || normalizedDescription !== currentDescription ||
editStatus !== selectedTask.status || editStatus !== selectedTask.status ||
editPriority !== selectedTask.priority || editPriority !== selectedTask.priority ||
editAssigneeId !== currentAssignee editAssigneeId !== currentAssignee ||
currentDeps !== nextDeps
); );
}, [ }, [
editAssigneeId, editAssigneeId,
editDependsOnTaskIds,
editDescription, editDescription,
editPriority, editPriority,
editStatus, editStatus,
@@ -1348,18 +1394,49 @@ export default function BoardDetailPage() {
setIsSavingTask(true); setIsSavingTask(true);
setSaveTaskError(null); setSaveTaskError(null);
try { 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( const result = await updateTaskApiV1BoardsBoardIdTasksTaskIdPatch(
boardId, boardId,
selectedTask.id, selectedTask.id,
{ updatePayload,
title: trimmedTitle,
description: editDescription.trim() || null,
status: editStatus,
priority: editPriority,
assigned_agent_id: editAssigneeId || null,
},
); );
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 = const previous =
tasksRef.current.find((task) => task.id === selectedTask.id) ?? tasksRef.current.find((task) => task.id === selectedTask.id) ??
selectedTask; selectedTask;
@@ -1393,6 +1470,7 @@ export default function BoardDetailPage() {
setEditStatus(selectedTask.status); setEditStatus(selectedTask.status);
setEditPriority(selectedTask.priority); setEditPriority(selectedTask.priority);
setEditAssigneeId(selectedTask.assigned_agent_id ?? ""); setEditAssigneeId(selectedTask.assigned_agent_id ?? "");
setEditDependsOnTaskIds(selectedTask.depends_on_task_ids ?? []);
setSaveTaskError(null); setSaveTaskError(null);
}; };
@@ -1422,6 +1500,10 @@ export default function BoardDetailPage() {
if (!isSignedIn || !boardId) return; if (!isSignedIn || !boardId) return;
const currentTask = tasksRef.current.find((task) => task.id === taskId); const currentTask = tasksRef.current.find((task) => task.id === taskId);
if (!currentTask || currentTask.status === status) return; if (!currentTask || currentTask.status === status) return;
if (currentTask.is_blocked && status !== "inbox") {
setError("Task is blocked by incomplete dependencies.");
return;
}
const previousTasks = tasksRef.current; const previousTasks = tasksRef.current;
setTasks((prev) => setTasks((prev) =>
prev.map((task) => prev.map((task) =>
@@ -1442,7 +1524,22 @@ export default function BoardDetailPage() {
taskId, taskId,
{ status }, { 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 const assignee = result.data.assigned_agent_id
? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id) ? agentsRef.current.find((agent) => agent.id === result.data.assigned_agent_id)
?.name ?? null ?.name ?? null
@@ -1461,7 +1558,7 @@ export default function BoardDetailPage() {
setTasks(previousTasks); setTasks(previousTasks);
setError(err instanceof Error ? err.message : "Unable to move task."); setError(err instanceof Error ? err.message : "Unable to move task.");
} }
}, [boardId, isSignedIn]); }, [boardId, isSignedIn, taskTitleById]);
const agentInitials = (agent: Agent) => const agentInitials = (agent: Agent) =>
agent.name agent.name
@@ -1980,6 +2077,68 @@ export default function BoardDetailPage() {
<p className="text-sm text-slate-500">No description provided.</p> <p className="text-sm text-slate-500">No description provided.</p>
)} )}
</div> </div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</p>
{selectedTask?.depends_on_task_ids?.length ? (
<div className="space-y-2">
{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 (
<button
key={depId}
type="button"
onClick={() => openComments({ id: depId })}
disabled={!depTask}
className={cn(
"w-full rounded-lg border px-3 py-2 text-left transition",
isBlocking
? "border-rose-200 bg-rose-50 hover:bg-rose-100/40"
: isDone
? "border-emerald-200 bg-emerald-50 hover:bg-emerald-100/40"
: "border-slate-200 bg-white hover:bg-slate-50",
!depTask && "cursor-not-allowed opacity-60",
)}
>
<div className="flex items-center justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-900">
{title}
</p>
<span
className={cn(
"text-[10px] font-semibold uppercase tracking-wide",
isBlocking
? "text-rose-700"
: isDone
? "text-emerald-700"
: "text-slate-500",
)}
>
{statusLabel}
</span>
</div>
</button>
);
})}
</div>
) : (
<p className="text-sm text-slate-500">No dependencies.</p>
)}
{selectedTask?.is_blocked ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
Blocked by incomplete dependencies.
</div>
) : null}
</div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500"> <p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
@@ -2333,6 +2492,73 @@ export default function BoardDetailPage() {
</p> </p>
) : null} ) : null}
</div> </div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-slate-500">
Dependencies
</label>
<p className="text-xs text-slate-500">
Tasks stay blocked until every dependency is marked done.
</p>
<DropdownSelect
ariaLabel="Add dependency"
placeholder="Add dependency"
options={dependencyOptions}
onValueChange={addTaskDependency}
disabled={
!selectedTask ||
isSavingTask ||
selectedTask.status === "done"
}
emptyMessage="No other tasks found."
/>
{selectedTask?.status === "done" ? (
<p className="text-xs text-slate-500">
Dependencies can only be edited until the task is done.
</p>
) : null}
{editDependsOnTaskIds.length === 0 ? (
<p className="text-xs text-slate-500">No dependencies.</p>
) : (
<div className="flex flex-wrap gap-2">
{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 (
<span
key={depId}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs",
isDone
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-slate-50 text-slate-700",
)}
>
<span className="max-w-[18rem] truncate">{label}</span>
{statusLabel ? (
<span className="text-[10px] font-semibold uppercase tracking-wide text-slate-400">
{statusLabel}
</span>
) : null}
{selectedTask?.status !== "done" ? (
<button
type="button"
onClick={() => removeTaskDependency(depId)}
className="rounded-full p-0.5 text-slate-500 transition hover:bg-white hover:text-slate-700"
aria-label="Remove dependency"
>
<X className="h-3 w-3" />
</button>
) : null}
</span>
);
})}
</div>
)}
</div>
{saveTaskError ? ( {saveTaskError ? (
<div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600"> <div className="rounded-lg border border-slate-200 bg-white p-3 text-xs text-slate-600">
{saveTaskError} {saveTaskError}

View File

@@ -8,6 +8,8 @@ interface TaskCardProps {
assignee?: string; assignee?: string;
due?: string; due?: string;
approvalsPendingCount?: number; approvalsPendingCount?: number;
isBlocked?: boolean;
blockedByCount?: number;
onClick?: () => void; onClick?: () => void;
draggable?: boolean; draggable?: boolean;
isDragging?: boolean; isDragging?: boolean;
@@ -21,6 +23,8 @@ export function TaskCard({
assignee, assignee,
due, due,
approvalsPendingCount = 0, approvalsPendingCount = 0,
isBlocked = false,
blockedByCount = 0,
onClick, onClick,
draggable = false, draggable = false,
isDragging = false, isDragging = false,
@@ -28,6 +32,11 @@ export function TaskCard({
onDragEnd, onDragEnd,
}: TaskCardProps) { }: TaskCardProps) {
const hasPendingApproval = approvalsPendingCount > 0; const hasPendingApproval = approvalsPendingCount > 0;
const leftBarClassName = isBlocked
? "bg-rose-400"
: hasPendingApproval
? "bg-amber-400"
: null;
const priorityBadge = (value?: string) => { const priorityBadge = (value?: string) => {
if (!value) return null; if (!value) return null;
const normalized = value.toLowerCase(); 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", "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", isDragging && "opacity-60 shadow-none",
hasPendingApproval && "border-amber-200 bg-amber-50/40", hasPendingApproval && "border-amber-200 bg-amber-50/40",
isBlocked && "border-rose-200 bg-rose-50/50",
)} )}
draggable={draggable} draggable={draggable}
onDragStart={onDragStart} onDragStart={onDragStart}
@@ -65,12 +75,23 @@ export function TaskCard({
} }
}} }}
> >
{hasPendingApproval ? ( {leftBarClassName ? (
<span className="absolute left-0 top-0 h-full w-1 rounded-l-lg bg-amber-400" /> <span
className={cn(
"absolute left-0 top-0 h-full w-1 rounded-l-lg",
leftBarClassName,
)}
/>
) : null} ) : null}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium text-slate-900">{title}</p> <p className="text-sm font-medium text-slate-900">{title}</p>
{isBlocked ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-rose-700">
<span className="h-1.5 w-1.5 rounded-full bg-rose-500" />
Blocked{blockedByCount > 0 ? ` · ${blockedByCount}` : ""}
</div>
) : null}
{hasPendingApproval ? ( {hasPendingApproval ? (
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700"> <div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" /> <span className="h-1.5 w-1.5 rounded-full bg-amber-500" />

View File

@@ -17,6 +17,9 @@ type Task = {
assigned_agent_id?: string | null; assigned_agent_id?: string | null;
assignee?: string | null; assignee?: string | null;
approvals_pending_count?: number; approvals_pending_count?: number;
depends_on_task_ids?: string[];
blocked_by_task_ids?: string[];
is_blocked?: boolean;
}; };
type TaskBoardProps = { type TaskBoardProps = {
@@ -253,6 +256,10 @@ export const TaskBoard = memo(function TaskBoard({
const handleDragStart = const handleDragStart =
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => { (task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
if (task.is_blocked) {
event.preventDefault();
return;
}
setDraggingId(task.id); setDraggingId(task.id);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData( event.dataTransfer.setData(
@@ -342,8 +349,10 @@ export const TaskBoard = memo(function TaskBoard({
assignee={task.assignee ?? undefined} assignee={task.assignee ?? undefined}
due={formatDueDate(task.due_at)} due={formatDueDate(task.due_at)}
approvalsPendingCount={task.approvals_pending_count} approvalsPendingCount={task.approvals_pending_count}
isBlocked={task.is_blocked}
blockedByCount={task.blocked_by_task_ids?.length ?? 0}
onClick={() => onTaskSelect?.(task)} onClick={() => onTaskSelect?.(task)}
draggable draggable={!task.is_blocked}
isDragging={draggingId === task.id} isDragging={draggingId === task.id}
onDragStart={handleDragStart(task)} onDragStart={handleDragStart(task)}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}