Files
OpenMesh/coworker/automation/store.py
zhaolei 6f402ffcee
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled
feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
2026-09-13 23:41:04 +08:00

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()