Files
sproutclaw-cron/lib/manager.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

326 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""sproutclaw-cron 任务管理 API供 cronctl 与 WebUI 共用。"""
from __future__ import annotations
import importlib.util
import os
import re
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from external_runner import run_external_task
from cron_platform import is_windows, lock_ex_nb, sync_windows_task, unlock
from runner import (
DISABLED_DIR_NAME,
LogMode,
TaskContext,
set_task_enabled,
task_is_enabled,
)
from task_manifest import (
effective_task_tags,
is_valid_task_dir,
load_task_manifest,
load_task_tags,
normalize_tags,
save_task_tags,
)
CRON_D = Path("/etc/cron.d")
_CRON_LINE = re.compile(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
)
_DESC_SKIP_PREFIX = ("开关",)
def _is_description_comment(text: str) -> bool:
if not text:
return False
if text.startswith(_DESC_SKIP_PREFIX):
return False
if "cronctl" in text:
return False
return True
def _validate_cron_schedule(schedule: str) -> str:
parts = schedule.strip().split()
if len(parts) != 5:
raise ValueError("cron 表达式须为 5 段:分 时 日 月 周")
return " ".join(parts)
def _replace_cron_expression(line: str, new_schedule: str) -> str:
stripped = line.strip()
if not _CRON_LINE.match(stripped):
return line
parts = stripped.split(None, 6)
if len(parts) < 7:
raise ValueError("schedule.cron 中的 cron 行格式无法识别")
return f"{new_schedule} {parts[5]} {parts[6]}"
def cron_root() -> Path:
return Path(__file__).resolve().parents[1]
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
"""将旧的 <task-id>.disabled/ 目录迁入 .disabled/<task-id>/。"""
base = root or cron_root()
disabled_root = base / DISABLED_DIR_NAME
disabled_root.mkdir(exist_ok=True)
for path in sorted(base.iterdir()):
if not path.is_dir() or not path.name.endswith(".disabled"):
continue
task_id = path.name.removesuffix(".disabled")
target = disabled_root / task_id
if target.exists():
continue
path.rename(target)
def iter_task_ids(root: Path | None = None) -> list[str]:
"""遍历根目录与 .disabled/ 下的任务,返回 task_id 列表。"""
base = root or cron_root()
skip = {"lib", "__pycache__", DISABLED_DIR_NAME, ".claude", "webui"}
task_ids: set[str] = set()
for path in sorted(base.iterdir()):
if not path.is_dir() or path.name in skip or path.name.startswith("."):
continue
if path.name.endswith(".disabled"):
task_ids.add(path.name.removesuffix(".disabled"))
continue
if is_valid_task_dir(path):
task_ids.add(path.name)
disabled_root = base / DISABLED_DIR_NAME
if disabled_root.is_dir():
for path in sorted(disabled_root.iterdir()):
if path.is_dir() and is_valid_task_dir(path):
task_ids.add(path.name)
return sorted(task_ids)
def get_context(task_id: str, root: Path | None = None) -> TaskContext:
ctx = TaskContext.from_task_id(task_id, cron_root=root)
if not ctx.task_dir.is_dir():
raise FileNotFoundError(f"任务不存在: {task_id}")
if not is_valid_task_dir(ctx.task_dir):
raise FileNotFoundError(f"任务目录缺少 schedule.cron 或入口脚本: {task_id}")
return ctx
def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
schedule = task_dir / "schedule.cron"
if not schedule.is_file():
return None, None
description: str | None = None
expression: str | None = None
for raw in schedule.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#"):
if description is None:
text = line.lstrip("#").strip()
if _is_description_comment(text):
description = text
continue
match = _CRON_LINE.match(line)
if match:
expression = match.group(1)
break
return expression, description
def task_is_running(ctx: TaskContext) -> bool:
lock_file = ctx.lock_file
if not lock_file.is_file():
return False
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
lock_ex_nb(fd)
return False
except BlockingIOError:
return True
finally:
unlock(fd)
os.close(fd)
@dataclass
class TaskInfo:
task_id: str
enabled: bool
running: bool
schedule: str | None
description: str | None
log_size: int
log_updated: str | None
task_dir: str
runtime: str
tags: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
ctx = get_context(task_id, root)
schedule, description = _parse_schedule(ctx.task_dir)
log_size = 0
log_updated: str | None = None
if ctx.log_file.is_file():
stat = ctx.log_file.stat()
log_size = stat.st_size
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
manifest = load_task_manifest(ctx.task_dir)
return TaskInfo(
task_id=task_id,
enabled=task_is_enabled(ctx),
running=task_is_running(ctx),
schedule=schedule,
description=description,
log_size=log_size,
log_updated=log_updated,
task_dir=str(ctx.task_dir),
runtime=manifest.runtime,
tags=effective_task_tags(ctx.task_dir, manifest),
)
def list_tasks(root: Path | None = None) -> list[TaskInfo]:
migrate_legacy_disabled_layout(root)
return [build_task_info(task_id, root) for task_id in iter_task_ids(root)]
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
"""安装定时调度Linux 写入 /etc/cron.d/Windows 注册任务计划程序。"""
schedule = ctx.task_dir / "schedule.cron"
if not schedule.is_file():
return "未找到 schedule.cron跳过安装"
expression, _ = _parse_schedule(ctx.task_dir)
if is_windows():
return sync_windows_task(
ctx.task_id,
ctx.cron_root,
expression,
enabled=enabled,
)
cron_d_file = CRON_D / ctx.task_id
shutil.copy2(schedule, cron_d_file)
state = "开启" if enabled else "关闭cron 仍触发,任务自动跳过)"
return f"已同步到 {cron_d_file}{state}"
def set_task_state(task_id: str, enabled: bool, root: Path | None = None) -> tuple[TaskInfo, str | None]:
ctx = get_context(task_id, root)
set_task_enabled(ctx, enabled)
ctx = TaskContext.from_task_id(task_id, cron_root=root)
message = sync_cron_d(ctx, enabled)
return build_task_info(task_id, root), message
def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str | None]:
ctx = get_context(task_id, root)
return set_task_state(task_id, not task_is_enabled(ctx), root)
def run_task(task_id: str, root: Path | None = None) -> int:
"""执行任务Python 动态加载 run.py其余 runtime 由 external_runner 启动。"""
ctx = get_context(task_id, root)
manifest = load_task_manifest(ctx.task_dir)
if manifest.runtime != "python":
return run_external_task(ctx, manifest)
run_py = ctx.task_dir / manifest.entry
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(mod) # type: ignore[union-attr]
log_mode = getattr(mod, "LOG_MODE", LogMode.REDIRECT_STD)
ctx = TaskContext.from_task_id(task_id, log_mode=log_mode, cron_root=root)
return mod.run(ctx)
def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> str:
ctx = get_context(task_id, root)
if not ctx.log_file.is_file():
return ""
content = ctx.log_file.read_text(encoding="utf-8", errors="replace")
parts = content.splitlines()
if lines <= 0 or len(parts) <= lines:
return content
return "\n".join(parts[-lines:]) + "\n"
def update_task_schedule(
task_id: str,
*,
description: str,
schedule: str,
tags: list[str] | None = None,
root: Path | None = None,
sync: bool = True,
) -> tuple[TaskInfo, str | None]:
"""更新 schedule.cron 中的描述注释、cron 表达式前五段,以及 task.json 标签。"""
ctx = get_context(task_id, root)
schedule_file = ctx.task_dir / "schedule.cron"
if not schedule_file.is_file():
raise FileNotFoundError(f"未找到 schedule.cron: {task_id}")
new_schedule = _validate_cron_schedule(schedule)
desc_text = description.strip()
raw_lines = schedule_file.read_text(encoding="utf-8").splitlines()
desc_updated = False
cron_updated = False
new_lines: list[str] = []
for line in raw_lines:
stripped = line.strip()
if not desc_updated and stripped.startswith("#"):
text = stripped.lstrip("#").strip()
if _is_description_comment(text):
new_lines.append(f"# {desc_text}" if desc_text else "#")
desc_updated = True
continue
if _CRON_LINE.match(stripped):
new_lines.append(_replace_cron_expression(line, new_schedule))
cron_updated = True
continue
new_lines.append(line)
if not cron_updated:
raise ValueError("schedule.cron 中未找到 cron 调度行")
if not desc_updated and desc_text:
inserted: list[str] = []
for line in new_lines:
if _CRON_LINE.match(line.strip()) and (not inserted or inserted[-1] != ""):
inserted.append(f"# {desc_text}")
inserted.append("")
inserted.append(line)
new_lines = inserted
schedule_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
if tags is not None:
save_task_tags(ctx.task_dir, normalize_tags(tags))
message = None
if sync:
ctx = get_context(task_id, root)
message = sync_cron_d(ctx, task_is_enabled(ctx))
return build_task_info(task_id, root), message