mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones
This commit is contained in:
@@ -58,6 +58,11 @@ def _host_tz() -> ZoneInfo:
|
||||
return _host_tz_cache
|
||||
|
||||
|
||||
def host_timezone_name() -> str:
|
||||
"""Concrete IANA-ish zone name for schedules created on this host."""
|
||||
return getattr(_host_tz(), "key", None) or "UTC"
|
||||
|
||||
|
||||
def _resolve_tz(tz: str) -> ZoneInfo:
|
||||
if not tz or tz == "local":
|
||||
return _host_tz()
|
||||
@@ -194,6 +199,53 @@ def fires_in_window(wf: Workflow, days: int = 30) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def occurrences_between(
|
||||
wf: Workflow,
|
||||
from_utc: datetime,
|
||||
to_utc: datetime,
|
||||
cap: int = 5000,
|
||||
) -> list[datetime]:
|
||||
"""Return scheduled fire instants in [from_utc, to_utc).
|
||||
|
||||
Calendar previews must use the same timezone-aware recurrence engine as
|
||||
the scheduler. Inputs and outputs are UTC-aware datetimes; callers can
|
||||
render those absolute instants in any local timezone.
|
||||
"""
|
||||
sched = wf.schedule
|
||||
if not sched.enabled or not is_schedule_configured(sched):
|
||||
return []
|
||||
if sched.max_runs is not None and sched.runs_count >= sched.max_runs:
|
||||
return []
|
||||
start_utc = _as_utc(from_utc)
|
||||
end_utc = _as_utc(to_utc)
|
||||
if start_utc is None or end_utc is None or end_utc <= start_utc:
|
||||
return []
|
||||
|
||||
created_at = _as_utc(getattr(wf, "created_at", None))
|
||||
cursor_utc = start_utc - timedelta(microseconds=1)
|
||||
if created_at is not None and created_at > cursor_utc:
|
||||
cursor_utc = created_at
|
||||
|
||||
ends_at = _as_utc(sched.ends_at)
|
||||
if ends_at is not None:
|
||||
if ends_at <= start_utc:
|
||||
return []
|
||||
if ends_at < end_utc:
|
||||
end_utc = ends_at
|
||||
|
||||
remaining = sched.max_runs - sched.runs_count if sched.max_runs is not None else cap
|
||||
limit = max(0, min(cap, remaining))
|
||||
out: list[datetime] = []
|
||||
while len(out) < limit:
|
||||
nxt = _next_fire_after(sched, cursor_utc)
|
||||
if nxt is None or nxt >= end_utc:
|
||||
break
|
||||
if nxt >= start_utc:
|
||||
out.append(nxt.astimezone(timezone.utc))
|
||||
cursor_utc = nxt
|
||||
return out
|
||||
|
||||
|
||||
def kick() -> None:
|
||||
_wake.set()
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Header, Request
|
||||
from fastapi import HTTPException, Header, Query, Request
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.workflows.models import (
|
||||
@@ -144,11 +144,26 @@ async def list_workflows(dashboard_id: Optional[str] = None):
|
||||
|
||||
|
||||
def _normalize_schedule_state(wf: Workflow) -> None:
|
||||
if wf.schedule.timezone == "local" and wf.schedule.enabled:
|
||||
wf.schedule.timezone = scheduler.host_timezone_name()
|
||||
if wf.schedule.enabled and not scheduler.is_schedule_configured(wf.schedule):
|
||||
wf.schedule.enabled = False
|
||||
wf.next_run_at = scheduler.compute_next_fire(wf) if wf.schedule.enabled else None
|
||||
|
||||
|
||||
def _parse_calendar_bound(value: str, label: str) -> datetime:
|
||||
raw = (value or "").strip()
|
||||
if raw.endswith("Z"):
|
||||
raw = raw[:-1] + "+00:00"
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {label} timestamp")
|
||||
if dt.tzinfo is None:
|
||||
raise HTTPException(status_code=400, detail=f"{label} timestamp must include a timezone")
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@workflows.router.post("/create")
|
||||
async def create_workflow(body: WorkflowCreate):
|
||||
actions = body.actions
|
||||
@@ -507,6 +522,30 @@ async def list_all_runs(limit: int = 200):
|
||||
return {"runs": [r.model_dump(mode="json") for r in runs]}
|
||||
|
||||
|
||||
@workflows.router.get("/calendar")
|
||||
async def list_calendar_events(
|
||||
from_: str = Query(..., alias="from"),
|
||||
to: str = Query(...),
|
||||
dashboard_id: Optional[str] = None,
|
||||
):
|
||||
start_utc = _parse_calendar_bound(from_, "from")
|
||||
end_utc = _parse_calendar_bound(to, "to")
|
||||
if end_utc <= start_utc:
|
||||
raise HTTPException(status_code=400, detail="to must be after from")
|
||||
items = storage.list_workflows()
|
||||
if dashboard_id:
|
||||
items = [w for w in items if not w.dashboard_id or w.dashboard_id == dashboard_id]
|
||||
events: list[dict] = []
|
||||
for wf in items:
|
||||
for fire_at in scheduler.occurrences_between(wf, start_utc, end_utc):
|
||||
events.append({
|
||||
"workflow_id": wf.id,
|
||||
"fire_at": fire_at.astimezone(timezone.utc).isoformat(),
|
||||
})
|
||||
events.sort(key=lambda e: (e["fire_at"], e["workflow_id"]))
|
||||
return {"events": events}
|
||||
|
||||
|
||||
@workflows.router.get("/{workflow_id}")
|
||||
async def get_workflow(workflow_id: str):
|
||||
wf = storage.get_workflow(workflow_id)
|
||||
@@ -955,11 +994,12 @@ async def schedule_agent_session(workflow_id: str):
|
||||
from backend.apps.agents.core.models import AgentConfig
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
now_local = datetime.now().astimezone()
|
||||
local_tz = scheduler.host_timezone_name()
|
||||
current_dt = now_local.strftime("%A %Y-%m-%d %H:%M %Z")
|
||||
system_prompt = (
|
||||
f"You are the Scheduling Agent for the user's saved workflow \"{wf.title}\" "
|
||||
f"(id: {wf.id}). Your only job is to set when this workflow runs.\n\n"
|
||||
f"The current local date and time is {current_dt}. Resolve relative "
|
||||
f"The current local date and time is {current_dt} in {local_tz}. Resolve relative "
|
||||
"phrasing (\"this month\", \"next Wednesday\", \"this time\") against it.\n\n"
|
||||
"When the user states a cadence, interpret it yourself and call "
|
||||
"UpdateScheduledWorkflow with:\n"
|
||||
@@ -970,7 +1010,7 @@ async def schedule_agent_session(workflow_id: str):
|
||||
" - repeat_every: the interval count (1 unless they say e.g. \"every other\"; "
|
||||
"for repeat_unit=\"minute\" the minimum is 15, e.g. \"every 15 minutes\")\n"
|
||||
" - on_days: weekday indices when repeat_unit=\"week\" (Sun=0, Mon=1, ... Sat=6)\n"
|
||||
" - timezone: an IANA name only if the user names a specific zone\n\n"
|
||||
f" - timezone: \"{local_tz}\" unless the user names a different specific zone\n\n"
|
||||
"If no AM/PM is given, assume PM for 1-7 and AM for 8-12. If the cadence "
|
||||
"is genuinely ambiguous, ask ONE short clarifying question first; otherwise "
|
||||
"go straight to the tool call. The user approves or rejects the change in a "
|
||||
|
||||
Reference in New Issue
Block a user