"""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, Query, Request from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field from app.gateway.deps import ( get_checkpointer, get_current_user, get_local_provider, get_optional_user_from_request, get_run_event_store, get_run_store, get_tag_store, ) from deerflow.runtime import serialize_channel_values from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"]) class ScheduledTaskCreateRequest(BaseModel): name: str = Field(default="Scheduled task") description: str | None = None prompt: str schedule: str timezone: str | None = None execution_agent_name: str | None = None execution_context: dict[str, Any] | None = None require_markdown: bool = Field( default=False, description="Require the result to be Markdown and exceed the minimum length, else mark the run failed.", ) class ScheduledTaskUpdateRequest(BaseModel): name: str | None = None description: 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 require_markdown: bool | None = None class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel): element_user_id: str | None = None class ScheduledTaskSubscribeRequest(BaseModel): notify_element: bool = False class ScheduledTaskRunFileUpdateRequest(BaseModel): content: str = Field(..., description="New text content to write into the run file") _MAX_RUN_FILE_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB 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 async def _is_admin(request: Request) -> bool: """True when the caller is an admin (or auth is disabled entirely).""" user = await get_optional_user_from_request(request) if user is None: return True return getattr(user, "system_role", None) == "admin" async def _load_run(service, request: Request, run_id: str, user_id: str) -> dict[str, Any] | None: """Owner/subscriber run lookup, with an admin fallback to any run. Lets admins open run results for tasks they do not own (the admin management view links into the shared run-detail page). """ run = await service.get_run(user_id, run_id) if run is None and await _is_admin(request): run = await service.admin_get_run(run_id) return run async def _enrich_with_creator(tasks: list[dict[str, Any]] | dict[str, Any] | None) -> Any: """Attach ``creator_email`` / ``creator_user_id`` to one or many task dicts. Looks up the auth user once per distinct ``user_id`` in the batch so we do not pay N+1 round trips when rendering a long task list. Tolerates missing user records (deleted accounts) by leaving the email blank. """ if tasks is None: return tasks items = tasks if isinstance(tasks, list) else [tasks] user_ids = {str(t.get("user_id")) for t in items if isinstance(t, dict) and t.get("user_id")} if not user_ids: return tasks try: provider = get_local_provider() except Exception: # Auth provider not initialised (e.g. memory-mode test harness): keep # the bare user_id and let the frontend render the raw id. provider = None email_by_id: dict[str, str] = {} if provider is not None: for uid in user_ids: try: user = await provider.get_user(uid) except Exception: user = None if user is not None: email_by_id[uid] = user.email for item in items: if not isinstance(item, dict): continue uid = item.get("user_id") if uid is None: continue uid_str = str(uid) item["creator_user_id"] = uid_str item["creator_email"] = email_by_id.get(uid_str, "") return tasks 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("/html-templates") async def list_html_templates() -> dict: """可选的 HTML 模板清单(供「模板数据转换器」内置 agent 填充用)。 返回 ``{"templates": [{id, name, description}]}``,前端定时任务表单在选中 「模板数据转换器」后据此渲染「HTML 模板」下拉。开放给任意登录用户。 """ from deerflow.runtime.scheduler import template_fill return {"templates": template_fill.list_templates()} class TemplateRenderRequest(BaseModel): """「模板数据转换器」对话页的调试渲染入参。""" template_id: str # 智能体回复原文(含转换后的 JSON);后端用 extract_embedded_json 抽取后填充。 text: str | None = None # 或直接给已解析好的对象(优先于 text)。 data: Any | None = None @router.post("/template-fill/render") async def render_template_fill(body: TemplateRenderRequest) -> dict: """调试用:把一段(智能体回复的)内容里的 JSON 填进所选 HTML 模板并返回完整 HTML。 供「模板数据转换器」对话页的「渲染预览」按钮调试智能体效果——不调模型,只做 抽取 → 校验 → 填充,把校验错误/缺失字段一并返回方便定位。「只展示」模板原样返回。 开放给任意登录用户。 """ from deerflow.runtime.scheduler import template_fill spec = template_fill.get_template(body.template_id) if spec is None: raise HTTPException(status_code=404, detail=f"未知模板:{body.template_id}") # 「只展示」模板(no.2 等):原样返回模板 HTML。 if not spec.convertible: html = template_fill.read_template_text(body.template_id) return {"ok": True, "display_only": True, "errors": [], "gaps": [], "html": html} obj = body.data if obj is None and body.text: obj = template_fill.extract_embedded_json(body.text) if obj is None: return {"ok": False, "errors": ["未从内容中识别到可用的 JSON"], "gaps": [], "html": None} errors = template_fill.validate_data(body.template_id, obj) gaps = template_fill.field_coverage_gaps(body.template_id, obj) try: html = template_fill.fill_template(body.template_id, obj) except template_fill.TemplateError as exc: return {"ok": False, "errors": [str(exc)], "gaps": gaps, "html": None} return {"ok": not errors, "errors": errors, "gaps": gaps, "html": html} @router.get("") async def list_scheduled_tasks( request: Request, search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"), tag_ids: list[str] | None = Query(default=None, description="Keep tasks carrying any of these tags (OR)"), ) -> list[dict]: service = get_scheduled_task_service() user_id = await _user_id(request) tasks = await service.list_tasks(user_id) await _enrich_with_creator(tasks) tag_store = get_tag_store(request) assignments = await tag_store.list_assignments("scheduled_task", [str(t.get("task_id")) for t in tasks]) for task in tasks: task["tags"] = assignments.get(str(task.get("task_id")), []) if search: needle = search.strip().lower() tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()] if tag_ids: wanted = set(tag_ids) tasks = [t for t in tasks if any(tg.get("id") in wanted for tg in t.get("tags", []))] return tasks @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 _load_run(service, request, run_id, user_id) if run is None: raise HTTPException(status_code=404, detail=f"Run {run_id} not found") return run @router.get("/runs/{run_id}/html") async def get_scheduled_task_run_html(run_id: str, request: Request) -> dict: """Return the generated HTML document for an ``html_page`` run.""" service = get_scheduled_task_service() user_id = await _user_id(request) run = await _load_run(service, request, run_id, user_id) if run is None: raise HTTPException(status_code=404, detail=f"Run {run_id} not found") page = await service.get_html_page_for_run(run_id) if page is None: raise HTTPException(status_code=404, detail="该执行记录没有 HTML 页面") return { "run_id": run_id, "task_id": page.get("task_id") or run.get("task_id"), "title": page.get("title"), "html": page.get("html_content"), "style_summary": page.get("style_summary"), "layout_summary": page.get("layout_summary"), "page_id": page.get("id"), "created_at": page.get("created_at"), } def _event_type_for_checkpoint_message(message: dict) -> str: """Map a serialized LangChain message to the same ``event_type`` strings the run-event store uses, so the frontend's existing role labels keep working for checkpoint-derived synthetic rows.""" raw_type = message.get("type") or message.get("role") or "" if raw_type in {"human", "user"}: return "llm.human.input" if raw_type in {"ai", "assistant"}: return "llm.ai.response" if raw_type == "tool": return "llm.tool.result" if raw_type == "system": return "llm.system" return f"llm.{raw_type or 'message'}" async def _checkpoint_messages_for_run( request: Request, agent_run: dict, *, limit: int ) -> list[dict]: """Return a synthetic list of message rows pulled from the LangGraph checkpoint for an in-flight run. The event store is updated by the streaming pipeline and may stay empty until the first message tuple is flushed — meanwhile the checkpoint already carries the current state (input + accumulating AI deltas). We project that into the same row shape the frontend uses for the event store, so the run-detail page can show progress immediately.""" try: checkpointer = get_checkpointer(request) except Exception: return [] thread_id = agent_run.get("thread_id") if not thread_id: return [] config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} try: tup = await checkpointer.aget_tuple(config) except Exception: return [] if tup is None: return [] checkpoint = getattr(tup, "checkpoint", {}) or {} channel_values = checkpoint.get("channel_values") or {} try: values = serialize_channel_values(channel_values) except Exception: return [] messages = values.get("messages") or [] rows: list[dict] = [] for index, message in enumerate(messages[-limit:]): if not isinstance(message, dict): continue rows.append( { "seq": index + 1, "event_type": _event_type_for_checkpoint_message(message), "category": "message", "content": message, "metadata": {"source": "checkpoint"}, "created_at": None, } ) return rows @router.get("/runs/{run_id}/messages") async def get_scheduled_task_run_messages(run_id: str, request: Request, limit: int = 500) -> dict: """Agent messages produced by a scheduled run's underlying agent run. Resolution order (most authoritative first): 1. Event-store rows for the underlying agent run — these are the per-tuple streaming events the worker writes as the agent runs. 2. LangGraph checkpoint snapshot — the canonical conversation state. Used as a fallback when the event store is still empty (early in a run) or when only partial events have been flushed; we project the snapshot into the same row shape the frontend already renders so the user can see the prompt + current AI output while the task is mid-flight. 3. Synthetic human-input row built from ``task.prompt`` — shown even before the agent run is registered, so a `pending` task at minimum displays what was queued. """ service = get_scheduled_task_service() user_id = await _user_id(request) run = await _load_run(service, request, run_id, user_id) if run is None: raise HTTPException(status_code=404, detail=f"Run {run_id} not found") capped = max(1, min(limit, 1000)) # Always available: task prompt as the seeded human input. Lets the # ``execution process`` tab show *something* even before the agent run is # registered, which is the user-visible bug we're fixing. fallback_rows: list[dict] = [] task_id = run.get("task_id") if task_id: task = await service.store.get_task_any(task_id) prompt = (task or {}).get("prompt") if prompt: fallback_rows.append( { "seq": 0, "event_type": "llm.human.input", "category": "message", # Frontend treats human-input content as a plain string. "content": str(prompt), "metadata": {"source": "task_prompt"}, "created_at": run.get("created_at") or run.get("scheduled_for"), } ) agent_run_id = run.get("agent_run_id") if not agent_run_id: return {"messages": fallback_rows, "has_more": False} # Each scheduled run executes on its own throwaway runtime thread — NOT the # run row's ``scheduler_thread_id`` — so resolve the real thread from the # run store before querying its message events. run_store = get_run_store(request) agent_run = await run_store.get(agent_run_id, user_id=None) if agent_run is None: return {"messages": fallback_rows, "has_more": False} event_store = get_run_event_store(request) rows = await event_store.list_messages_by_run( agent_run["thread_id"], agent_run_id, limit=capped + 1, user_id=None, ) # Event store has no rows yet (run just started, or events still buffering). # Fall back to the live checkpoint so the user immediately sees the prompt # + whatever AI output has been streamed so far. if not rows: checkpoint_rows = await _checkpoint_messages_for_run(request, agent_run, limit=capped) if checkpoint_rows: return {"messages": checkpoint_rows[:capped], "has_more": False} return {"messages": fallback_rows, "has_more": False} has_more = len(rows) > capped return {"messages": rows[:capped] if has_more else rows, "has_more": has_more} @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) try: deleted = await service.delete_run(user_id, run_id) except PermissionError as exc: # Owner-only for regular users; admins may still delete any run. if await _is_admin(request): deleted = await service.admin_delete_run(run_id) else: raise HTTPException(status_code=403, detail=str(exc)) # A run the caller does not own returns deleted=False — fall back to an # admin-scoped delete so admins can clean up other users' runs. if not deleted and await _is_admin(request): deleted = await service.admin_delete_run(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 _load_run(service, request, run_id, user_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.put("/runs/{run_id}/files/{file_index}") async def update_scheduled_task_run_file( run_id: str, file_index: int, body: ScheduledTaskRunFileUpdateRequest, request: Request, ) -> dict: if len(body.content.encode("utf-8")) > _MAX_RUN_FILE_EDIT_BYTES: raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit") service = get_scheduled_task_service() user_id = await _user_id(request) try: return await service.update_run_file(user_id, run_id, file_index, body.content) except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) @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 _load_run(service, request, run_id, user_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 _load_run(service, request, run_id, user_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) try: task = await service.create_task( user_id=user_id, name=body.name, description=body.description, prompt=body.prompt, schedule=body.schedule, timezone=body.timezone, execution_agent_name=body.execution_agent_name, execution_context=body.execution_context, require_markdown=body.require_markdown, ) except DuplicateTaskNameError: raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称") await _enrich_with_creator(task) return task @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) try: task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True)) except DuplicateTaskNameError: raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称") if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id} not found") await _enrich_with_creator(task) 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") try: await get_tag_store(request).unassign_all("scheduled_task", task_id) except Exception: # noqa: BLE001 — tag cleanup must not block deletion pass 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} # --- Publish / subscribe --- @router.get("/published") async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]: service = get_scheduled_task_service() await _user_id(request) tasks = await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset)) # published list uses owner_user_id; resolve it into a creator_email here too # so the discovery view can show the author's account. for task in tasks: if isinstance(task, dict) and task.get("owner_user_id"): task["user_id"] = task["owner_user_id"] await _enrich_with_creator(tasks) for task in tasks: if isinstance(task, dict): task.pop("user_id", None) return tasks @router.post("/{task_id}/publish") async def publish_scheduled_task(task_id: str, request: Request) -> dict: service = get_scheduled_task_service() user_id = await _user_id(request) task = await service.publish_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}/unpublish") async def unpublish_scheduled_task(task_id: str, request: Request) -> dict: service = get_scheduled_task_service() user_id = await _user_id(request) task = await service.unpublish_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}/subscribe") async def subscribe_scheduled_task(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict: service = get_scheduled_task_service() user_id = await _user_id(request) sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element) if sub is None: raise HTTPException(status_code=404, detail="Task is not available for subscription") return sub @router.patch("/{task_id}/subscribe") async def update_scheduled_task_subscription(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict: service = get_scheduled_task_service() user_id = await _user_id(request) sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element) if sub is None: raise HTTPException(status_code=404, detail="Subscription not found") return sub @router.delete("/{task_id}/subscribe") async def delete_scheduled_task_subscription(task_id: str, request: Request) -> dict: service = get_scheduled_task_service() user_id = await _user_id(request) deleted = await service.unsubscribe_task(user_id, task_id) if not deleted: raise HTTPException(status_code=404, detail="Subscription not found") return {"success": True} # --- Admin (cross-user management) --- async def _require_admin(request: Request) -> None: """Reject non-admins. Lets the call through when auth is disabled.""" user = await get_optional_user_from_request(request) if user is None: return if getattr(user, "system_role", None) != "admin": raise HTTPException(status_code=403, detail="定时任务管理仅限管理员") @router.get("/admin/all") async def admin_list_scheduled_tasks( request: Request, search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"), ) -> list[dict]: """List every scheduled task across all users. Admin-only.""" await _require_admin(request) service = get_scheduled_task_service() tasks = await service.admin_list_tasks() await _enrich_with_creator(tasks) tag_store = get_tag_store(request) assignments = await tag_store.list_assignments("scheduled_task", [str(t.get("task_id")) for t in tasks]) for task in tasks: task["tags"] = assignments.get(str(task.get("task_id")), []) if search: needle = search.strip().lower() tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()] return tasks @router.get("/admin/{task_id}/runs") async def admin_list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]: """List runs for any task regardless of owner. Admin-only.""" await _require_admin(request) service = get_scheduled_task_service() return await service.admin_list_runs(task_id, limit=max(1, min(limit, 200))) @router.patch("/admin/{task_id}") async def admin_update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict: """Update any task regardless of owner. Admin-only.""" await _require_admin(request) service = get_scheduled_task_service() try: task = await service.admin_update_task(task_id, body.model_dump(exclude_unset=True)) except DuplicateTaskNameError: raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称") if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id} not found") await _enrich_with_creator(task) return task @router.delete("/admin/{task_id}") async def admin_delete_scheduled_task(task_id: str, request: Request) -> dict: """Delete any task regardless of owner. Admin-only.""" await _require_admin(request) service = get_scheduled_task_service() deleted = await service.admin_delete_task(task_id) if not deleted: raise HTTPException(status_code=404, detail=f"Task {task_id} not found") try: await get_tag_store(request).unassign_all("scheduled_task", task_id) except Exception: # noqa: BLE001 — tag cleanup must not block deletion pass return {"success": True} @router.post("/admin/{task_id}/trigger") async def admin_trigger_scheduled_task(task_id: str, request: Request) -> dict: """Trigger an immediate run of any task regardless of owner. Admin-only.""" await _require_admin(request) service = get_scheduled_task_service() task = await service.admin_trigger_task(task_id) if task is None: raise HTTPException(status_code=404, detail=f"Task {task_id} not found") return {"success": True, "task": task}