feat: init sproutclaw-cron 定时任务管理框架

- 每任务一目录结构,统一开关与日志
- cronctl CLI:enable/disable/toggle/status/run/sync-cron
- 公共库 lib/shumengya_cron:runner/manager/notify/ssh
- _template 示例任务(hello world + 复制模板)
- webui 管理面板:FastAPI 后端 + 文档式响应式前端
This commit is contained in:
2026-06-24 16:19:53 +08:00
commit 6c2db2dfa3
23 changed files with 2667 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""shumengya-cron共用库日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,204 @@
"""sproutclaw-cron 任务管理 API供 cronctl 与 WebUI 共用。"""
from __future__ import annotations
import fcntl
import importlib.util
import re
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from shumengya_cron.runner import (
DISABLED_DIR_NAME,
LogMode,
TaskContext,
set_task_enabled,
task_is_enabled,
)
CRON_D = Path("/etc/cron.d")
_CRON_LINE = re.compile(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
)
def cron_root() -> Path:
return Path(__file__).resolve().parents[2]
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 (path / "run.py").is_file():
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 (path / "run.py").is_file():
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 (ctx.task_dir / "run.py").is_file():
raise FileNotFoundError(f"任务目录缺少 run.py: {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 text and not text.startswith("开关") and "cronctl" not in 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 = lock_file.open("r")
try:
try:
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return False
except BlockingIOError:
return True
finally:
fd.close()
@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
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")
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),
)
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:
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None。"""
cron_d_file = CRON_D / ctx.task_id
schedule = ctx.task_dir / "schedule.cron"
if not schedule.is_file():
return "未找到 schedule.cron跳过安装"
shutil.copy2(schedule, cron_d_file)
state = "开启" if enabled else "关闭cron 仍触发run.py 自动跳过)"
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:
"""动态加载任务 run.py调用其 run(ctx) 并返回退出码。"""
ctx = get_context(task_id, root)
run_py = ctx.task_dir / "run.py"
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"

View File

@@ -0,0 +1,208 @@
"""
飞书 / Lark Markdown 通知 + 任务结果汇总。
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from shumengya_cron.runner import TaskContext
CRON_FEISHU_WEBHOOK_DEFAULT = (
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
)
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
@dataclass
class TaskResult:
"""任务执行结果,汇总 ok / skip / fail 三类条目。"""
ok: list[str] = field(default_factory=list)
skip: list[str] = field(default_factory=list)
fail: list[str] = field(default_factory=list)
def add_ok(self, item: str) -> None:
self.ok.append(item)
def add_skip(self, item: str) -> None:
self.skip.append(item)
def add_fail(self, item: str) -> None:
self.fail.append(item)
@property
def success(self) -> bool:
return not self.fail
@property
def status_cn(self) -> str:
return "成功" if self.success else "失败"
@property
def total(self) -> int:
return len(self.ok) + len(self.skip) + len(self.fail)
def markdown_list(items: list[str]) -> str:
"""将多行条目格式化为 Markdown 列表。"""
if not items:
return "- 无"
return "\n".join(f"- {x}" for x in items if x)
def markdown_fields(items: list[tuple[str, object]]) -> str:
"""将键值对格式化为更简洁的 Markdown 列表。"""
if not items:
return "- 无"
lines: list[str] = []
for key, value in items:
text = "-" if value is None or value == "" else str(value)
lines.append(f"- **{key}**{text}")
return "\n".join(lines)
def send_task_summary(
ctx: "TaskContext",
result: TaskResult,
start_time: str,
*,
log: Callable[[str], None],
extra_fields: list[tuple[str, object]] | None = None,
) -> None:
"""发送标准任务汇总通知(飞书 + 邮件降级)。
extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。
"""
from shumengya_cron.runner import task_output_prefix
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
fields: list[tuple[str, object]] = [
("任务", ctx.task_id),
("开始", start_time),
("结束", end_time),
]
if extra_fields:
fields.extend(extra_fields)
fields.extend([
("总数", result.total),
("成功", len(result.ok)),
("跳过", len(result.skip)),
("失败", len(result.fail)),
])
md = "\n".join([
markdown_fields(fields),
"", "**成功列表**", markdown_list(result.ok),
"", "**跳过列表**", markdown_list(result.skip),
"", "**失败列表**", markdown_list(result.fail),
])
send_feishu_markdown(subject, md, log=log)
def send_feishu_markdown(
title: str,
markdown: str,
*,
log: Callable[[str], None],
) -> None:
"""发送飞书 Markdown失败时自动降级为邮件通知邮件也失败则结束"""
if not title or not markdown:
log("飞书通知内容为空,跳过。")
return
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
if not webhook:
log("未配置飞书 webhook跳过飞书通知。")
return
env = os.environ.copy()
pp = api_src
if env.get("PYTHONPATH"):
pp = f"{api_src}:{env['PYTHONPATH']}"
env["PYTHONPATH"] = pp
feishu_ok = False
try:
r = subprocess.run(
[
sys.executable,
"-m",
"lark_notice_api",
"--webhook",
webhook,
"send-markdown",
"--title",
title,
"--markdown",
markdown,
],
cwd=api_src if os.path.isdir(api_src) else None,
env=env,
check=False,
capture_output=True,
text=True,
)
if r.returncode == 0:
feishu_ok = True
else:
detail = (r.stderr or r.stdout or "").strip()
if detail:
log(f"发送飞书通知失败(退出码 {r.returncode}{detail}")
else:
log(f"发送飞书通知失败(退出码 {r.returncode})。")
except OSError:
log("发送飞书通知失败lark-notice-api 调用异常。")
if feishu_ok:
return
log("飞书通知失败,尝试通过邮件发送…")
_send_mail_fallback(title, markdown, log)
# ── 邮件降级 ──────────────────────────────────────────────
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
"""通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。"""
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
if enabled != "1":
log("邮件通知未开启export CRON_MAIL_ENABLED=1 可启用),跳过。")
return
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
if not os.path.isfile(script):
log(f"邮件脚本不存在:{script},跳过邮件通知。")
return
try:
r = subprocess.run(
[sys.executable, script, "--env-file", env_file, "--format", "json",
"send-email", "--to", mail_to, "--subject", title,
"--from-name", from_name, "--html-body", markdown],
capture_output=True, text=True, check=False,
)
if r.returncode == 0:
log("邮件通知发送成功。")
else:
detail = (r.stderr or r.stdout or "").strip()
log(f"邮件通知发送失败(退出码 {r.returncode}{detail or '无详细信息'}")
except OSError as exc:
log(f"邮件通知发送异常:{exc}")

