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

166 lines
4.8 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.
"""任务清单 task.json 解析与 runtime 推断。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
Runtime = Literal["python", "javascript", "bash", "powershell"]
RUNTIME_DEFAULT_ENTRY: dict[str, str] = {
"python": "run.py",
"javascript": "run.js",
"bash": "run.sh",
"powershell": "run.ps1",
}
RUNTIME_ALIASES: dict[str, Runtime] = {
"python": "python",
"py": "python",
"javascript": "javascript",
"js": "javascript",
"node": "javascript",
"bash": "bash",
"sh": "bash",
"shell": "bash",
"powershell": "powershell",
"ps1": "powershell",
"pwsh": "powershell",
}
ENTRY_TO_RUNTIME: dict[str, Runtime] = {
"run.py": "python",
"run.js": "javascript",
"run.sh": "bash",
"run.ps1": "powershell",
}
RUNTIME_LABEL: dict[str, str] = {
"python": "Python",
"javascript": "JavaScript",
"bash": "Bash",
"powershell": "PowerShell",
}
MAX_TASK_TAGS = 4
MAX_TAG_LENGTH = 32
@dataclass(frozen=True)
class TaskManifest:
runtime: Runtime
entry: str
@property
def entry_path(self) -> str:
return self.entry
@property
def label(self) -> str:
return RUNTIME_LABEL.get(self.runtime, self.runtime)
def _normalize_runtime(raw: str) -> Runtime | None:
key = raw.strip().lower()
return RUNTIME_ALIASES.get(key) # type: ignore[return-value]
def _infer_from_entry_files(task_dir: Path) -> TaskManifest | None:
for entry, runtime in ENTRY_TO_RUNTIME.items():
if (task_dir / entry).is_file():
return TaskManifest(runtime=runtime, entry=entry)
return None
def load_task_manifest(task_dir: Path) -> TaskManifest:
"""读取 task.json缺失时按入口文件推断默认 python + run.py。"""
manifest_file = task_dir / "task.json"
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
runtime_raw = str(data.get("runtime", "python"))
runtime = _normalize_runtime(runtime_raw)
if runtime is None:
raise ValueError(f"不支持的 runtime: {runtime_raw}")
entry = str(data.get("entry", RUNTIME_DEFAULT_ENTRY[runtime])).strip()
if not entry:
entry = RUNTIME_DEFAULT_ENTRY[runtime]
entry_path = task_dir / entry
if not entry_path.is_file():
raise FileNotFoundError(f"task.json 指定的入口不存在: {entry}")
return TaskManifest(runtime=runtime, entry=entry)
inferred = _infer_from_entry_files(task_dir)
if inferred is not None:
return inferred
if (task_dir / "run.py").is_file():
return TaskManifest(runtime="python", entry="run.py")
raise FileNotFoundError("未找到 task.json 或可识别的入口脚本")
def normalize_tags(raw: object) -> list[str]:
"""去重、去空白,最多 MAX_TASK_TAGS 个。"""
if not isinstance(raw, (list, tuple)):
return []
tags: list[str] = []
for item in raw:
s = str(item).strip()
if not s or len(s) > MAX_TAG_LENGTH:
continue
if s not in tags:
tags.append(s)
if len(tags) >= MAX_TASK_TAGS:
break
return tags
def load_task_tags(task_dir: Path) -> list[str]:
manifest_file = task_dir / "task.json"
if not manifest_file.is_file():
return []
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return normalize_tags(data.get("tags", []))
def effective_task_tags(task_dir: Path, manifest: TaskManifest) -> list[str]:
"""已存标签优先;否则默认用运行时名称作为唯一标签。"""
stored = load_task_tags(task_dir)
if stored:
return stored
return [RUNTIME_LABEL.get(manifest.runtime, manifest.runtime)]
def save_task_tags(task_dir: Path, tags: list[str]) -> None:
"""写入 task.json 的 tags 字段;无文件时按当前 manifest 创建。"""
manifest = load_task_manifest(task_dir)
normalized = normalize_tags(tags)
manifest_file = task_dir / "task.json"
data: dict[str, object] = {}
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
data["runtime"] = manifest.runtime
data["entry"] = manifest.entry
data["tags"] = normalized
manifest_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def is_valid_task_dir(task_dir: Path) -> bool:
try:
if not task_dir.is_dir():
return False
if not (task_dir / "schedule.cron").is_file():
return False
load_task_manifest(task_dir)
return True
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError):
return False