feat: add validation for minimum length on various fields and update type definitions

This commit is contained in:
Abhimanyu Saharan
2026-02-06 16:12:04 +05:30
parent ca614328ac
commit d86fe0a7a6
157 changed files with 12340 additions and 2977 deletions

View File

@@ -4,14 +4,17 @@ import asyncio
import json
import re
from collections import deque
from collections.abc import AsyncIterator
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 sqlmodel import Session, col, select
from sqlmodel import col, select
from sqlmodel.sql.expression import Select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from starlette.concurrency import run_in_threadpool
from app.api.deps import (
ActorContext,
@@ -21,7 +24,8 @@ from app.api.deps import (
require_admin_or_agent,
)
from app.core.auth import AuthContext
from app.db.session import engine, get_session
from app.core.time import utcnow
from app.db.session import async_session_maker, get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
from app.models.activity_events import ActivityEvent
@@ -30,6 +34,7 @@ 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.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
@@ -46,14 +51,6 @@ SSE_SEEN_MAX = 2000
MENTION_PATTERN = re.compile(r"@([A-Za-z][\w-]{0,31})")
def validate_task_status(status_value: str) -> None:
if status_value not in ALLOWED_STATUSES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Unsupported task status.",
)
def _comment_validation_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -61,8 +58,8 @@ def _comment_validation_error() -> HTTPException:
)
def has_valid_recent_comment(
session: Session,
async def has_valid_recent_comment(
session: AsyncSession,
task: Task,
agent_id: UUID | None,
since: datetime | None,
@@ -77,7 +74,7 @@ def has_valid_recent_comment(
.where(col(ActivityEvent.created_at) >= since)
.order_by(desc(col(ActivityEvent.created_at)))
)
event = session.exec(statement).first()
event = (await session.exec(statement)).first()
if event is None or event.message is None:
return False
return bool(event.message.strip())
@@ -116,8 +113,8 @@ def _matches_mention(agent: Agent, mentions: set[str]) -> bool:
return first in mentions
def _lead_was_mentioned(
session: Session,
async def _lead_was_mentioned(
session: AsyncSession,
task: Task,
lead: Agent,
) -> bool:
@@ -127,7 +124,7 @@ def _lead_was_mentioned(
.where(col(ActivityEvent.event_type) == "task.comment")
.order_by(desc(col(ActivityEvent.created_at)))
)
for message in session.exec(statement):
for message in await session.exec(statement):
if not message:
continue
mentions = _extract_mentions(message)
@@ -142,23 +139,24 @@ def _lead_created_task(task: Task, lead: Agent) -> bool:
return task.auto_reason == f"lead_agent:{lead.id}"
def _fetch_task_events(
async def _fetch_task_events(
session: AsyncSession,
board_id: UUID,
since: datetime,
) -> list[tuple[ActivityEvent, Task | None]]:
with Session(engine) as session:
task_ids = list(session.exec(select(Task.id).where(col(Task.board_id) == board_id)))
if not task_ids:
return []
statement = (
select(ActivityEvent, Task)
.outerjoin(Task, ActivityEvent.task_id == Task.id)
.where(col(ActivityEvent.task_id).in_(task_ids))
.where(col(ActivityEvent.event_type).in_(TASK_EVENT_TYPES))
.where(col(ActivityEvent.created_at) >= since)
.order_by(asc(col(ActivityEvent.created_at)))
)
return list(session.exec(statement))
task_ids = list(await session.exec(select(Task.id).where(col(Task.board_id) == board_id)))
if not task_ids:
return []
statement = cast(
Select[tuple[ActivityEvent, Task | None]],
select(ActivityEvent, Task)
.outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(col(ActivityEvent.task_id).in_(task_ids))
.where(col(ActivityEvent.event_type).in_(TASK_EVENT_TYPES))
.where(col(ActivityEvent.created_at) >= since)
.order_by(asc(col(ActivityEvent.created_at))),
)
return list(await session.exec(statement))
def _serialize_task(task: Task | None) -> dict[str, object] | None:
@@ -171,10 +169,10 @@ def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
return TaskCommentRead.model_validate(event).model_dump(mode="json")
def _gateway_config(session: Session, board: Board) -> GatewayClientConfig | None:
async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientConfig | None:
if not board.gateway_id:
return None
gateway = session.get(Gateway, board.gateway_id)
gateway = await session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
return None
return GatewayClientConfig(url=gateway.url, token=gateway.token)
@@ -201,16 +199,16 @@ async def _send_agent_task_message(
await send_message(message, session_key=session_key, config=config, deliver=False)
def _notify_agent_on_task_assign(
async def _notify_agent_on_task_assign(
*,
session: Session,
session: AsyncSession,
board: Board,
task: Task,
agent: Agent,
) -> None:
if not agent.openclaw_session_id:
return
config = _gateway_config(session, board)
config = await _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
@@ -230,13 +228,11 @@ def _notify_agent_on_task_assign(
+ "\n\nTake action: open the task and begin work. Post updates as task comments."
)
try:
asyncio.run(
_send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
await _send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
record_activity(
session,
@@ -245,7 +241,7 @@ def _notify_agent_on_task_assign(
agent_id=agent.id,
task_id=task.id,
)
session.commit()
await session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
@@ -254,21 +250,25 @@ def _notify_agent_on_task_assign(
agent_id=agent.id,
task_id=task.id,
)
session.commit()
await session.commit()
def _notify_lead_on_task_create(
async def _notify_lead_on_task_create(
*,
session: Session,
session: AsyncSession,
board: Board,
task: Task,
) -> None:
lead = session.exec(
select(Agent).where(Agent.board_id == board.id).where(Agent.is_board_lead.is_(True))
lead = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True))
)
).first()
if lead is None or not lead.openclaw_session_id:
return
config = _gateway_config(session, board)
config = await _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
@@ -288,12 +288,10 @@ def _notify_lead_on_task_create(
+ "\n\nTake action: triage, assign, or plan next steps."
)
try:
asyncio.run(
_send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
await _send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
record_activity(
session,
@@ -302,7 +300,7 @@ def _notify_lead_on_task_create(
agent_id=lead.id,
task_id=task.id,
)
session.commit()
await session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
@@ -311,21 +309,25 @@ def _notify_lead_on_task_create(
agent_id=lead.id,
task_id=task.id,
)
session.commit()
await session.commit()
def _notify_lead_on_task_unassigned(
async def _notify_lead_on_task_unassigned(
*,
session: Session,
session: AsyncSession,
board: Board,
task: Task,
) -> None:
lead = session.exec(
select(Agent).where(Agent.board_id == board.id).where(Agent.is_board_lead.is_(True))
lead = (
await session.exec(
select(Agent)
.where(Agent.board_id == board.id)
.where(col(Agent.is_board_lead).is_(True))
)
).first()
if lead is None or not lead.openclaw_session_id:
return
config = _gateway_config(session, board)
config = await _gateway_config(session, board)
if config is None:
return
description = (task.description or "").strip()
@@ -345,12 +347,10 @@ def _notify_lead_on_task_unassigned(
+ "\n\nTake action: assign a new owner or adjust the plan."
)
try:
asyncio.run(
_send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
await _send_lead_task_message(
session_key=lead.openclaw_session_id,
config=config,
message=message,
)
record_activity(
session,
@@ -359,7 +359,7 @@ def _notify_lead_on_task_unassigned(
agent_id=lead.id,
task_id=task.id,
)
session.commit()
await session.commit()
except OpenClawGatewayError as exc:
record_activity(
session,
@@ -368,7 +368,7 @@ def _notify_lead_on_task_unassigned(
agent_id=lead.id,
task_id=task.id,
)
session.commit()
await session.commit()
@router.get("/stream")
@@ -378,16 +378,17 @@ async def stream_tasks(
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
since_dt = _parse_since(since) or datetime.utcnow()
since_dt = _parse_since(since) or utcnow()
seen_ids: set[UUID] = set()
seen_queue: deque[UUID] = deque()
async def event_generator():
async def event_generator() -> AsyncIterator[dict[str, str]]:
last_seen = since_dt
while True:
if await request.is_disconnected():
break
rows = await run_in_threadpool(_fetch_task_events, board.id, last_seen)
async with async_session_maker() as session:
rows = await _fetch_task_events(session, board.id, last_seen)
for event, task in rows:
if event.id in seen_ids:
continue
@@ -410,13 +411,13 @@ async def stream_tasks(
@router.get("", response_model=list[TaskRead])
def list_tasks(
async def list_tasks(
status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
limit: int | None = Query(default=None, ge=1, le=200),
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[Task]:
statement = select(Task).where(Task.board_id == board.id)
@@ -435,24 +436,23 @@ def list_tasks(
statement = statement.where(col(Task.assigned_agent_id).is_(None))
if limit is not None:
statement = statement.limit(limit)
return list(session.exec(statement))
return list(await session.exec(statement))
@router.post("", response_model=TaskRead)
def create_task(
async def create_task(
payload: TaskCreate,
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Task:
validate_task_status(payload.status)
task = Task.model_validate(payload)
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
session.add(task)
session.commit()
session.refresh(task)
await session.commit()
await session.refresh(task)
record_activity(
session,
@@ -460,12 +460,12 @@ def create_task(
task_id=task.id,
message=f"Task created: {task.title}.",
)
session.commit()
_notify_lead_on_task_create(session=session, board=board, task=task)
await session.commit()
await _notify_lead_on_task_create(session=session, board=board, task=task)
if task.assigned_agent_id:
assigned_agent = session.get(Agent, task.assigned_agent_id)
assigned_agent = await session.get(Agent, task.assigned_agent_id)
if assigned_agent:
_notify_agent_on_task_assign(
await _notify_agent_on_task_assign(
session=session,
board=board,
task=task,
@@ -475,18 +475,16 @@ def create_task(
@router.patch("/{task_id}", response_model=TaskRead)
def update_task(
async def update_task(
payload: TaskUpdate,
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Task:
previous_status = task.status
previous_assigned = task.assigned_agent_id
updates = payload.model_dump(exclude_unset=True)
comment = updates.pop("comment", None)
if comment is not None and not comment.strip():
comment = None
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
allowed_fields = {"assigned_agent_id", "status"}
@@ -498,7 +496,7 @@ def update_task(
if "assigned_agent_id" in updates:
assigned_id = updates["assigned_agent_id"]
if assigned_id:
agent = session.get(Agent, 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:
@@ -512,7 +510,6 @@ def update_task(
else:
task.assigned_agent_id = None
if "status" in updates:
validate_task_status(updates["status"])
if task.status != "review":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -526,8 +523,8 @@ def update_task(
if updates["status"] == "inbox":
task.assigned_agent_id = None
task.in_progress_at = None
task.status = updates["status"]
task.updated_at = datetime.utcnow()
task.status = updates["status"]
task.updated_at = utcnow()
session.add(task)
if task.status != previous_status:
event_type = "task.status_changed"
@@ -542,17 +539,17 @@ def update_task(
message=message,
agent_id=actor.agent.id,
)
session.commit()
session.refresh(task)
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 = session.get(Agent, task.assigned_agent_id)
assigned_agent = await session.get(Agent, task.assigned_agent_id)
if assigned_agent:
board = 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
if board:
_notify_agent_on_task_assign(
await _notify_agent_on_task_assign(
session=session,
board=board,
task=task,
@@ -567,37 +564,35 @@ def update_task(
if not set(updates).issubset(allowed_fields):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates:
validate_task_status(updates["status"])
if updates["status"] == "inbox":
task.assigned_agent_id = None
task.in_progress_at = None
else:
task.assigned_agent_id = actor.agent.id if actor.agent else None
if updates["status"] == "in_progress":
task.in_progress_at = datetime.utcnow()
task.in_progress_at = utcnow()
elif "status" in updates:
validate_task_status(updates["status"])
if updates["status"] == "inbox":
task.assigned_agent_id = None
task.in_progress_at = None
elif updates["status"] == "in_progress":
task.in_progress_at = datetime.utcnow()
task.in_progress_at = utcnow()
if "assigned_agent_id" in updates and updates["assigned_agent_id"]:
agent = session.get(Agent, 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 = datetime.utcnow()
task.updated_at = utcnow()
if "status" in updates and updates["status"] == "review":
if comment is not None and comment.strip():
if not comment.strip():
raise _comment_validation_error()
else:
if not has_valid_recent_comment(
if not await has_valid_recent_comment(
session,
task,
task.assigned_agent_id,
@@ -606,8 +601,8 @@ def update_task(
raise _comment_validation_error()
session.add(task)
session.commit()
session.refresh(task)
await session.commit()
await session.refresh(task)
if comment is not None and comment.strip():
event = ActivityEvent(
@@ -617,7 +612,7 @@ def update_task(
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.add(event)
session.commit()
await session.commit()
if "status" in updates and task.status != previous_status:
event_type = "task.status_changed"
@@ -632,12 +627,12 @@ def update_task(
message=message,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.commit()
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 = 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
if board:
_notify_lead_on_task_unassigned(
await _notify_lead_on_task_unassigned(
session=session,
board=board,
task=task,
@@ -645,11 +640,11 @@ 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 = session.get(Agent, task.assigned_agent_id)
assigned_agent = await session.get(Agent, task.assigned_agent_id)
if assigned_agent:
board = 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
if board:
_notify_agent_on_task_assign(
await _notify_agent_on_task_assign(
session=session,
board=board,
task=task,
@@ -658,23 +653,23 @@ def update_task(
return task
@router.delete("/{task_id}")
def delete_task(
session: Session = Depends(get_session),
@router.delete("/{task_id}", response_model=OkResponse)
async def delete_task(
session: AsyncSession = Depends(get_session),
task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]:
session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
session.delete(task)
session.commit()
return {"ok": True}
) -> OkResponse:
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.delete(task)
await session.commit()
return OkResponse()
@router.get("/{task_id}/comments", response_model=list[TaskCommentRead])
def list_task_comments(
async def list_task_comments(
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[ActivityEvent]:
if actor.actor_type == "agent" and actor.agent:
@@ -686,19 +681,19 @@ def list_task_comments(
.where(col(ActivityEvent.event_type) == "task.comment")
.order_by(asc(col(ActivityEvent.created_at)))
)
return list(session.exec(statement))
return list(await session.exec(statement))
@router.post("/{task_id}/comments", response_model=TaskCommentRead)
def create_task_comment(
async def create_task_comment(
payload: TaskCommentCreate,
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review":
if not _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
task, actor.agent
):
raise HTTPException(
@@ -709,8 +704,6 @@ def create_task_comment(
)
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not payload.message.strip():
raise _comment_validation_error()
event = ActivityEvent(
event_type="task.comment",
message=payload.message,
@@ -718,24 +711,24 @@ def create_task_comment(
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.add(event)
session.commit()
session.refresh(event)
await session.commit()
await session.refresh(event)
mention_names = _extract_mentions(payload.message)
targets: dict[UUID, Agent] = {}
if mention_names and task.board_id:
statement = select(Agent).where(col(Agent.board_id) == task.board_id)
for agent in session.exec(statement):
for agent in await session.exec(statement):
if _matches_mention(agent, mention_names):
targets[agent.id] = agent
if not mention_names and task.assigned_agent_id:
assigned_agent = session.get(Agent, task.assigned_agent_id)
assigned_agent = await session.get(Agent, task.assigned_agent_id)
if assigned_agent:
targets[assigned_agent.id] = assigned_agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(actor.agent.id, None)
if targets:
board = session.get(Board, task.board_id) if task.board_id else None
config = _gateway_config(session, board) if board else None
board = await session.get(Board, task.board_id) if task.board_id else None
config = await _gateway_config(session, board) if board else None
if board and config:
snippet = payload.message.strip()
if len(snippet) > 500:
@@ -762,13 +755,11 @@ def create_task_comment(
"If you are mentioned but not assigned, reply in the task thread but do not change task status."
)
try:
asyncio.run(
_send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
await _send_agent_task_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=message,
)
except OpenClawGatewayError:
pass