Files
EvoScientist-Multi/EvoScientist/cron/schedule.py
T
2026-07-03 11:16:33 +02:00

107 lines
3.5 KiB
Python

"""Thin wrapper over the langgraph dev built-in cron API (langgraph_sdk).
EvoScientist scheduled tasks ARE langgraph crons targeting the ``scheduler``
graph. This module is the single choke-point so the ``/schedule`` command and the
NL ``schedule_task`` tool share one implementation.
Isolation is **process-level**, not data-level: EvoScientist's manager.py restarts
langgraph dev when the active workspace changes, so each workspace gets its own
langgraph-dev process and its own ``.langgraph_api`` cron store. If you point
multiple clients at one hand-started server they will share the same cron store.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from langgraph_sdk.schema import Cron, Run
from ..langgraph_dev.sdk import (
configured_langgraph_dev_url,
default_scheduler_timezone,
get_langgraph_sync_client,
messages_input,
)
SCHEDULER_GRAPH_ID = "scheduler"
SCHEDULED_RUN_KIND = "scheduled_task"
def _scheduler_url() -> str:
return configured_langgraph_dev_url()
def _client():
return get_langgraph_sync_client(url=_scheduler_url())
def _default_timezone() -> str | None:
return default_scheduler_timezone()
def is_available() -> bool:
"""True when the langgraph dev backend (which fires crons) is reachable."""
from ..langgraph_dev.manager import is_langgraph_dev_running
return bool(is_langgraph_dev_running(base_url=_scheduler_url()))
def create_schedule(
*, name: str, schedule: str, prompt: str, timezone: str | None = None
) -> Cron:
"""Create a recurring scheduled task on the scheduler graph."""
# Crons are stored in the langgraph-dev process's .langgraph_api store, not
# tagged by workspace. Isolation is process-level (see module docstring).
return _client().crons.create(
assistant_id=SCHEDULER_GRAPH_ID,
schedule=schedule,
input=messages_input(prompt),
metadata={"run_kind": SCHEDULED_RUN_KIND, "name": name, "prompt": prompt},
timezone=timezone or _default_timezone(),
)
def list_schedules() -> list[Cron]:
"""Return only EvoScientist scheduled tasks.
Filtered server-side by ``run_kind`` metadata (the cron backend matches by
metadata containment), so we never page through unrelated crons; ``limit`` is
a ceiling on OUR schedules (far below 1000 in practice). We filter on metadata
rather than ``assistant_id`` because the stored ``assistant_id`` is a resolved
UUID, not the ``scheduler`` graph name we create with.
"""
return _client().crons.search(
metadata={"run_kind": SCHEDULED_RUN_KIND},
limit=1000,
)
def delete_schedule(cron_id: str) -> None:
"""Delete a scheduled task by cron id."""
_client().crons.delete(cron_id)
def set_enabled(cron_id: str, enabled: bool) -> Cron:
"""Enable or disable a scheduled task by cron id."""
return _client().crons.update(cron_id, enabled=enabled)
def run_now(prompt: str) -> Run:
"""Fire a one-off scheduler run immediately (for ``/schedule run``).
Output goes wherever the task's prompt specifies; there is no push notification.
"""
client = _client()
thread = client.threads.create(graph_id=SCHEDULER_GRAPH_ID)
return client.runs.create(
thread_id=str(thread["thread_id"]),
assistant_id=SCHEDULER_GRAPH_ID,
input=messages_input(prompt),
metadata={
"run_kind": SCHEDULED_RUN_KIND,
"name": "manual-run",
"prompt": prompt,
},
)