Files
sproutclaw-cron/webui/backend/main.py
shumengya c10cacd5c6 feat: 多语言任务、WebUI 增强与 Agent MCP 集成
重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 21:45:25 +08:00

203 lines
5.5 KiB
Python

"""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")