Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

415 lines
14 KiB
Python

"""Built-in cron service — scheduled tasks for chat and partners.
A trimmed-down take on nanobot's CronService (docs/ref/nanobot): same job
semantics (``at`` / ``every`` / ``cron`` schedules, JSON persistence, run
bookkeeping) without the multi-process file-lock/action-log machinery —
DeepTutor runs one server process, so a single in-process scheduler owns
the store.
Jobs carry an *owner*: a chat session (the reply is appended to that
session) or a partner conversation (the prompt is injected into the
partner's message bus and the reply rides the original IM channel). The
executor lives in :mod:`deeptutor.services.cron.executor`.
"""
from __future__ import annotations
import asyncio
from dataclasses import asdict, dataclass, field
from datetime import datetime
import json
import logging
from pathlib import Path
import time
from typing import Any, Awaitable, Callable
import uuid
logger = logging.getLogger(__name__)
# Re-check the schedule at least this often even when nothing is due —
# cheap, and it picks up externally-edited stores within a minute.
_MAX_SLEEP_SECONDS = 60.0
_MAX_RUN_HISTORY = 10
def _now_ms() -> int:
return int(time.time() * 1000)
@dataclass
class CronSchedule:
"""When a job runs: one-shot, fixed interval, or cron expression."""
kind: str # "at" | "every" | "cron"
at_ms: int | None = None # "at": epoch ms
every_seconds: int | None = None # "every": interval
expr: str | None = None # "cron": e.g. "0 9 * * *"
tz: str | None = None # "cron": IANA timezone
@dataclass
class CronOwner:
"""Who scheduled the job and where its output goes."""
kind: str # "chat" | "partner"
user_id: str = "" # chat: owning user
is_admin: bool = True # chat: scope restore
session_id: str = "" # chat: reply lands in this session
language: str = "en"
partner_id: str = "" # partner: owning partner
channel: str = "" # partner: originating channel
chat_id: str = "" # partner: originating chat
session_key: str = "" # partner: conversation key
channel_meta: dict[str, Any] = field(default_factory=dict) # partner: thread/reply metadata
@property
def key(self) -> str:
if self.kind == "partner":
return f"partner:{self.partner_id}"
return f"chat:{self.user_id or 'local-admin'}"
@dataclass
class CronRunRecord:
run_at_ms: int
status: str # "ok" | "error" | "skipped"
duration_ms: int = 0
error: str | None = None
@dataclass
class CronJobState:
next_run_at_ms: int | None = None
last_run_at_ms: int | None = None
last_status: str | None = None
last_error: str | None = None
run_history: list[CronRunRecord] = field(default_factory=list)
@dataclass
class CronJob:
id: str
name: str
message: str
schedule: CronSchedule
owner: CronOwner
enabled: bool = True
delete_after_run: bool = False
created_at_ms: int = 0
state: CronJobState = field(default_factory=CronJobState)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "CronJob":
state = dict(data.get("state") or {})
state["run_history"] = [CronRunRecord(**record) for record in state.get("run_history", [])]
return cls(
id=str(data["id"]),
name=str(data.get("name") or ""),
message=str(data.get("message") or ""),
schedule=CronSchedule(**(data.get("schedule") or {"kind": "every"})),
owner=CronOwner(**(data.get("owner") or {"kind": "chat"})),
enabled=bool(data.get("enabled", True)),
delete_after_run=bool(data.get("delete_after_run", False)),
created_at_ms=int(data.get("created_at_ms", 0)),
state=CronJobState(**state),
)
def compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
"""Next due time in epoch ms, or ``None`` for never/expired."""
if schedule.kind == "at":
if schedule.at_ms and schedule.at_ms > now_ms:
return schedule.at_ms
return None
if schedule.kind == "every":
if not schedule.every_seconds or schedule.every_seconds <= 0:
return None
return now_ms + schedule.every_seconds * 1000
if schedule.kind == "cron" and schedule.expr:
try:
from zoneinfo import ZoneInfo
from croniter import croniter
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
base = datetime.fromtimestamp(now_ms / 1000, tz=tz)
next_dt = croniter(schedule.expr, base).get_next(datetime)
return int(next_dt.timestamp() * 1000)
except ImportError:
raise ValueError(
"cron expressions need the 'croniter' package — "
"use an 'every' or 'at' schedule instead"
) from None
except Exception as exc:
raise ValueError(f"invalid cron expression {schedule.expr!r}: {exc}") from None
return None
def validate_schedule(schedule: CronSchedule) -> None:
"""Reject schedules that could never run (raises ValueError)."""
if schedule.kind == "at":
if not schedule.at_ms:
raise ValueError("'at' schedules need a time")
if schedule.at_ms <= _now_ms():
raise ValueError("'at' time is in the past")
return
if schedule.kind == "every":
if not schedule.every_seconds or schedule.every_seconds < 30:
raise ValueError("'every' interval must be at least 30 seconds")
return
if schedule.kind == "cron":
if schedule.tz:
try:
from zoneinfo import ZoneInfo
ZoneInfo(schedule.tz)
except Exception:
raise ValueError(f"unknown timezone {schedule.tz!r}") from None
# Raises ValueError on bad/unsupported expressions.
if compute_next_run(schedule, _now_ms()) is None:
raise ValueError(f"cron expression {schedule.expr!r} never fires")
return
raise ValueError(f"unknown schedule kind {schedule.kind!r}")
class CronService:
"""Single-process job store + scheduler."""
def __init__(
self,
store_path: Path,
on_job: Callable[[CronJob], Awaitable[tuple[str, str | None]]] | None = None,
) -> None:
"""``on_job`` returns ``(status, error)`` with status ok/error/skipped."""
self.store_path = store_path
self.on_job = on_job
self._jobs: dict[str, CronJob] = {}
self._loaded = False
self._timer_task: asyncio.Task | None = None
self._wake = asyncio.Event()
self._running = False
# ── persistence ───────────────────────────────────────────────
def _load(self) -> None:
if self._loaded:
return
self._loaded = True
if not self.store_path.exists():
return
try:
data = json.loads(self.store_path.read_text(encoding="utf-8"))
for raw in data.get("jobs", []):
job = CronJob.from_dict(raw)
self._jobs[job.id] = job
except Exception:
# Preserve the corrupt store for recovery; an empty in-memory
# view would otherwise overwrite it on the next save.
backup = self.store_path.with_suffix(f".corrupt-{int(time.time())}")
try:
self.store_path.rename(backup)
except OSError:
pass
logger.exception("Corrupt cron store moved to %s", backup)
def _save(self) -> None:
self.store_path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "jobs": [asdict(job) for job in self._jobs.values()]}
tmp = self.store_path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(self.store_path)
# ── job management ────────────────────────────────────────────
def add_job(
self,
*,
name: str,
message: str,
schedule: CronSchedule,
owner: CronOwner,
delete_after_run: bool | None = None,
) -> CronJob:
self._load()
validate_schedule(schedule)
if not message.strip():
raise ValueError("message is required")
job = CronJob(
id=uuid.uuid4().hex[:10],
name=name.strip() or message.strip()[:48],
message=message.strip(),
schedule=schedule,
owner=owner,
# One-shot jobs clean up after themselves unless told otherwise.
delete_after_run=(
delete_after_run if delete_after_run is not None else schedule.kind == "at"
),
created_at_ms=_now_ms(),
)
job.state.next_run_at_ms = compute_next_run(schedule, _now_ms())
self._jobs[job.id] = job
self._save()
self._wake.set()
return job
def list_jobs(self, owner_key: str | None = None) -> list[CronJob]:
self._load()
jobs = list(self._jobs.values())
if owner_key is not None:
jobs = [job for job in jobs if job.owner.key == owner_key]
return sorted(jobs, key=lambda job: job.state.next_run_at_ms or 0)
def get_job(self, job_id: str) -> CronJob | None:
self._load()
return self._jobs.get(job_id)
def cancel_job(self, job_id: str, *, owner_key: str | None = None) -> bool:
"""Remove a job; ``owner_key`` scopes the cancel to its owner."""
self._load()
job = self._jobs.get(job_id)
if job is None:
return False
if owner_key is not None and job.owner.key != owner_key:
return False
del self._jobs[job_id]
self._save()
self._wake.set()
return True
def remove_owner_jobs(self, owner_key: str) -> int:
"""Drop every job belonging to *owner_key* (e.g. a destroyed partner)."""
self._load()
doomed = [job_id for job_id, job in self._jobs.items() if job.owner.key == owner_key]
for job_id in doomed:
del self._jobs[job_id]
if doomed:
self._save()
self._wake.set()
return len(doomed)
# ── scheduler ─────────────────────────────────────────────────
async def start(self) -> None:
if self._running:
return
self._load()
# Re-arm interval/cron jobs whose due time passed while the server
# was down: run once now (next_run in the past stays "due"); expired
# one-shots are dropped.
now = _now_ms()
changed = False
for job in list(self._jobs.values()):
if job.schedule.kind == "at" and (job.schedule.at_ms or 0) <= now:
del self._jobs[job.id]
changed = True
if changed:
self._save()
self._running = True
self._timer_task = asyncio.create_task(self._loop(), name="cron:scheduler")
logger.info("Cron service started (%d jobs)", len(self._jobs))
async def stop(self) -> None:
self._running = False
if self._timer_task:
self._timer_task.cancel()
try:
await self._timer_task
except asyncio.CancelledError:
pass
self._timer_task = None
async def _loop(self) -> None:
while self._running:
try:
await self._tick()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Cron tick failed")
sleep_s = self._seconds_until_next_due()
self._wake.clear()
try:
await asyncio.wait_for(self._wake.wait(), timeout=sleep_s)
except asyncio.TimeoutError:
pass
def _seconds_until_next_due(self) -> float:
due_times = [
job.state.next_run_at_ms
for job in self._jobs.values()
if job.enabled and job.state.next_run_at_ms
]
if not due_times:
return _MAX_SLEEP_SECONDS
delta_s = (min(due_times) - _now_ms()) / 1000
return max(0.05, min(delta_s, _MAX_SLEEP_SECONDS))
async def _tick(self) -> None:
now = _now_ms()
for job in list(self._jobs.values()):
if not job.enabled or not job.state.next_run_at_ms:
continue
if job.state.next_run_at_ms > now:
continue
await self._run_job(job)
async def _run_job(self, job: CronJob) -> None:
started = _now_ms()
status, error = "skipped", None
if self.on_job is not None:
try:
status, error = await self.on_job(job)
except Exception as exc: # noqa: BLE001
status, error = "error", f"{type(exc).__name__}: {exc}"
logger.exception("Cron job %s (%s) crashed", job.id, job.name)
job.state.last_run_at_ms = started
job.state.last_status = status
job.state.last_error = error
job.state.run_history.append(
CronRunRecord(
run_at_ms=started,
status=status,
duration_ms=_now_ms() - started,
error=error,
)
)
job.state.run_history = job.state.run_history[-_MAX_RUN_HISTORY:]
if job.delete_after_run or job.schedule.kind == "at":
self._jobs.pop(job.id, None)
else:
job.state.next_run_at_ms = compute_next_run(job.schedule, _now_ms())
if job.state.next_run_at_ms is None:
self._jobs.pop(job.id, None)
self._save()
_service: CronService | None = None
def get_cron_service() -> CronService:
"""Process-wide cron service, anchored at the admin workspace."""
global _service
if _service is None:
from deeptutor.multi_user.paths import get_admin_path_service
from deeptutor.services.cron.executor import execute_job
store = get_admin_path_service().workspace_root / "cron" / "jobs.json"
_service = CronService(store_path=store, on_job=execute_job)
return _service
__all__ = [
"CronJob",
"CronOwner",
"CronSchedule",
"CronService",
"compute_next_run",
"get_cron_service",
"validate_schedule",
]