重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。 Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
5.5 KiB
Python
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")
|