View File

@@ -0,0 +1,282 @@
"""
任务运行骨架路径、日志轮转、互斥锁、stdout 重定向。
按大小轮转日志、flock 互斥、
锁占用时退出码 0避免 cron 误报)。
"""
from __future__ import annotations
import fcntl
import os
import sys
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Callable, Iterator, TextIO
class LogMode(str, Enum):
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
REDIRECT_STD = "redirect_std" # 等价于 bash: exec >>LOG 2>&1
DIRECT_FILE = "direct_file" # 等价于 cron_log_file
def _cron_root() -> Path:
# .../lib/shumengya_cron/runner.py -> cron 根目录
return Path(__file__).resolve().parents[2]
DISABLED_DIR_NAME = ".disabled"
def disabled_tasks_root(cron_root: Path | None = None) -> Path:
root = cron_root or _cron_root()
return root / DISABLED_DIR_NAME
def active_task_dir(cron_root: Path, task_id: str) -> Path:
return cron_root / task_id
def disabled_task_dir(cron_root: Path, task_id: str) -> Path:
return disabled_tasks_root(cron_root) / task_id
def _legacy_disabled_task_dir(cron_root: Path, task_id: str) -> Path:
return cron_root / f"{task_id}.disabled"
# 固定缩写,遇到这些词时全大写而非首字母大写
_ACRONYMS = frozenset({"ai", "cli", "ssh", "api", "db", "url"})
def task_output_prefix(task_id: str) -> str:
"""将 task_id 格式化为统一输出前缀:[smallmengya][AI-CLI-Update]。"""
family, suffix = (task_id.split("-", 1) + [task_id])[:2]
display = "-".join(
p.upper() if p.lower() in _ACRONYMS else p.capitalize()
for p in suffix.split("-")
)
return f"[{family}][{display}]"
@dataclass
class TaskContext:
"""单次任务运行的上下文(每个任务目录对应一个 task_id"""
task_id: str
cron_root: Path
task_dir: Path
log_dir: Path
log_file: Path
lock_file: Path
log_mode: LogMode
@classmethod
def from_task_id(
cls,
task_id: str,
*,
log_mode: LogMode = LogMode.REDIRECT_STD,
cron_root: Path | None = None,
) -> TaskContext:
"""根据 task_id 创建上下文,自动识别 .disabled/<task-id>/ 目录。"""
root = cron_root or _cron_root()
disabled_dir = disabled_task_dir(root, task_id).resolve()
active_dir = active_task_dir(root, task_id).resolve()
legacy_disabled_dir = _legacy_disabled_task_dir(root, task_id).resolve()
if disabled_dir.is_dir():
task_dir = disabled_dir
elif legacy_disabled_dir.is_dir():
task_dir = legacy_disabled_dir
elif active_dir.is_dir():
task_dir = active_dir
else:
task_dir = active_dir
log_dir = task_dir / "logs"
log_file = log_dir / f"{task_id}.log"
lock_file = task_dir / f"{task_id}.lock"
return cls(
task_id=task_id,
cron_root=root,
task_dir=task_dir,
log_dir=log_dir,
log_file=log_file,
lock_file=lock_file,
log_mode=log_mode,
)
def task_is_enabled(ctx: TaskContext) -> bool:
"""任务位于 cron 根目录下为开启,位于 .disabled/ 或 *.disabled 下为关闭。"""
if ctx.task_dir.name.endswith(".disabled"):
return False
disabled_root = disabled_tasks_root(ctx.cron_root).resolve()
try:
return not ctx.task_dir.resolve().is_relative_to(disabled_root)
except AttributeError:
return ctx.task_dir.resolve().parent != disabled_root
def task_is_disabled(ctx: TaskContext) -> bool:
return not task_is_enabled(ctx)
def set_task_enabled(ctx: TaskContext, enabled: bool) -> None:
"""通过移动目录切换任务状态:启用时在根目录,禁用时在 .disabled/ 下。"""
root = ctx.cron_root
active_dir = active_task_dir(root, ctx.task_id)
disabled_dir = disabled_task_dir(root, ctx.task_id)
legacy_disabled_dir = _legacy_disabled_task_dir(root, ctx.task_id)
if legacy_disabled_dir.is_dir() and not disabled_dir.is_dir():
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
legacy_disabled_dir.rename(disabled_dir)
if enabled:
if disabled_dir.is_dir() and not active_dir.is_dir():
active_dir.parent.mkdir(parents=True, exist_ok=True)
disabled_dir.rename(active_dir)
ctx.task_dir = active_dir
elif active_dir.is_dir():
ctx.task_dir = active_dir
return
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
if active_dir.is_dir() and not disabled_dir.is_dir():
active_dir.rename(disabled_dir)
ctx.task_dir = disabled_dir
elif disabled_dir.is_dir():
ctx.task_dir = disabled_dir
def task_enabled_text(ctx: TaskContext) -> str:
return "开启" if task_is_enabled(ctx) else "关闭"
def cron_log_rotate(log_file: Path, max_bytes: int | None = None) -> None:
"""单日志超过阈值则轮转(默认 10MB与 legacy cron_log_rotate 一致。"""
mb = max_bytes if max_bytes is not None else int(
os.environ.get("CRON_LOG_MAX_BYTES", str(10 * 1024 * 1024))
)
try:
if not log_file.is_file():
return
if log_file.stat().st_size <= mb:
return
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
log_file.rename(log_file.with_name(f"{log_file.name}.{stamp}"))
except OSError:
pass
@contextmanager
def acquire_cron_lock(
lock_file: Path,
*,
busy_message: str | None = None,
log: Callable[[str], None],
) -> Iterator[bool]:
"""
获取互斥锁;若已被占用则记录日志并 yield False调用方应 sys.exit(0))。
使用 flock与 bash 版一致)。
"""
msg = busy_message or os.environ.get(
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
)
lock_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log(msg)
yield False
return
yield True
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
def _tee_streams(log_fp: TextIO) -> tuple[TextIO, TextIO]:
"""将 stdout/stderr 同时写到日志与原始 fd便于调试时仍可在终端看到"""
class Tee(TextIO):
def __init__(self, *streams: TextIO) -> None:
self._streams = streams
def write(self, s: str) -> int:
n = 0
for st in self._streams:
n = st.write(s)
st.flush()
return n
def flush(self) -> None:
for st in self._streams:
st.flush()
# 保留原 stdout/stderr 供 Tee
orig_out = sys.__stdout__
orig_err = sys.__stderr__
out = Tee(log_fp, orig_out)
err = Tee(log_fp, orig_err)
return out, err
@contextmanager
def task_logging(ctx: TaskContext) -> Iterator[Callable[[str], None]]:
"""
按 log_mode 配置日志。
- REDIRECT_STD轮转后重定向 stdout/stderr 到日志文件(并 tee 到原终端)。
- DIRECT_FILE不重定向返回 log_line() 仅写文件。
"""
ctx.log_dir.mkdir(parents=True, exist_ok=True)
cron_log_rotate(ctx.log_file)
prefix = task_output_prefix(ctx.task_id)
if ctx.log_mode == LogMode.DIRECT_FILE:
def log_line(message: str) -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with ctx.log_file.open("a", encoding="utf-8") as fp:
fp.write(f"[{ts}] {prefix} {message}\n")
yield log_line
return
log_fp = ctx.log_file.open("a", encoding="utf-8")
try:
out, err = _tee_streams(log_fp)
sys.stdout, sys.stderr = out, err
def log_line(message: str) -> None:
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] {prefix} {message}", flush=True)
yield log_line
finally:
if ctx.log_mode == LogMode.REDIRECT_STD:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
log_fp.close()
def append_raw_to_log(log_file: Path, text: str) -> None:
"""将多行原文追加到日志(命令输出等)。"""
if not text:
return
with log_file.open("a", encoding="utf-8") as fp:
fp.write(text)
if not text.endswith("\n"):
fp.write("\n")

25
lib/shumengya_cron/ssh.py Normal file
View File

@@ -0,0 +1,25 @@
"""
远端 SSH用 bash -lc 执行命令,使 PATH 与登录 shell 一致(非交互 ssh 常缺 /usr/local/bin 等,导致找不到 docker
"""
from __future__ import annotations
import subprocess
def ssh_bash_lc(host: str, remote_cmd: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
host,
"bash",
"-lc",
remote_cmd,
],
capture_output=True,
text=True,
)