"""SproutClaw Cron 定时任务管理面板 API。""" from __future__ import annotations import subprocess import sys import threading from pathlib import Path from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel CRON_ROOT = Path(__file__).resolve().parents[2] FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend" sys.path.insert(0, str(CRON_ROOT / "lib")) from manager import ( # noqa: E402 build_task_info, list_tasks, read_log_tail, set_task_state, sync_cron_d, toggle_task, update_task_schedule, ) from manager import get_context # noqa: E402 from runner import task_is_enabled # noqa: E402 app = FastAPI(title="SproutClaw Cron", version="1.0.0") _run_jobs: dict[str, subprocess.Popen[bytes]] = {} _run_lock = threading.Lock() class MessageResponse(BaseModel): ok: bool = True message: str = "" class RunResponse(BaseModel): ok: bool = True message: str pid: int | None = None class ScheduleUpdate(BaseModel): description: str = "" schedule: str tags: list[str] = [] class ScheduleUpdateResponse(BaseModel): ok: bool = True message: str = "" task: dict[str, object] def _task_not_found(exc: FileNotFoundError) -> HTTPException: return HTTPException(status_code=404, detail=str(exc)) def _cleanup_run_job(task_id: str, proc: subprocess.Popen[bytes]) -> None: proc.wait() with _run_lock: current = _run_jobs.get(task_id) if current is proc: _run_jobs.pop(task_id, None) @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} @app.get("/api/runtimes") def api_runtimes() -> list[dict[str, object]]: from runtime_probe import probe_all_runtimes return probe_all_runtimes() @app.get("/api/tasks") def api_list_tasks() -> list[dict[str, object]]: return [task.to_dict() for task in list_tasks(CRON_ROOT)] @app.get("/api/tasks/{task_id}") def api_get_task(task_id: str) -> dict[str, object]: try: return build_task_info(task_id, CRON_ROOT).to_dict() except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.post("/api/tasks/{task_id}/enable") def api_enable_task(task_id: str) -> dict[str, object]: try: info, _message = set_task_state(task_id, True, CRON_ROOT) return info.to_dict() except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.post("/api/tasks/{task_id}/disable") def api_disable_task(task_id: str) -> dict[str, object]: try: info, _message = set_task_state(task_id, False, CRON_ROOT) return info.to_dict() except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.post("/api/tasks/{task_id}/toggle") def api_toggle_task(task_id: str) -> dict[str, object]: try: info, _message = toggle_task(task_id, CRON_ROOT) return info.to_dict() except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.post("/api/tasks/{task_id}/sync-cron", response_model=MessageResponse) def api_sync_cron(task_id: str) -> MessageResponse: try: ctx = get_context(task_id, CRON_ROOT) message = sync_cron_d(ctx, task_is_enabled(ctx)) or "已同步" return MessageResponse(message=message) except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.patch("/api/tasks/{task_id}/schedule", response_model=ScheduleUpdateResponse) def api_update_schedule(task_id: str, body: ScheduleUpdate) -> ScheduleUpdateResponse: try: info, message = update_task_schedule( task_id, description=body.description, schedule=body.schedule, tags=body.tags, root=CRON_ROOT, sync=True, ) return ScheduleUpdateResponse( message=message or "已保存", task=info.to_dict(), ) except FileNotFoundError as exc: raise _task_not_found(exc) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.post("/api/tasks/{task_id}/run", response_model=RunResponse) def api_run_task(task_id: str) -> RunResponse: try: get_context(task_id, CRON_ROOT) except FileNotFoundError as exc: raise _task_not_found(exc) from exc with _run_lock: existing = _run_jobs.get(task_id) if existing is not None and existing.poll() is None: return RunResponse( ok=False, message="任务已在后台运行", pid=existing.pid, ) proc = subprocess.Popen( [sys.executable, str(CRON_ROOT / "cronctl.py"), "run", task_id], cwd=str(CRON_ROOT), ) _run_jobs[task_id] = proc threading.Thread( target=_cleanup_run_job, args=(task_id, proc), daemon=True, ).start() return RunResponse(message="已在后台启动", pid=proc.pid) @app.get("/api/tasks/{task_id}/log") def api_task_log( task_id: str, lines: int = Query(default=200, ge=1, le=2000), ) -> dict[str, str]: try: content = read_log_tail(task_id, lines, CRON_ROOT) return {"task_id": task_id, "content": content} except FileNotFoundError as exc: raise _task_not_found(exc) from exc @app.get("/") def index() -> FileResponse: return FileResponse(FRONTEND_DIR / "index.html") app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")