- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
187 lines
7.4 KiB
Python
187 lines
7.4 KiB
Python
"""SQLite-backed store for scheduled tasks + run history.
|
|
|
|
Tasks/runs are stored as JSON blobs with a few indexed columns (next_run, enabled) so the
|
|
scheduler can cheaply find what's due. `next_run` is computed with croniter, honoring the
|
|
task's timezone. Thread-safe (check_same_thread=False + a lock) since the scheduler and the
|
|
request handlers touch it from different threads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import threading
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .models import ScheduledTask, TaskRun
|
|
|
|
|
|
def compute_next_run(
|
|
task: ScheduledTask, *, after: Optional[float] = None
|
|
) -> Optional[float]:
|
|
"""Next fire time (epoch seconds), or None if the task is exhausted/one-shot-past."""
|
|
sched = task.schedule
|
|
now = after if after is not None else _epoch_now()
|
|
if sched.kind == "once":
|
|
if not sched.fire_at:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(sched.fire_at)
|
|
except ValueError:
|
|
return None
|
|
tz = _tz(sched.timezone)
|
|
if dt.tzinfo is None and tz is not None:
|
|
dt = dt.replace(tzinfo=tz)
|
|
# Naive local dt: datetime.timestamp() interprets it in the machine's zone and is
|
|
# DST-aware for the actual fire DATE (via the C library), so a "once" task set in
|
|
# summer for a winter date fires at the right wall-clock instead of an hour off.
|
|
ts = dt.timestamp()
|
|
return ts if (task.run_count == 0 and ts > now) else None
|
|
# cron
|
|
from croniter import croniter
|
|
|
|
if not sched.cron or not croniter.is_valid(sched.cron):
|
|
return None
|
|
if task.max_runs is not None and task.run_count >= task.max_runs:
|
|
return None
|
|
tz = _tz(sched.timezone)
|
|
# Local: a naive base makes croniter compute in local wall-clock and .timestamp() apply
|
|
# the correct DST offset per occurrence. A named zone anchors the base in that zone.
|
|
base = datetime.fromtimestamp(now) if tz is None else datetime.fromtimestamp(now, tz=tz)
|
|
return croniter(sched.cron, base).get_next(datetime).timestamp()
|
|
|
|
|
|
def _tz(name: str):
|
|
"""Resolve a schedule timezone to a DST-aware tzinfo, or None for the machine's local
|
|
zone. None (not a fixed-offset tzinfo) is deliberate: naive datetimes let .timestamp()/
|
|
the C library apply local DST at the fire date. A frozen `datetime.now().astimezone()`
|
|
offset baked in whatever offset was in effect at compute time and misfired across a DST
|
|
boundary. An unknown IANA name falls back to local (None) rather than raising."""
|
|
if not name or name.lower() == "local":
|
|
return None
|
|
try:
|
|
return ZoneInfo(name)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _epoch_now() -> float:
|
|
return datetime.now(timezone.utc).timestamp()
|
|
|
|
|
|
class TaskStore:
|
|
def __init__(self, path: str | Path) -> None:
|
|
self.path = str(path)
|
|
self._lock = threading.RLock()
|
|
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._init()
|
|
|
|
def _init(self) -> None:
|
|
with self._lock:
|
|
self._conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
next_run REAL,
|
|
data TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS task_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
started_at REAL NOT NULL,
|
|
data TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at DESC);
|
|
""")
|
|
self._conn.commit()
|
|
|
|
# -- tasks ------------------------------------------------------------------
|
|
def save(self, task: ScheduledTask) -> ScheduledTask:
|
|
task.updated_at = _epoch_now()
|
|
task.next_run = compute_next_run(task) if task.enabled else None
|
|
with self._lock:
|
|
self._conn.execute(
|
|
"INSERT OR REPLACE INTO scheduled_tasks (id, enabled, next_run, data) VALUES (?, ?, ?, ?)",
|
|
(
|
|
task.id,
|
|
1 if task.enabled else 0,
|
|
task.next_run,
|
|
json.dumps(task.to_dict()),
|
|
),
|
|
)
|
|
self._conn.commit()
|
|
return task
|
|
|
|
def get(self, task_id: str) -> Optional[ScheduledTask]:
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT data FROM scheduled_tasks WHERE id=?", (task_id,)
|
|
).fetchone()
|
|
return ScheduledTask.from_dict(json.loads(row["data"])) if row else None
|
|
|
|
def list(self) -> list[ScheduledTask]:
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"SELECT data FROM scheduled_tasks ORDER BY next_run IS NULL, next_run"
|
|
).fetchall()
|
|
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
|
|
|
|
def delete(self, task_id: str) -> bool:
|
|
with self._lock:
|
|
cur = self._conn.execute(
|
|
"DELETE FROM scheduled_tasks WHERE id=?", (task_id,)
|
|
)
|
|
self._conn.execute("DELETE FROM task_runs WHERE task_id=?", (task_id,))
|
|
self._conn.commit()
|
|
return cur.rowcount > 0
|
|
|
|
def due(self, *, now: Optional[float] = None) -> list[ScheduledTask]:
|
|
now = now if now is not None else _epoch_now()
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"SELECT data FROM scheduled_tasks WHERE enabled=1 AND next_run IS NOT NULL AND next_run<=? ORDER BY next_run",
|
|
(now,),
|
|
).fetchall()
|
|
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
|
|
|
|
# -- runs -------------------------------------------------------------------
|
|
def add_run(self, run: TaskRun) -> TaskRun:
|
|
with self._lock:
|
|
self._conn.execute(
|
|
"INSERT OR REPLACE INTO task_runs (run_id, task_id, started_at, data) VALUES (?, ?, ?, ?)",
|
|
(run.run_id, run.task_id, run.started_at, json.dumps(run.to_dict())),
|
|
)
|
|
self._conn.commit()
|
|
return run
|
|
|
|
def find_run(self, run_id: str) -> Optional[TaskRun]:
|
|
with self._lock:
|
|
row = self._conn.execute(
|
|
"SELECT data FROM task_runs WHERE run_id=?", (run_id,)
|
|
).fetchone()
|
|
return TaskRun.from_dict(json.loads(row["data"])) if row else None
|
|
|
|
def task_for_run_session(self, session_id: str) -> Optional[ScheduledTask]:
|
|
"""The owning task of a run session ('__run__<run_id>'), or None. How standing
|
|
scoped approvals resolve which automation a live approval belongs to (§25)."""
|
|
if not session_id.startswith("__run__"):
|
|
return None
|
|
run = self.find_run(session_id[len("__run__") :])
|
|
return self.get(run.task_id) if run else None
|
|
|
|
def runs(self, task_id: str, *, limit: int = 50) -> list[TaskRun]:
|
|
with self._lock:
|
|
rows = self._conn.execute(
|
|
"SELECT data FROM task_runs WHERE task_id=? ORDER BY started_at DESC LIMIT ?",
|
|
(task_id, limit),
|
|
).fetchall()
|
|
return [TaskRun.from_dict(json.loads(r["data"])) for r in rows]
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
self._conn.close()
|