Files
2026-05-13 12:56:52 +08:00

328 lines
12 KiB
Python

"""Scheduled task management API."""
from __future__ import annotations
import re
import zipfile
from io import BytesIO
import mimetypes
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel):
name: str = Field(default="Scheduled task")
prompt: str
schedule: str
timezone: str | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskUpdateRequest(BaseModel):
name: str | None = None
prompt: str | None = None
schedule: str | None = None
timezone: str | None = None
enabled: bool | None = None
execution_agent_name: str | None = None
execution_context: dict[str, Any] | None = None
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
element_user_id: str | None = None
async def _user_id(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
return user_id
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
def _result_download_name(result: dict[str, Any], *, extension: str = "md") -> str:
title = result.get("title") if isinstance(result, dict) else None
return f"{_safe_filename(str(title or 'scheduled-task-result'))}.{extension}"
def _result_markdown(result: dict[str, Any]) -> str:
title = str(result.get("title") or "Scheduled Task Result")
content = str(result.get("content") or "")
return f"# {title}\n\n{content}".strip() + "\n"
def _utf8_sig_bytes(content: str) -> bytes:
return content.encode("utf-8-sig")
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
return f"attachment; filename*=UTF-8''{quoted}"
def _run_files(run: dict[str, Any]) -> list:
result = run.get("result") or {}
return result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
files = _run_files(run)
if file_index < 0 or file_index >= len(files):
raise HTTPException(status_code=404, detail="Result file not found")
item = files[file_index]
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise HTTPException(status_code=404, detail="Result file not found")
path = Path(item["path"])
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Result file is no longer available")
return item, path
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
return True
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
def _is_text_download(path: Path, mime_type: str | None) -> bool:
return _is_text_previewable(path, mime_type)
def _read_text_download_bytes(path: Path) -> bytes:
try:
content = path.read_text(encoding="utf-8-sig")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return _utf8_sig_bytes(content)
@router.get("/thread")
async def get_scheduler_thread(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.ensure_scheduler_thread(user_id)
@router.get("")
async def list_scheduled_tasks(request: Request) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_tasks(user_id)
@router.get("/{task_id}/runs")
async def list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
runs = await service.list_runs(user_id, task_id, limit=max(1, min(limit, 200)))
return runs
@router.get("/runs/{run_id}")
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return run
@router.delete("/runs/{run_id}")
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_run(user_id, run_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@router.get("/runs/{run_id}/files/{file_index}")
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
previewable = _is_text_previewable(path, mime_type)
content = None
if previewable:
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return {
"index": file_index,
"name": item.get("name") or path.name,
"mime_type": mime_type,
"kind": item.get("kind"),
"size": path.stat().st_size,
"previewable": previewable,
"content": content,
}
@router.get("/runs/{run_id}/download")
async def download_scheduled_task_run(run_id: str, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
result = run.get("result") or {}
if not isinstance(result, dict):
result = {}
files = result.get("files") if isinstance(result.get("files"), list) else []
accessible_files: list[tuple[str, Path]] = []
for item in files:
if not isinstance(item, dict):
continue
path_value = item.get("path")
if not isinstance(path_value, str) or not path_value:
continue
path = Path(path_value)
if path.exists() and path.is_file():
accessible_files.append((str(item.get("name") or path.name), path))
if not accessible_files:
body = _utf8_sig_bytes(_result_markdown(result))
return Response(
content=body,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": _content_disposition(_result_download_name(result))},
)
archive = BytesIO()
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
for name, path in accessible_files:
arcname = _safe_filename(name, path.name)
mime_type = mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
zf.writestr(arcname, _read_text_download_bytes(path))
else:
zf.write(path, arcname=arcname)
archive.seek(0)
return StreamingResponse(
archive,
media_type="application/zip",
headers={"Content-Disposition": _content_disposition(_result_download_name(result, extension="zip"))},
)
@router.get("/runs/{run_id}/files/{file_index}/download")
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
filename = _safe_filename(str(item.get("name") or path.name), path.name)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
if _is_text_download(path, mime_type):
return Response(
content=_read_text_download_bytes(path),
media_type=f"{mime_type or 'text/plain'}; charset=utf-8",
headers={"Content-Disposition": _content_disposition(filename)},
)
return FileResponse(path, filename=filename, media_type=item.get("mime_type") or "application/octet-stream")
@router.get("/delivery-profile")
async def get_delivery_profile(request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.get_delivery_profile(user_id)
@router.put("/delivery-profile")
async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.update_delivery_profile(user_id, body.model_dump(exclude_unset=True))
@router.post("")
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.create_task(
user_id=user_id,
name=body.name,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
@router.patch("/{task_id}")
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.delete("/{task_id}")
async def delete_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True}
@router.post("/{task_id}/pause")
async def pause_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.pause_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/resume")
async def resume_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.resume_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/trigger")
async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.trigger_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True, "task": task}