feat(backend): enhance task and schedule center APIs with expanded endpoints

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-11 08:47:39 +08:00
parent 8c7cf0732b
commit 3e39b40a50
6 changed files with 1015 additions and 107 deletions

View File

@@ -23,3 +23,4 @@ from app.routers.agent_sessions import router as agent_sessions_router
from app.routers.terminal import router as terminal_router
from app.routers.tools import router as tools_router
from app.routers.remote_mount import router as remote_mount_router
from app.routers.office import router as office_router

View File

@@ -100,6 +100,7 @@ async def chat(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -115,7 +116,7 @@ async def chat(
conversation_id=conv_id,
message_id=msg_id,
content=content,
agent_name="jarvis",
agent_name=data.runtime or "jarvis",
model_name=model_name,
)
@@ -141,6 +142,7 @@ async def chat_stream(
conversation_id=data.conversation_id,
file_ids=data.file_ids,
model_name=data.model_name,
runtime=data.runtime,
)
except ValueError as exc:
yield f"event: error\ndata: {json.dumps({'error': str(exc)}, ensure_ascii=False)}\n\n"

View File

@@ -1,25 +1,62 @@
from calendar import monthrange
from datetime import UTC, date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.goal import Goal
from app.models.reminder import Reminder
from app.models.task import Task, TaskPriority
from app.models.task import Task, TaskDispatchStatus, TaskPriority, TaskQuadrant, TaskStatus
from app.models.todo import DailyTodo
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.schedule_center import (
ScheduleCenterCommanderSummaryOut,
ScheduleCenterDateOut,
ScheduleCenterDaySummary,
ScheduleCenterFocusTaskOut,
ScheduleCenterMonthOut,
ScheduleCenterQuadrantOut,
ScheduleCenterQuadrantTaskOut,
)
from app.schemas.task import build_task_out
router = APIRouter(prefix="/api/schedule-center", tags=["调度中心"])
QUADRANT_META: dict[TaskQuadrant, dict[str, str]] = {
TaskQuadrant.URGENT_IMPORTANT: {
"title": "重要且紧急",
"subtitle": "CRITICAL",
"color": "#ff4757",
"glow_color": "rgba(255, 71, 87, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_IMPORTANT: {
"title": "重要不紧急",
"subtitle": "PLANNED",
"color": "#ffd93d",
"glow_color": "rgba(255, 217, 61, 0.4)",
"icon": "",
},
TaskQuadrant.URGENT_NOT_IMPORTANT: {
"title": "紧急不重要",
"subtitle": "DELEGATE",
"color": "#00d4ff",
"glow_color": "rgba(0, 212, 255, 0.4)",
"icon": "",
},
TaskQuadrant.NOT_URGENT_NOT_IMPORTANT: {
"title": "不重要不紧急",
"subtitle": "ELIMINATE",
"color": "#6bcf7f",
"glow_color": "rgba(107, 207, 127, 0.4)",
"icon": "",
},
}
def _build_summary(
target_date: str,
@@ -39,6 +76,146 @@ def _build_summary(
)
def _coerce_enum(value, enum_cls, default=None):
if value is None:
return default
if isinstance(value, enum_cls):
return value
if isinstance(value, str):
raw = value.strip()
if not raw:
return default
for item in enum_cls:
if raw == item.value or raw.lower() == item.value:
return item
if raw.upper() == item.name:
return item
return default
def _derive_quadrant(task: Task) -> TaskQuadrant:
quadrant = _coerce_enum(task.quadrant, TaskQuadrant, None)
if quadrant is not None:
return quadrant
priority = _coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM)
status = _coerce_enum(task.status, TaskStatus, TaskStatus.TODO)
if priority in {TaskPriority.HIGH, TaskPriority.URGENT}:
return TaskQuadrant.URGENT_IMPORTANT
if status == TaskStatus.IN_PROGRESS:
return TaskQuadrant.NOT_URGENT_IMPORTANT
if priority == TaskPriority.MEDIUM:
return TaskQuadrant.URGENT_NOT_IMPORTANT
return TaskQuadrant.NOT_URGENT_NOT_IMPORTANT
def _enum_value(value) -> str | None:
if value is None:
return None
if hasattr(value, "value"):
return str(value.value)
if isinstance(value, str):
raw = value.strip()
return raw or None
return str(value)
def _build_focus_tasks(tasks: list[Task]) -> list[ScheduleCenterFocusTaskOut]:
priority_rank = {
TaskPriority.URGENT: 0,
TaskPriority.HIGH: 1,
TaskPriority.MEDIUM: 2,
TaskPriority.LOW: 3,
}
status_rank = {
TaskStatus.IN_PROGRESS: 0,
TaskStatus.TODO: 1,
TaskStatus.DONE: 2,
TaskStatus.CANCELLED: 3,
}
ordered = sorted(
tasks,
key=lambda item: (
status_rank.get(_coerce_enum(item.status, TaskStatus, TaskStatus.TODO), 99),
priority_rank.get(_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM), 99),
item.created_at,
),
)
return [
ScheduleCenterFocusTaskOut(
id=item.id,
title=item.title,
status=_coerce_enum(item.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(item.priority, TaskPriority, TaskPriority.MEDIUM),
quadrant=_derive_quadrant(item),
assignee_type=_enum_value(item.assignee_type),
assignee_id=item.assignee_id,
dispatch_status=_coerce_enum(item.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
due_date=item.due_date,
)
for item in ordered[:6]
]
def _build_quadrants(tasks: list[Task]) -> list[ScheduleCenterQuadrantOut]:
buckets: dict[TaskQuadrant, list[ScheduleCenterQuadrantTaskOut]] = {
quadrant: [] for quadrant in QUADRANT_META
}
for task in tasks:
quadrant = _derive_quadrant(task)
buckets[quadrant].append(
ScheduleCenterQuadrantTaskOut(
id=task.id,
title=task.title,
status=_coerce_enum(task.status, TaskStatus, TaskStatus.TODO),
priority=_coerce_enum(task.priority, TaskPriority, TaskPriority.MEDIUM),
dispatch_status=_coerce_enum(task.dispatch_status, TaskDispatchStatus, TaskDispatchStatus.IDLE),
assignee_type=_enum_value(task.assignee_type),
assignee_id=task.assignee_id,
)
)
return [
ScheduleCenterQuadrantOut(
id=quadrant,
title=meta["title"],
subtitle=meta["subtitle"],
color=meta["color"],
glow_color=meta["glow_color"],
icon=meta["icon"],
tasks=buckets[quadrant],
)
for quadrant, meta in QUADRANT_META.items()
]
def _build_commander_summary(tasks: list[Task]) -> ScheduleCenterCommanderSummaryOut:
counts = ScheduleCenterCommanderSummaryOut()
for task in tasks:
states = [task.dispatch_status, *(subtask.dispatch_status for subtask in task.subtasks)]
for state in states:
normalized = _coerce_enum(state, TaskDispatchStatus, TaskDispatchStatus.IDLE)
if normalized == TaskDispatchStatus.IDLE:
continue
counts.total += 1
if normalized == TaskDispatchStatus.QUEUED:
counts.queued += 1
elif normalized == TaskDispatchStatus.RUNNING:
counts.running += 1
elif normalized == TaskDispatchStatus.COMPLETED:
counts.completed += 1
elif normalized == TaskDispatchStatus.FAILED:
counts.failed += 1
if counts.running > 0:
counts.overall_status = "running"
elif counts.queued > 0:
counts.overall_status = "queued"
elif counts.failed > 0 and counts.completed == 0:
counts.overall_status = "failed"
return counts
@router.get("/month", response_model=ScheduleCenterMonthOut)
async def get_month_schedule(
year: int = Query(..., ge=2000, le=2100),
@@ -53,27 +230,43 @@ async def get_month_schedule(
start_dt = datetime.combine(month_start, datetime.min.time())
end_dt = datetime.combine(month_start.replace(day=days_in_month), datetime.max.time())
todos = (await db.execute(
select(DailyTodo).where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date >= start_key, DailyTodo.todo_date <= end_key)
)).scalars().all()
tasks = (await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
todos = (
await db.execute(
select(DailyTodo).where(
DailyTodo.user_id == current_user.id,
DailyTodo.todo_date >= start_key,
DailyTodo.todo_date <= end_key,
)
)
)).scalars().all()
reminders = (await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
).scalars().all()
tasks = (
await db.execute(
select(Task).where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
)
)).scalars().all()
goals = (await db.execute(
select(Goal).where(Goal.user_id == current_user.id, Goal.goal_date >= start_key, Goal.goal_date <= end_key)
)).scalars().all()
).scalars().all()
reminders = (
await db.execute(
select(Reminder).where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
)
).scalars().all()
goals = (
await db.execute(
select(Goal).where(
Goal.user_id == current_user.id,
Goal.goal_date >= start_key,
Goal.goal_date <= end_key,
)
)
).scalars().all()
todo_map: dict[str, list[DailyTodo]] = {}
for item in todos:
@@ -96,18 +289,20 @@ async def get_month_schedule(
days = []
for day in range(1, days_in_month + 1):
date_key = month_start.replace(day=day).isoformat()
days.append(_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
))
days.append(
_build_summary(
date_key,
todo_map.get(date_key, []),
task_map.get(date_key, []),
reminder_map.get(date_key, []),
goal_map.get(date_key, []),
)
)
return ScheduleCenterMonthOut(month=f"{year:04d}-{month:02d}", days=days)
@router.get("/date", response_model=ScheduleCenterDateOut)
@router.get("/date", response_model=ScheduleCenterDateOut, response_model_exclude_none=True)
async def get_date_schedule(
date_str: date = Query(...),
current_user: User = Depends(get_current_user),
@@ -118,43 +313,55 @@ async def get_date_schedule(
end_dt = datetime.combine(target_date, datetime.max.time())
date_key = target_date.isoformat()
todos = (await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)).scalars().all()
tasks = (await db.execute(
select(Task)
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
todos = (
await db.execute(
select(DailyTodo)
.where(DailyTodo.user_id == current_user.id, DailyTodo.todo_date == date_key)
.order_by(DailyTodo.created_at.desc())
)
.order_by(Task.created_at.desc())
)).scalars().all()
reminders = (await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
).scalars().all()
tasks = (
await db.execute(
select(Task)
.options(selectinload(Task.subtasks), selectinload(Task.history))
.where(
Task.user_id == current_user.id,
Task.due_date.is_not(None),
Task.due_date >= start_dt,
Task.due_date <= end_dt,
)
.order_by(Task.priority.desc(), Task.created_at.desc())
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)).scalars().all()
goals = (await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)).scalars().all()
).scalars().unique().all()
reminders = (
await db.execute(
select(Reminder)
.where(
Reminder.user_id == current_user.id,
Reminder.reminder_at >= start_dt,
Reminder.reminder_at <= end_dt,
)
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
)
).scalars().all()
goals = (
await db.execute(
select(Goal)
.where(Goal.user_id == current_user.id, Goal.goal_date == date_key)
.order_by(Goal.created_at.desc())
)
).scalars().all()
summary = _build_summary(date_key, todos, tasks, reminders, goals)
return ScheduleCenterDateOut(
date=date_key,
todos=todos,
tasks=tasks,
tasks=[build_task_out(task) for task in tasks],
reminders=reminders,
goals=goals,
summary=summary,
focus_tasks=_build_focus_tasks(tasks),
quadrants=_build_quadrants(tasks),
commander_summary=_build_commander_summary(tasks),
generated_at=datetime.now(UTC),
)

View File

@@ -1,15 +1,116 @@
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, TaskStatus
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, TaskUpdate, TaskOut
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=["看板"])
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])
@@ -18,12 +119,28 @@ async def list_tasks(
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).where(Task.user_id == current_user.id)
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())
@@ -32,65 +149,109 @@ async def list_tasks(
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="开始日期不能晚于结束日期")
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.created_at))
query = query.order_by(desc(Task.updated_at), desc(Task.created_at))
result = await db.execute(query)
return result.scalars().all()
tasks = result.scalars().unique().all()
return [_task_to_out(task) for task in tasks]
@router.post("", response_model=TaskOut, status_code=201)
@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),
):
import json
task = Task(
user_id=current_user.id,
title=data.title,
description=data.description,
priority=data.priority,
due_date=data.due_date,
tags=json.dumps(data.tags) if data.tags else None,
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()
await db.refresh(task)
return task
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.patch("/{task_id}", response_model=TaskOut)
@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),
):
import json
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
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 data.model_dump(exclude_none=True).items():
for field, value in payload.items():
previous = getattr(task, field)
if field == "tags":
setattr(task, field, json.dumps(value))
elif field == "status" and value == TaskStatus.DONE:
task.completed_at = datetime.now(UTC)
setattr(task, field, value)
elif field == "status":
task.completed_at = None
setattr(task, field, value)
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()
await db.refresh(task)
return task
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)
@@ -99,11 +260,171 @@ async def delete_task(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Task).where(Task.id == task_id, Task.user_id == current_user.id)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
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,
)