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

@@ -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}`;