diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index 578b362e..b8acd7c9 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -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: diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 7fa620a6..28dd949f 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -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() diff --git a/backend/apps/workflows/workflows.py b/backend/apps/workflows/workflows.py index 543d5579..d4210a5a 100644 --- a/backend/apps/workflows/workflows.py +++ b/backend/apps/workflows/workflows.py @@ -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 " diff --git a/backend/tests/test_workflows_semantics.py b/backend/tests/test_workflows_semantics.py index 491ea7b4..a4b53d3e 100644 --- a/backend/tests/test_workflows_semantics.py +++ b/backend/tests/test_workflows_semantics.py @@ -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(): diff --git a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx index 0ac97031..38851d86 100644 --- a/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleCalendar.tsx @@ -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 Delete ); - // 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([]); + 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(); - 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'; diff --git a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx index 3bbe53ac..dc603908 100644 --- a/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowsHubCard.tsx @@ -144,6 +144,23 @@ const WorkflowsHubCard: React.FC = ({ const [view, setView] = useState('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.