bub-schedule 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bub_schedule-0.0.1/PKG-INFO +9 -0
- bub_schedule-0.0.1/pyproject.toml +28 -0
- bub_schedule-0.0.1/src/bub_schedule/__init__.py +2 -0
- bub_schedule-0.0.1/src/bub_schedule/channel.py +43 -0
- bub_schedule-0.0.1/src/bub_schedule/jobs.py +26 -0
- bub_schedule-0.0.1/src/bub_schedule/jobstore.py +145 -0
- bub_schedule-0.0.1/src/bub_schedule/plugin.py +33 -0
- bub_schedule-0.0.1/src/bub_schedule/py.typed +0 -0
- bub_schedule-0.0.1/src/bub_schedule/tools.py +142 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bub-schedule"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Scheduling tools for Bub"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Frost Ming", email = "me@frostming.com" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"apscheduler>=3.11.2",
|
|
11
|
+
"republic>=0.5.6",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.entry-points.bub]
|
|
15
|
+
schedule = "bub_schedule.plugin:ScheduleImpl"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.9.7,<0.10.0"]
|
|
19
|
+
build-backend = "uv_build"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=9.0.3",
|
|
24
|
+
"pytest-asyncio>=1.3.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from asyncio import Event
|
|
5
|
+
|
|
6
|
+
from apscheduler.schedulers.base import BaseScheduler
|
|
7
|
+
from bub.channels import Lifecycle
|
|
8
|
+
from bub.framework import BubFramework
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ScheduleChannel(Lifecycle):
|
|
13
|
+
name = "schedule"
|
|
14
|
+
|
|
15
|
+
# Class-level runtime state (singleton per process)
|
|
16
|
+
_framework: BubFramework | None = None
|
|
17
|
+
|
|
18
|
+
def __init__(self, scheduler: BaseScheduler, *, framework: BubFramework) -> None:
|
|
19
|
+
self.scheduler = scheduler
|
|
20
|
+
self._instance_framework = framework
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def current_framework(cls) -> BubFramework:
|
|
24
|
+
"""Return the live framework bound to the current gateway process."""
|
|
25
|
+
if cls._framework is None:
|
|
26
|
+
raise RuntimeError(
|
|
27
|
+
"no live schedule framework available, cannot deliver scheduled message"
|
|
28
|
+
)
|
|
29
|
+
return cls._framework
|
|
30
|
+
|
|
31
|
+
async def start(self, stop_event: Event) -> None:
|
|
32
|
+
ScheduleChannel._framework = self._instance_framework
|
|
33
|
+
|
|
34
|
+
loop = asyncio.get_running_loop()
|
|
35
|
+
loop.call_soon_threadsafe(self.scheduler.start)
|
|
36
|
+
logger.info("schedule.start complete")
|
|
37
|
+
|
|
38
|
+
async def stop(self) -> None:
|
|
39
|
+
loop = asyncio.get_running_loop()
|
|
40
|
+
loop.call_soon_threadsafe(self.scheduler.shutdown)
|
|
41
|
+
|
|
42
|
+
ScheduleChannel._framework = None
|
|
43
|
+
logger.info("schedule.stop complete")
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from bub.channels.message import ChannelMessage
|
|
4
|
+
|
|
5
|
+
SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS = 300
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def run_scheduled_reminder(
|
|
9
|
+
message: str, session_id: str, workspace: str | None = None
|
|
10
|
+
) -> None:
|
|
11
|
+
from bub_schedule.channel import ScheduleChannel
|
|
12
|
+
|
|
13
|
+
if ":" in session_id:
|
|
14
|
+
channel, chat_id = session_id.split(":", 1)
|
|
15
|
+
else:
|
|
16
|
+
channel = "schedule"
|
|
17
|
+
chat_id = "default"
|
|
18
|
+
|
|
19
|
+
payload = ChannelMessage(
|
|
20
|
+
content=message,
|
|
21
|
+
session_id=session_id,
|
|
22
|
+
channel=channel,
|
|
23
|
+
chat_id=chat_id,
|
|
24
|
+
)
|
|
25
|
+
framework = ScheduleChannel.current_framework()
|
|
26
|
+
await framework.process_inbound(payload)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import pickle
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from apscheduler.job import Job
|
|
10
|
+
from apscheduler.jobstores.base import BaseJobStore, ConflictingIdError, JobLookupError
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JSONJobStore(BaseJobStore):
|
|
15
|
+
"""
|
|
16
|
+
A simple JSON-based job store for APScheduler.
|
|
17
|
+
|
|
18
|
+
Jobs are serialized using pickle and stored as base64-encoded strings in a JSON file.
|
|
19
|
+
This provides persistence across restarts without requiring SQLAlchemy.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, file_path: str | Path):
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.file_path = Path(file_path)
|
|
25
|
+
self._lock = threading.RLock()
|
|
26
|
+
self._jobs: dict[str, dict[str, Any]] = self._load()
|
|
27
|
+
|
|
28
|
+
def _load(self) -> dict[str, dict[str, Any]]:
|
|
29
|
+
"""Load jobs from JSON file."""
|
|
30
|
+
if self.file_path.exists():
|
|
31
|
+
try:
|
|
32
|
+
with open(self.file_path, encoding="utf-8") as f:
|
|
33
|
+
loaded_jobs = json.load(f)
|
|
34
|
+
return loaded_jobs # type: ignore[no-any-return]
|
|
35
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
36
|
+
logger.error(f"Error loading job store: {e}")
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
def _save(self):
|
|
40
|
+
"""Save jobs to JSON file."""
|
|
41
|
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
try:
|
|
43
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(self._jobs, f, ensure_ascii=False, indent=2)
|
|
45
|
+
except OSError as e:
|
|
46
|
+
logger.error(f"Error saving job store: {e}")
|
|
47
|
+
|
|
48
|
+
def _serialize_job(self, job: Job) -> dict[str, Any]:
|
|
49
|
+
"""Serialize a job to a storable format."""
|
|
50
|
+
return {
|
|
51
|
+
"id": job.id,
|
|
52
|
+
"data": base64.b64encode(pickle.dumps(job)).decode("ascii"),
|
|
53
|
+
"next_run_time": (
|
|
54
|
+
job.next_run_time.isoformat() if job.next_run_time else None
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def _deserialize_job(self, job_data: dict[str, Any]) -> Job | None:
|
|
59
|
+
"""Deserialize a job from stored format."""
|
|
60
|
+
try:
|
|
61
|
+
job = pickle.loads(base64.b64decode(job_data["data"])) # noqa: S301
|
|
62
|
+
job._scheduler = self._scheduler
|
|
63
|
+
job._jobstore_alias = self._alias
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Error deserializing job {job_data.get('id')}: {e}")
|
|
66
|
+
return None
|
|
67
|
+
else:
|
|
68
|
+
return job
|
|
69
|
+
|
|
70
|
+
def shutdown(self):
|
|
71
|
+
"""Called when the scheduler shuts down."""
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._save()
|
|
74
|
+
|
|
75
|
+
def lookup_job(self, job_id: str) -> Job | None:
|
|
76
|
+
"""Look up a job by its ID."""
|
|
77
|
+
with self._lock:
|
|
78
|
+
job_data = self._jobs.get(job_id)
|
|
79
|
+
if job_data:
|
|
80
|
+
return self._deserialize_job(job_data)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def get_due_jobs(self, now: datetime) -> list[Job]:
|
|
84
|
+
"""Get jobs that are due to be run."""
|
|
85
|
+
with self._lock:
|
|
86
|
+
due_jobs = []
|
|
87
|
+
for job_data in self._jobs.values():
|
|
88
|
+
next_run_time_str = job_data.get("next_run_time")
|
|
89
|
+
if next_run_time_str:
|
|
90
|
+
next_run_time = datetime.fromisoformat(next_run_time_str)
|
|
91
|
+
if next_run_time <= now:
|
|
92
|
+
job = self._deserialize_job(job_data)
|
|
93
|
+
if job:
|
|
94
|
+
due_jobs.append(job)
|
|
95
|
+
return due_jobs
|
|
96
|
+
|
|
97
|
+
def get_next_run_time(self) -> datetime | None:
|
|
98
|
+
"""Get the earliest next run time among all jobs."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
next_times = []
|
|
101
|
+
for job_data in self._jobs.values():
|
|
102
|
+
next_run_time_str = job_data.get("next_run_time")
|
|
103
|
+
if next_run_time_str:
|
|
104
|
+
next_times.append(datetime.fromisoformat(next_run_time_str))
|
|
105
|
+
return min(next_times) if next_times else None
|
|
106
|
+
|
|
107
|
+
def get_all_jobs(self) -> list[Job]:
|
|
108
|
+
"""Get all jobs in the store."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
jobs = []
|
|
111
|
+
for job_data in self._jobs.values():
|
|
112
|
+
job = self._deserialize_job(job_data)
|
|
113
|
+
if job:
|
|
114
|
+
jobs.append(job)
|
|
115
|
+
return jobs
|
|
116
|
+
|
|
117
|
+
def add_job(self, job: Job):
|
|
118
|
+
"""Add a job to the store."""
|
|
119
|
+
with self._lock:
|
|
120
|
+
if job.id in self._jobs:
|
|
121
|
+
raise ConflictingIdError(job.id)
|
|
122
|
+
self._jobs[job.id] = self._serialize_job(job)
|
|
123
|
+
self._save()
|
|
124
|
+
|
|
125
|
+
def update_job(self, job: Job):
|
|
126
|
+
"""Update a job in the store."""
|
|
127
|
+
with self._lock:
|
|
128
|
+
if job.id not in self._jobs:
|
|
129
|
+
raise JobLookupError(job.id)
|
|
130
|
+
self._jobs[job.id] = self._serialize_job(job)
|
|
131
|
+
self._save()
|
|
132
|
+
|
|
133
|
+
def remove_job(self, job_id: str):
|
|
134
|
+
"""Remove a job from the store."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
if job_id not in self._jobs:
|
|
137
|
+
raise JobLookupError(job_id)
|
|
138
|
+
del self._jobs[job_id]
|
|
139
|
+
self._save()
|
|
140
|
+
|
|
141
|
+
def remove_all_jobs(self):
|
|
142
|
+
"""Remove all jobs from the store."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._jobs.clear()
|
|
145
|
+
self._save()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import bub
|
|
2
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
3
|
+
from apscheduler.schedulers.base import BaseScheduler
|
|
4
|
+
from bub import hookimpl
|
|
5
|
+
from bub.channels import Channel
|
|
6
|
+
from bub.framework import BubFramework
|
|
7
|
+
from bub.types import Envelope, MessageHandler, State
|
|
8
|
+
|
|
9
|
+
from bub_schedule.jobstore import JSONJobStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def default_scheduler() -> BaseScheduler:
|
|
13
|
+
job_file = bub.home / "jobs.json"
|
|
14
|
+
job_store = JSONJobStore(job_file)
|
|
15
|
+
return AsyncIOScheduler(jobstores={"default": job_store})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScheduleImpl:
|
|
19
|
+
def __init__(self, framework: BubFramework | None = None) -> None:
|
|
20
|
+
from bub_schedule import tools # noqa: F401
|
|
21
|
+
|
|
22
|
+
self.framework = framework
|
|
23
|
+
self.scheduler = default_scheduler()
|
|
24
|
+
|
|
25
|
+
@hookimpl
|
|
26
|
+
def load_state(self, message: Envelope, session_id: str) -> State:
|
|
27
|
+
return {"scheduler": self.scheduler}
|
|
28
|
+
|
|
29
|
+
@hookimpl
|
|
30
|
+
def provide_channels(self, message_handler: MessageHandler) -> list[Channel]:
|
|
31
|
+
from bub_schedule.channel import ScheduleChannel
|
|
32
|
+
|
|
33
|
+
return [ScheduleChannel(self.scheduler, framework=self.framework)]
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from datetime import UTC, datetime, timedelta
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError
|
|
6
|
+
from apscheduler.schedulers.base import BaseScheduler
|
|
7
|
+
from apscheduler.triggers.cron import CronTrigger
|
|
8
|
+
from apscheduler.triggers.date import DateTrigger
|
|
9
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
10
|
+
from bub import tool
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
from republic import ToolContext
|
|
13
|
+
|
|
14
|
+
from bub_schedule.jobs import run_scheduled_reminder
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_scheduler(state: dict) -> BaseScheduler:
|
|
18
|
+
if "scheduler" not in state:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
"scheduler not found in state, is ScheduleImpl plugin loaded?"
|
|
21
|
+
)
|
|
22
|
+
return cast(BaseScheduler, state["scheduler"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScheduleAddInput(BaseModel):
|
|
26
|
+
after_seconds: int | None = Field(
|
|
27
|
+
None, description="If set, schedule to run after this many seconds from now"
|
|
28
|
+
)
|
|
29
|
+
interval_seconds: int | None = Field(
|
|
30
|
+
None, description="If set, repeat at this interval"
|
|
31
|
+
)
|
|
32
|
+
cron: str | None = Field(
|
|
33
|
+
None,
|
|
34
|
+
description="If set, run with cron expression in crontab format: minute hour day month day_of_week",
|
|
35
|
+
)
|
|
36
|
+
message: str = Field(
|
|
37
|
+
...,
|
|
38
|
+
description="Reminder message to send, prefix the message with ',' to run a bash command instead",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@tool(name="schedule.add", context=True, model=ScheduleAddInput)
|
|
43
|
+
def schedule_add(params: ScheduleAddInput, context: ToolContext) -> str:
|
|
44
|
+
"""Schedule a reminder message to be sent to current session in the future."""
|
|
45
|
+
job_id = str(uuid.uuid4())[:8]
|
|
46
|
+
if params.after_seconds is not None:
|
|
47
|
+
trigger = DateTrigger(
|
|
48
|
+
run_date=datetime.now(UTC) + timedelta(seconds=params.after_seconds)
|
|
49
|
+
)
|
|
50
|
+
elif params.interval_seconds is not None:
|
|
51
|
+
trigger = IntervalTrigger(seconds=params.interval_seconds)
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
trigger = CronTrigger.from_crontab(params.cron)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise RuntimeError(f"invalid cron expression: {params.cron}") from exc
|
|
57
|
+
scheduler = _ensure_scheduler(context.state)
|
|
58
|
+
workspace = context.state.get("_runtime_workspace")
|
|
59
|
+
try:
|
|
60
|
+
job = scheduler.add_job(
|
|
61
|
+
run_scheduled_reminder,
|
|
62
|
+
trigger=trigger,
|
|
63
|
+
id=job_id,
|
|
64
|
+
kwargs={
|
|
65
|
+
"message": params.message,
|
|
66
|
+
"session_id": context.state.get("session_id", ""),
|
|
67
|
+
"workspace": str(workspace) if workspace else None,
|
|
68
|
+
},
|
|
69
|
+
coalesce=True,
|
|
70
|
+
max_instances=1,
|
|
71
|
+
)
|
|
72
|
+
except ConflictingIdError as exc:
|
|
73
|
+
raise RuntimeError(f"job id already exists: {job_id}") from exc
|
|
74
|
+
|
|
75
|
+
next_run = "-"
|
|
76
|
+
if isinstance(job.next_run_time, datetime):
|
|
77
|
+
next_run = job.next_run_time.isoformat()
|
|
78
|
+
return f"scheduled: {job.id} next={next_run}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@tool(name="schedule.remove", context=True)
|
|
82
|
+
def schedule_remove(job_id: str, context: ToolContext) -> str:
|
|
83
|
+
"""Remove one scheduled job by id."""
|
|
84
|
+
scheduler = _ensure_scheduler(context.state)
|
|
85
|
+
try:
|
|
86
|
+
scheduler.remove_job(job_id)
|
|
87
|
+
except JobLookupError as exc:
|
|
88
|
+
raise RuntimeError(f"job not found: {job_id}") from exc
|
|
89
|
+
return f"removed: {job_id}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@tool(name="schedule.list", context=True)
|
|
93
|
+
def schedule_list(context: ToolContext) -> str:
|
|
94
|
+
"""List scheduled jobs for current workspace."""
|
|
95
|
+
scheduler = _ensure_scheduler(context.state)
|
|
96
|
+
jobs = scheduler.get_jobs()
|
|
97
|
+
rows: list[str] = []
|
|
98
|
+
for job in jobs:
|
|
99
|
+
next_run = "-"
|
|
100
|
+
if isinstance(job.next_run_time, datetime):
|
|
101
|
+
next_run = job.next_run_time.isoformat()
|
|
102
|
+
message = str(job.kwargs.get("message", ""))
|
|
103
|
+
job_session = job.kwargs.get("session_id")
|
|
104
|
+
if job_session and job_session != context.state.get("session_id", ""):
|
|
105
|
+
continue
|
|
106
|
+
rows.append(f"{job.id} next={next_run} msg={message}")
|
|
107
|
+
|
|
108
|
+
if not rows:
|
|
109
|
+
return "(no scheduled jobs)"
|
|
110
|
+
|
|
111
|
+
return "\n".join(rows)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@tool(name="schedule.trigger", context=True)
|
|
115
|
+
async def schedule_trigger(job_id: str, context: ToolContext) -> str:
|
|
116
|
+
"""Manually trigger a scheduled job to run immediately.
|
|
117
|
+
|
|
118
|
+
Executes the job function directly without modifying the schedule.
|
|
119
|
+
The next scheduled run remains unchanged.
|
|
120
|
+
"""
|
|
121
|
+
import inspect
|
|
122
|
+
|
|
123
|
+
scheduler = _ensure_scheduler(context.state)
|
|
124
|
+
try:
|
|
125
|
+
job = scheduler.get_job(job_id)
|
|
126
|
+
if job is None:
|
|
127
|
+
raise RuntimeError(f"job not found: {job_id}")
|
|
128
|
+
|
|
129
|
+
# Execute job function directly, preserving original schedule
|
|
130
|
+
result = job.func(*job.args, **job.kwargs)
|
|
131
|
+
|
|
132
|
+
# Handle async job functions
|
|
133
|
+
if inspect.iscoroutine(result):
|
|
134
|
+
await result
|
|
135
|
+
|
|
136
|
+
next_run = "-"
|
|
137
|
+
if isinstance(job.next_run_time, datetime):
|
|
138
|
+
next_run = job.next_run_time.isoformat()
|
|
139
|
+
|
|
140
|
+
return f"triggered: {job_id} (next scheduled run: {next_run})"
|
|
141
|
+
except JobLookupError as exc:
|
|
142
|
+
raise RuntimeError(f"job not found: {job_id}") from exc
|