fix(platform): add timezone awareness to scheduler (#10921)

### Changes 🏗️

This PR restores and improves timezone awareness in the scheduler
service to correctly handle daylight savings time (DST) transitions. The
changes ensure that scheduled agents run at the correct local time even
when crossing DST boundaries.

#### Backend Changes:
- **Scheduler Service (`scheduler.py`):**
- Added `user_timezone` parameter to `add_graph_execution_schedule()`
method
  - CronTrigger now uses the user's timezone instead of hardcoded UTC
  - Added timezone field to `GraphExecutionJobInfo` for visibility
  - Falls back to UTC with a warning if no timezone is provided
  - Extracts and includes timezone information from job triggers

- **API Router (`v1.py`):**
  - Added optional `timezone` field to `ScheduleCreationRequest`
- Fetches user's saved timezone from profile if not provided in request
  - Passes timezone to scheduler client when creating schedules
  - Converts `next_run_time` back to user timezone for display

#### Frontend Changes:
- **Schedule Creation Modal:**
  - Now sends user's timezone with schedule creation requests
- Uses browser's local timezone if user hasn't set one in their profile

- **Schedule Display Components:**
  - Updated to show timezone information in schedule details
  - Improved formatting of schedule information in monitoring views
  - Fixed schedule table display to properly show timezone-aware times

- **Cron Expression Utils:**
  - Removed UTC conversion logic from `formatTime()` function
  - Cron expressions are now stored in the schedule's timezone
  - Simplified humanization logic since no conversion is needed

- **API Types & OpenAPI:**
  - Added `timezone` field to schedule-related types
  - Updated OpenAPI schema to include timezone parameter

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [ ] I have tested my changes according to the test plan:
  
### Test Plan 🧪

#### 1. Schedule Creation Tests
- [ ] Create a new schedule and verify the timezone is correctly saved
- [ ] Create a schedule without specifying timezone - should use user's
profile timezone
- [ ] Create a schedule when user has no profile timezone - should
default to UTC with warning

#### 2. Daylight Savings Time Tests
- [ ] Create a schedule for a daily task at 2:00 PM in a DST timezone
(e.g., America/New_York)
- [ ] Verify the schedule runs at 2:00 PM local time before DST
transition
- [ ] Verify the schedule still runs at 2:00 PM local time after DST
transition
- [ ] Check that the next_run_time adjusts correctly across DST
boundaries

#### 3. Display and UI Tests
- [ ] Verify timezone is displayed in schedule details view
- [ ] Verify schedule times are shown in user's local timezone in
monitoring page
- [ ] Verify cron expression humanization shows correct local times
- [ ] Check that schedule table shows timezone information

#### 4. API Tests
- [ ] Test schedule creation API with timezone parameter
- [ ] Test schedule creation API without timezone parameter
- [ ] Verify GET schedules endpoint returns timezone information
- [ ] Verify next_run_time is converted to user timezone in responses

#### 5. Edge Cases
- [ ] Test with various timezones (UTC, EST, PST, Europe/London,
Asia/Tokyo)
- [ ] Test with invalid timezone strings - should handle gracefully
- [ ] Test scheduling at DST transition times (2:00 AM during spring
forward)
- [ ] Verify existing schedules without timezone info default to UTC

#### 6. Regression Tests
- [ ] Verify existing schedules continue to work
- [ ] Verify schedule deletion still works
- [ ] Verify schedule listing endpoints work correctly
- [ ] Check that scheduled graph executions trigger as expected

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Nicholas Tindle
2025-09-15 01:15:52 -05:00
parent fb8fbc9d1f
commit f262bb9307
10 changed files with 108 additions and 116 deletions

View File

@@ -191,15 +191,22 @@ class GraphExecutionJobInfo(GraphExecutionJobArgs):
id: str
name: str
next_run_time: str
timezone: str = Field(default="UTC", description="Timezone used for scheduling")
@staticmethod
def from_db(
job_args: GraphExecutionJobArgs, job_obj: JobObj
) -> "GraphExecutionJobInfo":
# Extract timezone from the trigger if it's a CronTrigger
timezone_str = "UTC"
if hasattr(job_obj.trigger, "timezone"):
timezone_str = str(job_obj.trigger.timezone)
return GraphExecutionJobInfo(
id=job_obj.id,
name=job_obj.name,
next_run_time=job_obj.next_run_time.isoformat(),
timezone=timezone_str,
**job_args.model_dump(),
)
@@ -395,6 +402,7 @@ class Scheduler(AppService):
input_data: BlockInput,
input_credentials: dict[str, CredentialsMetaInput],
name: Optional[str] = None,
user_timezone: str | None = None,
) -> GraphExecutionJobInfo:
# Validate the graph before scheduling to prevent runtime failures
# We don't need the return value, just want the validation to run
@@ -408,7 +416,18 @@ class Scheduler(AppService):
)
)
logger.info(f"Scheduling job for user {user_id} in UTC (cron: {cron})")
# Use provided timezone or default to UTC
# Note: Timezone should be passed from the client to avoid database lookups
if not user_timezone:
user_timezone = "UTC"
logger.warning(
f"No timezone provided for user {user_id}, using UTC for scheduling. "
f"Client should pass user's timezone for correct scheduling."
)
logger.info(
f"Scheduling job for user {user_id} with timezone {user_timezone} (cron: {cron})"
)
job_args = GraphExecutionJobArgs(
user_id=user_id,
@@ -422,12 +441,12 @@ class Scheduler(AppService):
execute_graph,
kwargs=job_args.model_dump(),
name=name,
trigger=CronTrigger.from_crontab(cron, timezone="UTC"),
trigger=CronTrigger.from_crontab(cron, timezone=user_timezone),
jobstore=Jobstores.EXECUTION.value,
replace_existing=True,
)
logger.info(
f"Added job {job.id} with cron schedule '{cron}' in UTC, input data: {input_data}"
f"Added job {job.id} with cron schedule '{cron}' in timezone {user_timezone}, input data: {input_data}"
)
return GraphExecutionJobInfo.from_db(job_args, job)

