mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-02 07:23:35 +00:00
feat: add time-based agent scheduling with APScheduler
Add comprehensive scheduling system that allows agents to automatically
start and stop during configured time windows, helping users manage
Claude API token limits by running agents during off-hours.
Backend Changes:
- Add Schedule and ScheduleOverride database models for persistent storage
- Implement APScheduler-based SchedulerService with UTC timezone support
- Add schedule CRUD API endpoints (/api/projects/{name}/schedules)
- Add manual override tracking to prevent unwanted auto-start/stop
- Integrate scheduler lifecycle with FastAPI startup/shutdown
- Fix timezone bug: explicitly set timezone=timezone.utc on CronTrigger
to ensure correct UTC scheduling (critical fix)
Frontend Changes:
- Add ScheduleModal component for creating and managing schedules
- Add clock button and schedule status display to AgentControl
- Add timezone utilities for converting between UTC and local time
- Add React Query hooks for schedule data fetching
- Fix 204 No Content handling in fetchJSON for delete operations
- Invalidate nextRun cache when manually stopping agent during window
- Add TypeScript type annotations to Terminal component callbacks
Features:
- Multiple overlapping schedules per project supported
- Auto-start at scheduled time via APScheduler cron jobs
- Auto-stop after configured duration
- Manual start/stop creates persistent overrides in database
- Crash recovery with exponential backoff (max 3 retries)
- Server restart preserves schedules and active overrides
- Times displayed in user's local timezone, stored as UTC
- Immediate start if schedule created during active window
Dependencies:
- Add APScheduler for reliable cron-like scheduling
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,11 @@ import type {
|
||||
DevServerStatusResponse,
|
||||
DevServerConfig,
|
||||
TerminalInfo,
|
||||
Schedule,
|
||||
ScheduleCreate,
|
||||
ScheduleUpdate,
|
||||
ScheduleListResponse,
|
||||
NextRunResponse,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
@@ -44,6 +49,11 @@ async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
@@ -441,3 +451,52 @@ export async function deleteTerminal(
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schedule API
|
||||
// ============================================================================
|
||||
|
||||
export async function listSchedules(projectName: string): Promise<ScheduleListResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`)
|
||||
}
|
||||
|
||||
export async function createSchedule(
|
||||
projectName: string,
|
||||
schedule: ScheduleCreate
|
||||
): Promise<Schedule> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(schedule),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSchedule(
|
||||
projectName: string,
|
||||
scheduleId: number
|
||||
): Promise<Schedule> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`)
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
projectName: string,
|
||||
scheduleId: number,
|
||||
update: ScheduleUpdate
|
||||
): Promise<Schedule> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(update),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
projectName: string,
|
||||
scheduleId: number
|
||||
): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNextScheduledRun(projectName: string): Promise<NextRunResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/next`)
|
||||
}
|
||||
|
||||
155
ui/src/lib/timeUtils.ts
Normal file
155
ui/src/lib/timeUtils.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Time Zone Utilities
|
||||
* ====================
|
||||
*
|
||||
* Utilities for converting between UTC and local time for schedule management.
|
||||
* All times in the database are stored in UTC and displayed in the user's local timezone.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert "HH:MM" UTC time to user's local time.
|
||||
* @param utcTime Time string in "HH:MM" format (UTC)
|
||||
* @returns Time string in "HH:MM" format (local)
|
||||
*/
|
||||
export function utcToLocal(utcTime: string): string {
|
||||
const [hours, minutes] = utcTime.split(':').map(Number)
|
||||
const utcDate = new Date()
|
||||
utcDate.setUTCHours(hours, minutes, 0, 0)
|
||||
|
||||
const localHours = utcDate.getHours()
|
||||
const localMinutes = utcDate.getMinutes()
|
||||
|
||||
return `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert "HH:MM" local time to UTC for storage.
|
||||
* @param localTime Time string in "HH:MM" format (local)
|
||||
* @returns Time string in "HH:MM" format (UTC)
|
||||
*/
|
||||
export function localToUTC(localTime: string): string {
|
||||
const [hours, minutes] = localTime.split(':').map(Number)
|
||||
const localDate = new Date()
|
||||
localDate.setHours(hours, minutes, 0, 0)
|
||||
|
||||
const utcHours = localDate.getUTCHours()
|
||||
const utcMinutes = localDate.getUTCMinutes()
|
||||
|
||||
return `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in minutes to a human-readable string.
|
||||
* @param minutes Duration in minutes
|
||||
* @returns Formatted string (e.g., "4h", "1h 30m", "30m")
|
||||
*/
|
||||
export function formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
|
||||
if (hours === 0) return `${mins}m`
|
||||
if (mins === 0) return `${hours}h`
|
||||
return `${hours}h ${mins}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string to a human-readable next run format.
|
||||
* Uses the browser's locale settings for 12/24-hour format.
|
||||
* @param isoString ISO datetime string in UTC
|
||||
* @returns Formatted string (e.g., "22:00", "10:00 PM", "Mon 22:00")
|
||||
*/
|
||||
export function formatNextRun(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
|
||||
if (diffHours < 24) {
|
||||
// Same day or within 24 hours - just show time
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Further out - show day and time
|
||||
return date.toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime string to show the end time.
|
||||
* Uses the browser's locale settings for 12/24-hour format.
|
||||
* @param isoString ISO datetime string in UTC
|
||||
* @returns Formatted string (e.g., "14:00", "2:00 PM")
|
||||
*/
|
||||
export function formatEndTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Day bit values for the days_of_week bitfield.
|
||||
*/
|
||||
export const DAY_BITS = {
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 4,
|
||||
Thu: 8,
|
||||
Fri: 16,
|
||||
Sat: 32,
|
||||
Sun: 64,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Array of days with their labels and bit values.
|
||||
*/
|
||||
export const DAYS = [
|
||||
{ label: 'Mon', bit: 1 },
|
||||
{ label: 'Tue', bit: 2 },
|
||||
{ label: 'Wed', bit: 4 },
|
||||
{ label: 'Thu', bit: 8 },
|
||||
{ label: 'Fri', bit: 16 },
|
||||
{ label: 'Sat', bit: 32 },
|
||||
{ label: 'Sun', bit: 64 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Check if a day is active in a bitfield.
|
||||
* @param bitfield The days_of_week bitfield
|
||||
* @param dayBit The bit value for the day to check
|
||||
* @returns True if the day is active
|
||||
*/
|
||||
export function isDayActive(bitfield: number, dayBit: number): boolean {
|
||||
return (bitfield & dayBit) !== 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a day in a bitfield.
|
||||
* @param bitfield The current days_of_week bitfield
|
||||
* @param dayBit The bit value for the day to toggle
|
||||
* @returns New bitfield with the day toggled
|
||||
*/
|
||||
export function toggleDay(bitfield: number, dayBit: number): number {
|
||||
return bitfield ^ dayBit
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of active days.
|
||||
* @param bitfield The days_of_week bitfield
|
||||
* @returns Description string (e.g., "Every day", "Weekdays", "Mon, Wed, Fri")
|
||||
*/
|
||||
export function formatDaysDescription(bitfield: number): string {
|
||||
if (bitfield === 127) return 'Every day'
|
||||
if (bitfield === 31) return 'Weekdays'
|
||||
if (bitfield === 96) return 'Weekends'
|
||||
|
||||
const activeDays = DAYS.filter(d => isDayActive(bitfield, d.bit))
|
||||
return activeDays.map(d => d.label).join(', ')
|
||||
}
|
||||
@@ -489,3 +489,50 @@ export interface SettingsUpdate {
|
||||
testing_agent_ratio?: number
|
||||
count_testing_in_concurrency?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schedule Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Schedule {
|
||||
id: number
|
||||
project_name: string
|
||||
start_time: string // "HH:MM" in UTC
|
||||
duration_minutes: number
|
||||
days_of_week: number // Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64
|
||||
enabled: boolean
|
||||
yolo_mode: boolean
|
||||
model: string | null
|
||||
crash_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ScheduleCreate {
|
||||
start_time: string // "HH:MM" format (local time, will be stored as UTC)
|
||||
duration_minutes: number
|
||||
days_of_week: number
|
||||
enabled: boolean
|
||||
yolo_mode: boolean
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export interface ScheduleUpdate {
|
||||
start_time?: string
|
||||
duration_minutes?: number
|
||||
days_of_week?: number
|
||||
enabled?: boolean
|
||||
yolo_mode?: boolean
|
||||
model?: string | null
|
||||
}
|
||||
|
||||
export interface ScheduleListResponse {
|
||||
schedules: Schedule[]
|
||||
}
|
||||
|
||||
export interface NextRunResponse {
|
||||
has_schedules: boolean
|
||||
next_start: string | null // ISO datetime in UTC
|
||||
next_end: string | null // ISO datetime in UTC (latest end if overlapping)
|
||||
is_currently_running: boolean
|
||||
active_schedule_count: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user