Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
431 lines
15 KiB
Python
431 lines
15 KiB
Python
import json
|
|
from datetime import UTC, date, datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import desc, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.models.task import (
|
|
Task,
|
|
TaskAssigneeType,
|
|
TaskDispatchStatus,
|
|
TaskQuadrant,
|
|
TaskSource,
|
|
TaskStatus,
|
|
TaskSubTask,
|
|
)
|
|
from app.models.user import User
|
|
from app.routers.auth import get_current_user
|
|
from app.schemas.task import (
|
|
TaskCreate,
|
|
TaskDetailOut,
|
|
TaskDispatchRequest,
|
|
TaskDispatchResponse,
|
|
TaskHistoryOut,
|
|
TaskOut,
|
|
TaskSubTaskCreate,
|
|
TaskSubTaskOut,
|
|
TaskSubTaskReorderRequest,
|
|
TaskSubTaskUpdate,
|
|
TaskUpdate,
|
|
build_task_detail_out,
|
|
)
|
|
from app.services.task_dispatch import append_task_history, load_task_with_details, queue_task_dispatch
|
|
|
|
router = APIRouter(prefix="/api/tasks", tags=["Tasks"])
|
|
|
|
|
|
def _encode_tags(tags: list[str] | None) -> str | None:
|
|
if not tags:
|
|
return None
|
|
return json.dumps(tags, ensure_ascii=False)
|
|
|
|
|
|
def _decode_tags(value: str | None) -> list[str]:
|
|
if not value:
|
|
return []
|
|
try:
|
|
payload = json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return [value]
|
|
if isinstance(payload, list):
|
|
return [str(item) for item in payload]
|
|
return [str(payload)]
|
|
|
|
|
|
def _subtask_to_out(subtask: TaskSubTask) -> TaskSubTaskOut:
|
|
return TaskSubTaskOut.model_validate(subtask)
|
|
|
|
|
|
def _history_to_out(history) -> TaskHistoryOut:
|
|
return TaskHistoryOut.model_validate(history)
|
|
|
|
|
|
def _task_to_out(task: Task) -> TaskOut:
|
|
return TaskOut(
|
|
id=task.id,
|
|
title=task.title,
|
|
description=task.description,
|
|
status=task.status,
|
|
priority=task.priority,
|
|
due_date=task.due_date,
|
|
completed_at=task.completed_at,
|
|
tags=_decode_tags(task.tags),
|
|
source=task.source or TaskSource.MANUAL,
|
|
conversation_id=task.conversation_id,
|
|
quadrant=task.quadrant,
|
|
assignee_type=task.assignee_type,
|
|
assignee_id=task.assignee_id,
|
|
dispatch_status=task.dispatch_status or TaskDispatchStatus.IDLE,
|
|
dispatch_run_id=task.dispatch_run_id,
|
|
result_summary=task.result_summary,
|
|
started_at=task.started_at,
|
|
last_synced_at=task.last_synced_at,
|
|
created_at=task.created_at,
|
|
updated_at=task.updated_at,
|
|
)
|
|
|
|
|
|
def _task_detail_to_out(task: Task) -> TaskDetailOut:
|
|
return build_task_detail_out(task)
|
|
|
|
|
|
async def _get_task_or_404(db: AsyncSession, *, task_id: str, user_id: str) -> Task:
|
|
task = await load_task_with_details(db, task_id=task_id, user_id=user_id)
|
|
if task is None:
|
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
return task
|
|
|
|
|
|
def _sync_task_completion(task: Task) -> None:
|
|
if task.status == TaskStatus.DONE:
|
|
task.completed_at = task.completed_at or datetime.now(UTC)
|
|
elif task.status != TaskStatus.CANCELLED:
|
|
task.completed_at = None
|
|
|
|
|
|
def _sync_subtask_completion(subtask: TaskSubTask) -> None:
|
|
if subtask.status == TaskStatus.DONE:
|
|
subtask.completed_at = subtask.completed_at or datetime.now(UTC)
|
|
elif subtask.status != TaskStatus.CANCELLED:
|
|
subtask.completed_at = None
|
|
|
|
|
|
@router.get("", response_model=list[TaskOut])
|
|
async def list_tasks(
|
|
status: TaskStatus | None = None,
|
|
due_date: date | None = Query(default=None),
|
|
date_from: date | None = Query(default=None),
|
|
date_to: date | None = Query(default=None),
|
|
quadrant: TaskQuadrant | None = None,
|
|
assignee_type: TaskAssigneeType | None = None,
|
|
dispatch_status: TaskDispatchStatus | None = None,
|
|
conversation_id: str | None = None,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
query = (
|
|
select(Task)
|
|
.options(selectinload(Task.subtasks), selectinload(Task.history))
|
|
.where(Task.user_id == current_user.id)
|
|
)
|
|
if status:
|
|
query = query.where(Task.status == status)
|
|
if quadrant:
|
|
query = query.where(Task.quadrant == quadrant)
|
|
if assignee_type:
|
|
query = query.where(Task.assignee_type == assignee_type)
|
|
if dispatch_status:
|
|
query = query.where(Task.dispatch_status == dispatch_status)
|
|
if conversation_id:
|
|
query = query.where(Task.conversation_id == conversation_id)
|
|
if due_date:
|
|
start = datetime.combine(due_date, datetime.min.time())
|
|
end = datetime.combine(due_date, datetime.max.time())
|
|
query = query.where(Task.due_date.is_not(None), Task.due_date >= start, Task.due_date <= end)
|
|
else:
|
|
start = datetime.combine(date_from, datetime.min.time()) if date_from else None
|
|
end = datetime.combine(date_to, datetime.max.time()) if date_to else None
|
|
if start and end and start > end:
|
|
raise HTTPException(status_code=400, detail="date_from cannot be later than date_to")
|
|
if start is not None:
|
|
query = query.where(Task.due_date.is_not(None), Task.due_date >= start)
|
|
if end is not None:
|
|
query = query.where(Task.due_date.is_not(None), Task.due_date <= end)
|
|
|
|
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
|
|
result = await db.execute(query)
|
|
tasks = result.scalars().unique().all()
|
|
return [_task_to_out(task) for task in tasks]
|
|
|
|
|
|
@router.post("", response_model=TaskDetailOut, status_code=201)
|
|
async def create_task(
|
|
data: TaskCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = Task(
|
|
user_id=current_user.id,
|
|
title=data.title,
|
|
description=data.description,
|
|
priority=data.priority,
|
|
due_date=data.due_date,
|
|
tags=_encode_tags(data.tags),
|
|
source=data.source,
|
|
conversation_id=data.conversation_id,
|
|
quadrant=data.quadrant,
|
|
assignee_type=data.assignee_type,
|
|
assignee_id=data.assignee_id,
|
|
status=data.status,
|
|
)
|
|
_sync_task_completion(task)
|
|
if data.source == TaskSource.CHAT:
|
|
append_task_history(task, action="created_from_chat", new_value=task.title)
|
|
append_task_history(task, action="created", new_value=task.title)
|
|
for index, subtask_data in enumerate(data.subtasks):
|
|
subtask = TaskSubTask(
|
|
title=subtask_data.title,
|
|
description=subtask_data.description,
|
|
status=subtask_data.status,
|
|
order_index=index if subtask_data.order_index is None else subtask_data.order_index,
|
|
assignee_type=subtask_data.assignee_type,
|
|
assignee_id=subtask_data.assignee_id,
|
|
)
|
|
_sync_subtask_completion(subtask)
|
|
task.subtasks.append(subtask)
|
|
append_task_history(task, action="subtask_created", new_value=subtask.title)
|
|
db.add(task)
|
|
await db.commit()
|
|
|
|
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
|
|
if data.dispatch_to_commander:
|
|
await queue_task_dispatch(task, db=db)
|
|
task = await _get_task_or_404(db, task_id=task.id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.get("/{task_id}", response_model=TaskDetailOut)
|
|
async def get_task(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.patch("/{task_id}", response_model=TaskDetailOut)
|
|
async def update_task(
|
|
task_id: str,
|
|
data: TaskUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
payload = data.model_dump(exclude_none=True)
|
|
previous_assignee = (task.assignee_type, task.assignee_id)
|
|
|
|
for field, value in payload.items():
|
|
previous = getattr(task, field)
|
|
if field == "tags":
|
|
task.tags = _encode_tags(value)
|
|
append_task_history(task, action="updated", old_value=_decode_tags(previous), new_value=value)
|
|
continue
|
|
setattr(task, field, value)
|
|
if field == "status":
|
|
_sync_task_completion(task)
|
|
append_task_history(task, action="status_changed", old_value=previous, new_value=value)
|
|
elif previous != value:
|
|
append_task_history(task, action="updated", old_value=previous, new_value=value)
|
|
|
|
if ("assignee_type" in payload or "assignee_id" in payload) and previous_assignee != (task.assignee_type, task.assignee_id):
|
|
append_task_history(
|
|
task,
|
|
action="assigned",
|
|
old_value=f"{previous_assignee[0]}:{previous_assignee[1]}",
|
|
new_value=f"{task.assignee_type}:{task.assignee_id}",
|
|
)
|
|
|
|
await db.commit()
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.delete("/{task_id}", status_code=204)
|
|
async def delete_task(
|
|
task_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
await db.delete(task)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/{task_id}/subtasks", status_code=201)
|
|
async def create_subtask(
|
|
task_id: str,
|
|
data: TaskSubTaskCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
max_order = max((item.order_index for item in task.subtasks), default=-1)
|
|
subtask = TaskSubTask(
|
|
task_id=task.id,
|
|
title=data.title,
|
|
description=data.description,
|
|
status=data.status,
|
|
order_index=max_order + 1 if data.order_index is None else data.order_index,
|
|
assignee_type=data.assignee_type,
|
|
assignee_id=data.assignee_id,
|
|
)
|
|
_sync_subtask_completion(subtask)
|
|
task.subtasks.append(subtask)
|
|
append_task_history(task, action="subtask_created", new_value=data.title)
|
|
await db.commit()
|
|
db.expire_all()
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
detail = _task_detail_to_out(task)
|
|
created_subtask = max(
|
|
(item for item in detail.subtasks if item.title == data.title),
|
|
key=lambda item: (item.order_index, item.created_at),
|
|
default=None,
|
|
)
|
|
if created_subtask is None:
|
|
raise HTTPException(status_code=500, detail="Created subtask could not be loaded")
|
|
return {
|
|
**created_subtask.model_dump(),
|
|
"task": detail.model_dump(),
|
|
"subtasks": [item.model_dump() for item in detail.subtasks],
|
|
"history": [item.model_dump() for item in detail.history],
|
|
"dispatch": detail.dispatch.model_dump(),
|
|
"dispatch_summary": detail.dispatch_summary.model_dump(),
|
|
}
|
|
|
|
|
|
@router.patch("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
|
|
async def update_subtask(
|
|
task_id: str,
|
|
subtask_id: str,
|
|
data: TaskSubTaskUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
|
if subtask is None:
|
|
raise HTTPException(status_code=404, detail="Subtask not found")
|
|
|
|
payload = data.model_dump(exclude_none=True)
|
|
for field, value in payload.items():
|
|
previous = getattr(subtask, field)
|
|
setattr(subtask, field, value)
|
|
if field == "status":
|
|
_sync_subtask_completion(subtask)
|
|
if previous != value:
|
|
append_task_history(
|
|
task,
|
|
action="updated" if field != "status" else "status_changed",
|
|
old_value=f"{subtask.id}:{field}:{previous}",
|
|
new_value=f"{subtask.id}:{field}:{value}",
|
|
)
|
|
|
|
await db.commit()
|
|
db.expire_all()
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.delete("/{task_id}/subtasks/{subtask_id}", response_model=TaskDetailOut)
|
|
async def delete_subtask(
|
|
task_id: str,
|
|
subtask_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
|
if subtask is None:
|
|
raise HTTPException(status_code=404, detail="Subtask not found")
|
|
|
|
append_task_history(task, action="updated", old_value="subtask_deleted", new_value=subtask.title)
|
|
await db.delete(subtask)
|
|
await db.commit()
|
|
db.expire_all()
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.post("/{task_id}/subtasks/reorder", response_model=TaskDetailOut)
|
|
async def reorder_subtasks(
|
|
task_id: str,
|
|
data: TaskSubTaskReorderRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
lookup = {item.id: item for item in task.subtasks}
|
|
for item in data.items:
|
|
subtask = lookup.get(item.id)
|
|
if subtask is None:
|
|
raise HTTPException(status_code=404, detail=f"Subtask not found: {item.id}")
|
|
subtask.order_index = item.order_index
|
|
|
|
append_task_history(
|
|
task,
|
|
action="subtask_reordered",
|
|
new_value=",".join(f"{item.id}:{item.order_index}" for item in data.items),
|
|
)
|
|
await db.commit()
|
|
db.expire_all()
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return _task_detail_to_out(task)
|
|
|
|
|
|
@router.post("/{task_id}/dispatch", response_model=TaskDispatchResponse)
|
|
async def dispatch_task(
|
|
task_id: str,
|
|
data: TaskDispatchRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if data.target != "commander":
|
|
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
_, payload = await queue_task_dispatch(task, db=db)
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return TaskDispatchResponse(
|
|
status=task.dispatch_status,
|
|
run_id=task.dispatch_run_id,
|
|
task=_task_detail_to_out(task),
|
|
payload=payload,
|
|
)
|
|
|
|
|
|
@router.post("/{task_id}/subtasks/{subtask_id}/dispatch", response_model=TaskDispatchResponse)
|
|
async def dispatch_subtask(
|
|
task_id: str,
|
|
subtask_id: str,
|
|
data: TaskDispatchRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if data.target != "commander":
|
|
raise HTTPException(status_code=400, detail="Only commander dispatch is supported")
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
subtask = next((item for item in task.subtasks if item.id == subtask_id), None)
|
|
if subtask is None:
|
|
raise HTTPException(status_code=404, detail="Subtask not found")
|
|
_, payload = await queue_task_dispatch(task, db=db, subtask=subtask)
|
|
task = await _get_task_or_404(db, task_id=task_id, user_id=current_user.id)
|
|
return TaskDispatchResponse(
|
|
status=subtask.dispatch_status,
|
|
run_id=subtask.dispatch_run_id,
|
|
task=_task_detail_to_out(task),
|
|
payload=payload,
|
|
)
|