feat: 多语言任务、WebUI 增强与 Agent MCP 集成

重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 21:45:25 +08:00
parent 6c2db2dfa3
commit c10cacd5c6
59 changed files with 3989 additions and 800 deletions

325
lib/manager.py Normal file
View File

@@ -0,0 +1,325 @@
"""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