[aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones

This commit is contained in:
abccodes
2026-06-18 03:56:33 -07:00
parent 506bcb7cbf
commit 4894b608b9
6 changed files with 369 additions and 28 deletions
+18 -3
View File
@@ -15,6 +15,7 @@ import os
import uuid
import urllib.request
import urllib.error
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
BACKEND_AUTH = os.environ.get("OPENSWARM_AUTH_TOKEN", "")
@@ -23,6 +24,20 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
def _local_timezone_name() -> str:
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
if not name:
try:
from tzlocal import get_localzone_name # type: ignore
name = get_localzone_name() or ""
except Exception:
name = ""
try:
return (getattr(ZoneInfo(name), "key", None) or "UTC") if name else "UTC"
except ZoneInfoNotFoundError:
return "UTC"
PRESETS = {
"daily_morning": {"enabled": True, "repeat_unit": "day", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []},
"weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]},
@@ -68,7 +83,7 @@ TOOLS = [
"items": {"type": "integer"},
"description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.",
},
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's local zone."},
"timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's current local zone at scheduling time."},
"source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."},
},
"required": ["title", "steps", "preset"],
@@ -253,7 +268,8 @@ def _call(method: str, path: str, body=None) -> dict:
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
base = {"timezone": "local", "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
local_tz = _local_timezone_name()
base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
if preset == "custom":
return {
**base,
@@ -263,7 +279,6 @@ def _build_schedule_from_preset(preset: str, args: dict) -> dict:
"hour": int(args.get("hour", 9)),
"minute": int(args.get("minute", 0)),
"on_days": list(args.get("on_days") or []),
"timezone": args.get("timezone") or "local",
}
preset_def = PRESETS.get(preset)
if not preset_def:
+52
View File
@@ -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()
+44 -4
View File
@@ -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 "
+169
View File
@@ -189,6 +189,122 @@ def test_month_repeat_no_longer_clamps_to_28():
assert nxt.astimezone(tz).date() == datetime(2025, 4, 30).date()
def test_calendar_occurrences_use_schedule_timezone_not_viewer_timezone():
"""A 9am New York schedule returns UTC instants. The frontend can then
render those instants in the viewer's current timezone."""
from backend.apps.workflows import scheduler
from backend.apps.workflows.models import ScheduleConfig
wf = _make_wf(
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=1,
hour=9,
minute=0,
timezone="America/New_York",
)
)
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 6, 20, 0, 0, tzinfo=timezone.utc)
fires = scheduler.occurrences_between(wf, start, end)
assert len(fires) == 2
assert fires[0].astimezone(ZoneInfo("America/New_York")).hour == 9
assert fires[0].astimezone(ZoneInfo("America/Los_Angeles")).hour == 6
def test_calendar_occurrences_stay_wall_clock_across_dst():
from backend.apps.workflows import scheduler
from backend.apps.workflows.models import ScheduleConfig
wf = _make_wf(
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=1,
hour=9,
minute=0,
timezone="America/New_York",
)
)
wf.created_at = datetime(2025, 1, 1, tzinfo=timezone.utc)
fires = scheduler.occurrences_between(
wf,
datetime(2025, 3, 8, 0, 0, tzinfo=timezone.utc),
datetime(2025, 3, 11, 0, 0, tzinfo=timezone.utc),
)
ny = ZoneInfo("America/New_York")
locals_ = [f.astimezone(ny) for f in fires]
assert [d.date() for d in locals_] == [
datetime(2025, 3, 8).date(),
datetime(2025, 3, 9).date(),
datetime(2025, 3, 10).date(),
]
assert all((d.hour, d.minute) == (9, 0) for d in locals_)
assert [f.hour for f in fires] == [14, 13, 13]
def test_calendar_occurrences_honor_end_conditions():
from backend.apps.workflows import scheduler
from backend.apps.workflows.models import ScheduleConfig
start = datetime(2026, 6, 18, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 6, 22, 0, 0, tzinfo=timezone.utc)
wf = _make_wf(
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=1,
hour=9,
minute=0,
timezone="UTC",
max_runs=3,
runs_count=1,
ends_at=datetime(2026, 6, 21, 0, 0, tzinfo=timezone.utc),
)
)
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
fires = scheduler.occurrences_between(wf, start, end)
assert [f.date() for f in fires] == [
datetime(2026, 6, 18).date(),
datetime(2026, 6, 19).date(),
]
wf.schedule.enabled = False
assert scheduler.occurrences_between(wf, start, end) == []
wf.schedule.enabled = True
wf.schedule.repeat_unit = "week"
wf.schedule.on_days = []
assert scheduler.occurrences_between(wf, start, end) == []
def test_calendar_endpoint_returns_sorted_utc_events():
from backend.apps.workflows import storage
from backend.apps.workflows.workflows import list_calendar_events
from backend.apps.workflows.models import ScheduleConfig
wf = _make_wf(
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=1,
hour=9,
minute=0,
timezone="America/New_York",
)
)
wf.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
storage.save_workflow(wf)
async def runner():
return await list_calendar_events(
from_="2026-06-18T00:00:00+00:00",
to="2026-06-20T00:00:00+00:00",
)
res = asyncio.new_event_loop().run_until_complete(runner())
assert [e["workflow_id"] for e in res["events"]] == [wf.id, wf.id]
assert res["events"][0]["fire_at"].startswith("2026-06-18T13:00:00")
# --- Cost cap ----------------------------------------------------------------
def test_cost_cap_skips_with_clear_error(monkeypatch):
@@ -243,6 +359,59 @@ def test_freeze_not_forced_when_source_session_present():
assert result["actions"]["freeze"] is False
def test_create_enabled_schedule_normalizes_local_timezone(monkeypatch):
from backend.apps.workflows import scheduler
from backend.apps.workflows.workflows import create_workflow
from backend.apps.workflows.models import WorkflowCreate, ScheduleConfig
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Chicago")
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
body = WorkflowCreate(
title="local-tz-create",
schedule=ScheduleConfig(
enabled=True,
repeat_unit="day",
repeat_every=1,
hour=9,
minute=0,
timezone="local",
),
)
result = asyncio.new_event_loop().run_until_complete(create_workflow(body))
assert result["schedule"]["timezone"] == "America/Chicago"
def test_enable_schedule_normalizes_local_timezone_and_preserves_concrete_timezone(monkeypatch):
from backend.apps.workflows import storage, scheduler
from backend.apps.workflows.workflows import update_workflow
from backend.apps.workflows.models import WorkflowUpdate, ScheduleConfig
monkeypatch.setenv("OPENSWARM_TIMEZONE", "America/Denver")
monkeypatch.setattr(scheduler, "_host_tz_cache", None)
wf = _make_wf()
wf.schedule.enabled = False
wf.schedule.timezone = "local"
storage.save_workflow(wf)
async def enable_runner():
sched = ScheduleConfig(**wf.schedule.model_dump(mode="json"))
sched.enabled = True
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
enabled = asyncio.new_event_loop().run_until_complete(enable_runner())
assert enabled["schedule"]["timezone"] == "America/Denver"
stored = storage.get_workflow(wf.id)
sched = ScheduleConfig(**stored.schedule.model_dump(mode="json"))
sched.hour = 10
async def edit_runner():
return await update_workflow(wf.id, WorkflowUpdate(schedule=sched), if_match=None)
edited = asyncio.new_event_loop().run_until_complete(edit_runner())
assert edited["schedule"]["timezone"] == "America/Denver"
assert edited["schedule"]["hour"] == 10
# --- Audit log ---------------------------------------------------------------
def test_audit_log_records_title_change():
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
@@ -7,10 +7,11 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE } from '@/shared/config';
import type { Workflow } from '@/shared/state/workflowsSlice';
import { runWorkflowNow, deleteWorkflow, updateWorkflow, openWorkflowCard } from '@/shared/state/workflowsSlice';
import { addWorkflowCard } from '@/shared/state/dashboardLayoutSlice';
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, fireTimesWithin, formatTime, formatHourLabel, isScheduleActive } from './scheduleUtils';
import { WEEKDAY_FULL, WEEKDAY_LABEL_SHORT, addDays, sameDay, startOfMonthGrid, startOfWeek, formatTime, formatHourLabel } from './scheduleUtils';
interface Props {
view: 'Week' | 'Month' | 'List';
@@ -24,6 +25,11 @@ interface Props {
// starting hour. The scroll container caps the visible window.
const HOURS_24 = Array.from({ length: 24 }, (_, i) => i);
interface CalendarEvent {
workflow_id: string;
fire_at: string;
}
export default function ScheduleCalendar({ view, density, onSelectWorkflow, refDate }: Props) {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
@@ -74,32 +80,74 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
<MenuItem onClick={onDelete} sx={{ color: c.status.error }}>Delete</MenuItem>
</Menu>
);
// refDate is recreated on every render unless the caller memoizes it,
// which then trips the eventsByDay memo every paint. Pin the calendar
// to a day-precision key so the heavy fireTimesWithin loop only re-runs
// when the day or workflow set actually changed.
// refDate is recreated on every render unless the caller memoizes it.
// Pin the calendar to a day-precision key so occurrence fetches only
// change when the visible day, view, or schedule set changes.
const today = refDate || new Date();
const dayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
const compact = density === 'compact';
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
const rangeStart = useMemo(
() => view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : new Date(today.getFullYear(), today.getMonth(), today.getDate()),
// eslint-disable-next-line react-hooks/exhaustive-deps
[view, dayKey],
);
const rangeEndExclusive = useMemo(() => addDays(rangeStart, range), [rangeStart, range]);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarFetchKey, setCalendarFetchKey] = useState('');
const workflowScheduleKey = workflows
.map((w) => `${w.id}:${w.updated_at}:${w.schedule.enabled}:${w.schedule.timezone}:${w.schedule.repeat_unit}:${w.schedule.repeat_every}:${w.schedule.hour}:${w.schedule.minute}:${w.schedule.on_days.join(',')}:${w.schedule.ends_at || ''}:${w.schedule.max_runs ?? ''}:${w.schedule.runs_count}`)
.sort()
.join('|');
const fromIso = rangeStart.toISOString();
const toIso = rangeEndExclusive.toISOString();
const calendarRequestKey = `${view}:${fromIso}:${toIso}:${workflowScheduleKey}`;
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
fetch(`${API_BASE}/workflows/calendar?from=${encodeURIComponent(fromIso)}&to=${encodeURIComponent(toIso)}`, { signal: ctrl.signal })
.then((res) => {
if (!res.ok) throw new Error(`calendar failed ${res.status}`);
return res.json();
})
.then((data) => {
if (cancelled) return;
setCalendarEvents((data.events || []) as CalendarEvent[]);
setCalendarFetchKey(calendarRequestKey);
})
.catch(() => {
if (cancelled) return;
setCalendarEvents([]);
setCalendarFetchKey(calendarRequestKey);
});
return () => {
cancelled = true;
ctrl.abort();
};
}, [fromIso, toIso, calendarRequestKey]);
const eventsByDay = useMemo(() => {
const range = view === 'Month' ? 35 : view === 'Week' ? 7 : 14;
const start = view === 'Month' ? startOfMonthGrid(today) : view === 'Week' ? startOfWeek(today) : today;
const end = addDays(start, range - 1);
const map = new Map<string, { workflow: Workflow; date: Date }[]>();
for (const wf of workflows) {
if (!isScheduleActive(wf.schedule)) continue;
const fires = fireTimesWithin(wf, start, end, 60);
for (const d of fires) {
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const arr = map.get(key) || [];
arr.push({ workflow: wf, date: d });
map.set(key, arr);
}
if (calendarFetchKey !== calendarRequestKey) {
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}
return { map, start, end };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workflows, view, dayKey]);
const workflowById = new Map(workflows.map((wf) => [wf.id, wf]));
for (const event of calendarEvents) {
const wf = workflowById.get(event.workflow_id);
if (!wf) continue;
const d = new Date(event.fire_at);
if (Number.isNaN(d.getTime())) continue;
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const arr = map.get(key) || [];
arr.push({ workflow: wf, date: d });
map.set(key, arr);
}
for (const arr of map.values()) {
arr.sort((a, b) => a.date.getTime() - b.date.getTime());
}
return { map, start: rangeStart, end: rangeEndExclusive, key: calendarFetchKey };
}, [calendarEvents, calendarFetchKey, calendarRequestKey, workflows, rangeStart, rangeEndExclusive]);
const SLOT_H = compact ? 32 : 44;
const ROW_LABEL = compact ? '0.7rem' : '0.74rem';
@@ -144,6 +144,23 @@ const WorkflowsHubCard: React.FC<Props> = ({
const [view, setView] = useState<CalendarView>('List');
const [viewOpen, setViewOpen] = useState(false);
const [refDate, setRefDate] = useState(new Date());
// The hub card lives on the canvas for days at a time, so a refDate frozen
// at mount leaves the calendar stuck on the day it was opened (e.g. still
// showing "yesterday" past midnight). Roll it forward when the day flips,
// but only if the user was parked on today, so manual navigation is left be.
const refDateRef = useRef(refDate);
refDateRef.current = refDate;
useEffect(() => {
let lastToday = new Date();
const id = window.setInterval(() => {
const now = new Date();
if (sameDay(now, lastToday)) return;
if (sameDay(refDateRef.current, lastToday)) setRefDate(now);
lastToday = now;
}, 60000);
return () => window.clearInterval(id);
}, []);
const [search, setSearch] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(true);
// Right-click on a sidebar row opens this menu pinned to the cursor.