重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。 Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
3.3 KiB
Python
129 lines
3.3 KiB
Python
"""跨平台辅助:文件锁、Windows 任务计划程序同步。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
def is_windows() -> bool:
|
||
return sys.platform == "win32"
|
||
|
||
|
||
def lock_ex_nb(fd: int) -> None:
|
||
"""非阻塞独占锁;无法获取时抛出 BlockingIOError。"""
|
||
if is_windows():
|
||
import msvcrt
|
||
|
||
try:
|
||
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
||
except OSError as exc:
|
||
raise BlockingIOError from exc
|
||
return
|
||
|
||
import fcntl
|
||
|
||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
|
||
|
||
def unlock(fd: int) -> None:
|
||
if is_windows():
|
||
import msvcrt
|
||
|
||
try:
|
||
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
||
except OSError:
|
||
pass
|
||
return
|
||
|
||
import fcntl
|
||
|
||
try:
|
||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def windows_task_name(task_id: str) -> str:
|
||
return f"sproutclaw-{task_id}"
|
||
|
||
|
||
def _cron_to_daily_time(expression: str) -> str | None:
|
||
"""将 `分 时 * * *` 转为 schtasks 的 HH:MM;不支持则返回 None。"""
|
||
parts = expression.split()
|
||
if len(parts) != 5:
|
||
return None
|
||
minute, hour, dom, month, dow = parts
|
||
if dom != "*" or month != "*" or dow != "*":
|
||
return None
|
||
if not minute.isdigit() or not hour.isdigit():
|
||
return None
|
||
return f"{int(hour):02d}:{int(minute):02d}"
|
||
|
||
|
||
def sync_windows_task(
|
||
task_id: str,
|
||
cron_root: Path,
|
||
schedule_expression: str | None,
|
||
*,
|
||
enabled: bool,
|
||
) -> str | None:
|
||
"""在 Windows 任务计划程序中注册/更新定时任务。"""
|
||
if schedule_expression is None:
|
||
return "未找到 cron 表达式,跳过任务计划注册"
|
||
|
||
daily_time = _cron_to_daily_time(schedule_expression)
|
||
if daily_time is None:
|
||
return (
|
||
f"暂不支持 cron 表达式「{schedule_expression}」自动转换,"
|
||
"请手动创建任务计划或使用 `分 时 * * *` 形式"
|
||
)
|
||
|
||
python_exe = Path(sys.executable).resolve()
|
||
cronctl = (cron_root / "cronctl.py").resolve()
|
||
task_name = windows_task_name(task_id)
|
||
run_cmd = f'"{python_exe}" "{cronctl}" run {task_id}'
|
||
|
||
query = subprocess.run(
|
||
["schtasks", "/Query", "/TN", task_name],
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
exists = query.returncode == 0
|
||
|
||
if exists:
|
||
subprocess.run(
|
||
["schtasks", "/Delete", "/TN", task_name, "/F"],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
|
||
create = subprocess.run(
|
||
[
|
||
"schtasks",
|
||
"/Create",
|
||
"/TN",
|
||
task_name,
|
||
"/TR",
|
||
run_cmd,
|
||
"/SC",
|
||
"DAILY",
|
||
"/ST",
|
||
daily_time,
|
||
"/F",
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
if create.returncode != 0:
|
||
err = (create.stderr or create.stdout or "").strip()
|
||
return f"任务计划注册失败: {err or '未知错误'}"
|
||
|
||
state = "开启" if enabled else "关闭(计划仍触发,任务自动跳过)"
|
||
action = "已更新" if exists else "已创建"
|
||
return f"{action} Windows 任务「{task_name}」,每天 {daily_time}({state})"
|