View File

@@ -90,7 +90,6 @@ from backend.util.cloud_storage import get_cloud_storage_handler
from backend.util.exceptions import GraphValidationError, NotFoundError
from backend.util.settings import Settings
from backend.util.timezone_utils import (
convert_cron_to_utc,
convert_utc_time_to_user_timezone,
get_user_timezone_or_utc,
)
@@ -945,6 +944,10 @@ class ScheduleCreationRequest(pydantic.BaseModel):
cron: str
inputs: dict[str, Any]
credentials: dict[str, CredentialsMetaInput] = pydantic.Field(default_factory=dict)
timezone: Optional[str] = pydantic.Field(
default=None,
description="User's timezone for scheduling (e.g., 'America/New_York'). If not provided, will use user's saved timezone or UTC.",
)
@v1_router.post(
@@ -969,26 +972,22 @@ async def create_graph_execution_schedule(
detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.",
)
user = await get_user_by_id(user_id)
user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
# Convert cron expression from user timezone to UTC
try:
utc_cron = convert_cron_to_utc(schedule_params.cron, user_timezone)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f"Invalid cron expression for timezone {user_timezone}: {e}",
)
# Use timezone from request if provided, otherwise fetch from user profile
if schedule_params.timezone:
user_timezone = schedule_params.timezone
else:
user = await get_user_by_id(user_id)
user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
result = await get_scheduler_client().add_execution_schedule(
user_id=user_id,
graph_id=graph_id,
graph_version=graph.version,
name=schedule_params.name,
cron=utc_cron, # Send UTC cron to scheduler
cron=schedule_params.cron,
input_data=schedule_params.inputs,
input_credentials=schedule_params.credentials,
user_timezone=user_timezone,
)
# Convert the next_run_time back to user timezone for display
@@ -1010,24 +1009,11 @@ async def list_graph_execution_schedules(
user_id: Annotated[str, Security(get_user_id)],
graph_id: str = Path(),
) -> list[scheduler.GraphExecutionJobInfo]:
schedules = await get_scheduler_client().get_execution_schedules(
return await get_scheduler_client().get_execution_schedules(
user_id=user_id,
graph_id=graph_id,
)
# Get user timezone for conversion
user = await get_user_by_id(user_id)
user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
# Convert next_run_time to user timezone for display
for schedule in schedules:
if schedule.next_run_time:
schedule.next_run_time = convert_utc_time_to_user_timezone(
schedule.next_run_time, user_timezone
)
return schedules
@v1_router.get(
path="/schedules",
@@ -1038,20 +1024,7 @@ async def list_graph_execution_schedules(
async def list_all_graphs_execution_schedules(
user_id: Annotated[str, Security(get_user_id)],
) -> list[scheduler.GraphExecutionJobInfo]:
schedules = await get_scheduler_client().get_execution_schedules(user_id=user_id)
# Get user timezone for conversion
user = await get_user_by_id(user_id)
user_timezone = get_user_timezone_or_utc(user.timezone if user else None)
# Convert UTC next_run_time to user timezone for display
for schedule in schedules:
if schedule.next_run_time:
schedule.next_run_time = convert_utc_time_to_user_timezone(
schedule.next_run_time, user_timezone
)
return schedules
return await get_scheduler_client().get_execution_schedules(user_id=user_id)
@v1_router.delete(

View File

@@ -15,6 +15,7 @@ import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
export type RunVariant =
| "manual"
@@ -48,6 +49,13 @@ export function useAgentRunModal(
agent.recommended_schedule_cron || "0 9 * * 1",
);
// Get user timezone for scheduling
const { data: userTimezone } = useGetV1GetUserTimezone({
query: {
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
},
});
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.has_external_trigger
? "automatic-trigger"
@@ -307,6 +315,8 @@ export function useAgentRunModal(
inputs: inputValues,
graph_version: agent.graph_version,
credentials: inputCredentials,
timezone:
userTimezone && userTimezone !== "not-set" ? userTimezone : undefined,
},
});
}, [
@@ -319,6 +329,7 @@ export function useAgentRunModal(
notifyMissingRequirements,
createScheduleMutation,
toast,
userTimezone,
]);
function handleShowSchedule() {

View File

@@ -98,7 +98,7 @@ export function ScheduleDetails({
run={undefined}
scheduleRecurrence={
schedule
? `${humanizeCronExpression(schedule.cron || "", userTzRes)} · ${getTimezoneDisplayName(userTzRes || "UTC")}`
? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}`
: undefined
}
/>
@@ -161,10 +161,12 @@ export function ScheduleDetails({
Recurrence
</Text>
<p className="text-sm text-zinc-600">
{humanizeCronExpression(schedule.cron, userTzRes)}
{humanizeCronExpression(schedule.cron)}
{" • "}
<span className="text-xs text-zinc-600">
{getTimezoneDisplayName(userTzRes || "UTC")}
{getTimezoneDisplayName(
schedule.timezone || userTzRes || "UTC",
)}
</span>
</p>
</div>
@@ -187,7 +189,9 @@ export function ScheduleDetails({
)}{" "}
{" "}
<span className="text-xs text-zinc-600">
{getTimezoneDisplayName(userTzRes || "UTC")}
{getTimezoneDisplayName(
schedule.timezone || userTzRes || "UTC",
)}
</span>
</p>
</div>

View File

@@ -59,7 +59,7 @@ export function AgentScheduleDetailsView({
},
{
label: "Schedule",
value: humanizeCronExpression(schedule.cron, userTimezone),
value: humanizeCronExpression(schedule.cron),
},
{
label: "Next run",

View File

@@ -1,12 +1,12 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import {
GraphExecutionMeta,
Schedule,
LibraryAgent,
ScheduleID,
} from "@/lib/autogpt-server-api";
useGetV1ListExecutionSchedulesForAUser,
useDeleteV1DeleteExecutionSchedule,
} from "@/app/api/__generated__/endpoints/schedules/schedules";
import { Card } from "@/components/ui/card";
import {
@@ -22,26 +22,29 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context";
const Monitor = () => {
const [flows, setFlows] = useState<LibraryAgent[]>([]);
const [executions, setExecutions] = useState<GraphExecutionMeta[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [selectedFlow, setSelectedFlow] = useState<LibraryAgent | null>(null);
const [selectedRun, setSelectedRun] = useState<GraphExecutionMeta | null>(
null,
);
const [sortColumn, setSortColumn] = useState<keyof Schedule>("id");
const [sortColumn, setSortColumn] =
useState<keyof GraphExecutionJobInfo>("id");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const api = useBackendAPI();
const fetchSchedules = useCallback(async () => {
setSchedules(await api.listAllGraphsExecutionSchedules());
}, [api]);
// Use generated API hooks for schedules
const { data: schedulesResponse, refetch: refetchSchedules } =
useGetV1ListExecutionSchedulesForAUser();
const deleteScheduleMutation = useDeleteV1DeleteExecutionSchedule();
const schedules =
schedulesResponse?.status === 200 ? schedulesResponse.data : [];
const removeSchedule = useCallback(
async (scheduleId: ScheduleID) => {
const removedSchedule =
await api.deleteGraphExecutionSchedule(scheduleId);
setSchedules(schedules.filter((s) => s.id !== removedSchedule.id));
async (scheduleId: string) => {
await deleteScheduleMutation.mutateAsync({ scheduleId });
refetchSchedules();
},
[schedules, api],
[deleteScheduleMutation, refetchSchedules],
);
const fetchAgents = useCallback(() => {
@@ -57,10 +60,6 @@ const Monitor = () => {
fetchAgents();
}, [fetchAgents]);
useEffect(() => {
fetchSchedules();
}, [fetchSchedules]);
useEffect(() => {
const intervalId = setInterval(() => fetchAgents(), 5000);
return () => clearInterval(intervalId);
@@ -70,7 +69,7 @@ const Monitor = () => {
const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3";
const column3 = "col-span-full xl:col-span-4 xxl:col-span-5";
const handleSort = (column: keyof Schedule) => {
const handleSort = (column: keyof GraphExecutionJobInfo) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {

View File

@@ -5369,7 +5369,13 @@
},
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"next_run_time": { "type": "string", "title": "Next Run Time" }
"next_run_time": { "type": "string", "title": "Next Run Time" },
"timezone": {
"type": "string",
"title": "Timezone",
"description": "Timezone used for scheduling",
"default": "UTC"
}
},
"type": "object",
"required": [
@@ -7051,6 +7057,11 @@
},
"type": "object",
"title": "Credentials"
},
"timezone": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Timezone",
"description": "User's timezone for scheduling (e.g., 'America/New_York'). If not provided, will use user's saved timezone or UTC."
}
},
"type": "object",

View File

@@ -1,4 +1,5 @@
import { LibraryAgent, Schedule, ScheduleID } from "@/lib/autogpt-server-api";
import { LibraryAgent } from "@/lib/autogpt-server-api";
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@@ -39,12 +40,12 @@ import { Input } from "../ui/input";
import { Label } from "../ui/label";
interface SchedulesTableProps {
schedules: Schedule[];
schedules: GraphExecutionJobInfo[];
agents: LibraryAgent[];
onRemoveSchedule: (scheduleId: ScheduleID, enabled: boolean) => void;
sortColumn: keyof Schedule;
onRemoveSchedule: (scheduleId: string, enabled: boolean) => void;
sortColumn: keyof GraphExecutionJobInfo;
sortDirection: "asc" | "desc";
onSort: (column: keyof Schedule) => void;
onSort: (column: keyof GraphExecutionJobInfo) => void;
}
export const SchedulesTable = ({
@@ -84,7 +85,7 @@ export const SchedulesTable = ({
return String(bValue).localeCompare(String(aValue));
});
const handleToggleSchedule = (scheduleId: ScheduleID, enabled: boolean) => {
const handleToggleSchedule = (scheduleId: string, enabled: boolean) => {
onRemoveSchedule(scheduleId, enabled);
if (!enabled) {
toast({
@@ -257,12 +258,14 @@ export const SchedulesTable = ({
</TableCell>
<TableCell>
<Badge variant="secondary">
{humanizeCronExpression(schedule.cron, userTimezone)}
{humanizeCronExpression(schedule.cron)}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">
{userTimezone && getTimezoneAbbreviation(userTimezone)}
{schedule.timezone
? getTimezoneAbbreviation(schedule.timezone)
: userTimezone && getTimezoneAbbreviation(userTimezone)}
</span>
</TableCell>
<TableCell>

View File

@@ -797,6 +797,7 @@ export type Schedule = {
input_data: Record<string, any>;
input_credentials: Record<string, CredentialsMetaInput>;
next_run_time: Date;
timezone: string;
};
export type ScheduleID = Brand<string, "ScheduleID">;

View File

@@ -79,10 +79,7 @@ export function makeCronExpression(params: CronExpressionParams): string {
return "";
}
export function humanizeCronExpression(
cronExpression: string,
userTimezone?: string,
): string {
export function humanizeCronExpression(cronExpression: string): string {
const parts = cronExpression.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error("Invalid cron expression format.");
@@ -138,7 +135,7 @@ export function humanizeCronExpression(
!minute.includes("/") &&
!hour.includes("/")
) {
return `Every day at ${formatTime(hour, minute, userTimezone)}`;
return `Every day at ${formatTime(hour, minute)}`;
}
// Handle weekly (e.g., 30 14 * * 1,3,5)
@@ -150,7 +147,7 @@ export function humanizeCronExpression(
!hour.includes("/")
) {
const days = getDayNames(dayOfWeek);
return `Every ${days} at ${formatTime(hour, minute, userTimezone)}`;
return `Every ${days} at ${formatTime(hour, minute)}`;
}
// Handle monthly (e.g., 30 14 1,15 * *)
@@ -163,7 +160,7 @@ export function humanizeCronExpression(
) {
const days = dayOfMonth.split(",").map(Number);
const dayList = days.join(", ");
return `On day ${dayList} of every month at ${formatTime(hour, minute, userTimezone)}`;
return `On day ${dayList} of every month at ${formatTime(hour, minute)}`;
}
// Handle yearly (e.g., 30 14 1 1,6,12 *)
@@ -175,7 +172,7 @@ export function humanizeCronExpression(
!hour.includes("/")
) {
const months = getMonthNames(month);
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute, userTimezone)}`;
return `Every year on the 1st day of ${months} at ${formatTime(hour, minute)}`;
}
// Handle custom minute intervals with other fields as * (e.g., every N minutes)
@@ -211,41 +208,15 @@ export function humanizeCronExpression(
!hour.includes("/")
) {
const interval = dayOfMonth.substring(2);
return `Every ${interval} days at ${formatTime(hour, minute, userTimezone)}`;
return `Every ${interval} days at ${formatTime(hour, minute)}`;
}
return `Cron Expression: ${cronExpression}`;
}
function formatTime(
hour: string,
minute: string,
userTimezone?: string,
): string {
// Convert from UTC cron time to user timezone for display consistency with next_run_time
if (userTimezone && userTimezone !== "UTC" && userTimezone !== "not-set") {
try {
// Create a date in UTC with the cron hour/minute (cron expressions are stored in UTC)
const utcDate = new Date();
utcDate.setUTCHours(parseInt(hour), parseInt(minute), 0, 0);
// Format in user's timezone to match next_run_time display
const formatter = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: userTimezone,
});
return formatter.format(utcDate);
} catch {
// Fallback to original formatting if timezone conversion fails
const formattedHour = padZero(hour);
const formattedMinute = padZero(minute);
return `${formattedHour}:${formattedMinute}`;
}
}
function formatTime(hour: string, minute: string): string {
// Cron expressions are now stored in the schedule's timezone (not UTC)
// So we just format the time as-is without conversion
const formattedHour = padZero(hour);
const formattedMinute = padZero(minute);
return `${formattedHour}:${formattedMinute}`;