From 73de1cd26d4dba2ed516df104ac30bac899ce795 Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 26 Apr 2026 10:45:48 +0200 Subject: [PATCH] feat: implement agent scheduled execution with cron and time picker - Add scheduledTime, scheduledDay, timezone fields to Agent schema - Create calculateNextRun() helper with timezone-aware scheduling - Add POST /api/cron/agents endpoint for external scheduler - Calculate nextRun on agent create, update, and after execution - Add time/day picker in agent form (daily/weekly/monthly) - Show "Next run" countdown in agent card - Add i18n keys for schedule UI (FR + EN) External scheduler (N8N, Vercel Cron) should call /api/cron/agents every 5-15 min. Requires `prisma db push` to apply schema changes. Co-Authored-By: Claude Opus 4.7 --- .../app/(main)/agents/agents-page-client.tsx | 3 + memento-note/app/actions/agent-actions.ts | 52 ++++++ memento-note/app/api/cron/agents/route.ts | 102 +++++++++++ memento-note/components/agents/agent-card.tsx | 11 ++ memento-note/components/agents/agent-form.tsx | 64 +++++++ memento-note/lib/agents/schedule.ts | 162 ++++++++++++++++++ .../lib/ai/services/agent-executor.service.ts | 13 +- memento-note/locales/en.json | 15 ++ memento-note/locales/fr.json | 15 ++ memento-note/prisma/schema.prisma | 3 + 10 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 memento-note/app/api/cron/agents/route.ts create mode 100644 memento-note/lib/agents/schedule.ts diff --git a/memento-note/app/(main)/agents/agents-page-client.tsx b/memento-note/app/(main)/agents/agents-page-client.tsx index 1f7fbbc..cd4c20a 100644 --- a/memento-note/app/(main)/agents/agents-page-client.tsx +++ b/memento-note/app/(main)/agents/agents-page-client.tsx @@ -120,6 +120,9 @@ export function AgentsPageClient({ maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined, notifyEmail: formData.get('notifyEmail') === 'true', includeImages: formData.get('includeImages') === 'true', + scheduledTime: (formData.get('scheduledTime') as string) || undefined, + scheduledDay: formData.get('scheduledDay') ? Number(formData.get('scheduledDay')) : undefined, + timezone: (formData.get('timezone') as string) || undefined, } if (editingAgent) { diff --git a/memento-note/app/actions/agent-actions.ts b/memento-note/app/actions/agent-actions.ts index 5a23306..306b561 100644 --- a/memento-note/app/actions/agent-actions.ts +++ b/memento-note/app/actions/agent-actions.ts @@ -8,6 +8,7 @@ import { auth } from '@/auth' import { prisma } from '@/lib/prisma' import { revalidatePath } from 'next/cache' +import { calculateNextRun } from '@/lib/agents/schedule' // --- CRUD --- @@ -24,6 +25,9 @@ export async function createAgent(data: { maxSteps?: number notifyEmail?: boolean includeImages?: boolean + scheduledTime?: string + scheduledDay?: number + timezone?: string }) { const session = await auth() if (!session?.user?.id) { @@ -45,10 +49,30 @@ export async function createAgent(data: { maxSteps: data.maxSteps || 10, notifyEmail: data.notifyEmail || false, includeImages: data.includeImages || false, + scheduledTime: data.scheduledTime || '08:00', + scheduledDay: data.scheduledDay ?? null, + timezone: data.timezone || null, userId: session.user.id, } }) + // Calculate nextRun for scheduled agents + const freq = data.frequency || 'manual' + if (freq !== 'manual') { + const nextRun = calculateNextRun({ + frequency: freq, + scheduledTime: data.scheduledTime || '08:00', + scheduledDay: data.scheduledDay, + timezone: data.timezone, + }) + if (nextRun) { + await prisma.agent.update({ + where: { id: agent.id }, + data: { nextRun }, + }) + } + } + revalidatePath('/agents') return { success: true, agent } } catch (error) { @@ -71,6 +95,9 @@ export async function updateAgent(id: string, data: { maxSteps?: number notifyEmail?: boolean includeImages?: boolean + scheduledTime?: string + scheduledDay?: number | null + timezone?: string }) { const session = await auth() if (!session?.user?.id) { @@ -97,6 +124,31 @@ export async function updateAgent(id: string, data: { if (data.maxSteps !== undefined) updateData.maxSteps = data.maxSteps if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail if (data.includeImages !== undefined) updateData.includeImages = data.includeImages + if (data.scheduledTime !== undefined) updateData.scheduledTime = data.scheduledTime + if (data.scheduledDay !== undefined) updateData.scheduledDay = data.scheduledDay + if (data.timezone !== undefined) updateData.timezone = data.timezone + + // Recalculate nextRun when scheduling fields change + const shouldRecalcNextRun = + data.frequency !== undefined || + data.scheduledTime !== undefined || + data.scheduledDay !== undefined || + data.timezone !== undefined + + if (shouldRecalcNextRun) { + const freq = data.frequency || existing.frequency + if (freq === 'manual') { + updateData.nextRun = null + } else { + const nextRun = calculateNextRun({ + frequency: freq, + scheduledTime: data.scheduledTime || existing.scheduledTime || '08:00', + scheduledDay: data.scheduledDay ?? existing.scheduledDay, + timezone: data.timezone || existing.timezone, + }) + updateData.nextRun = nextRun + } + } const agent = await prisma.agent.update({ where: { id }, diff --git a/memento-note/app/api/cron/agents/route.ts b/memento-note/app/api/cron/agents/route.ts new file mode 100644 index 0000000..914c1a7 --- /dev/null +++ b/memento-note/app/api/cron/agents/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { calculateNextRun } from '@/lib/agents/schedule' + +export const dynamic = 'force-dynamic' + +/** + * POST /api/cron/agents + * + * Finds all enabled, non-manual agents whose nextRun <= now, + * executes them, and schedules the next run. + * + * Optional auth: set CRON_SECRET env var, callers must pass + * Authorization: Bearer + */ +export async function POST(request: NextRequest) { + // Optional auth + const cronSecret = process.env.CRON_SECRET + if (cronSecret) { + const authHeader = request.headers.get('authorization') + if (authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + } + + try { + const now = new Date() + + const dueAgents = await prisma.agent.findMany({ + where: { + isEnabled: true, + frequency: { not: 'manual' }, + nextRun: { lte: now }, + }, + select: { + id: true, + userId: true, + frequency: true, + scheduledTime: true, + scheduledDay: true, + timezone: true, + }, + }) + + if (dueAgents.length === 0) { + return NextResponse.json({ success: true, executed: 0 }) + } + + const results: { id: string; success: boolean; error?: string }[] = [] + + // Execute agents sequentially to avoid overwhelming the AI provider + for (const agent of dueAgents) { + try { + const { executeAgent } = await import('@/lib/ai/services/agent-executor.service') + const result = await executeAgent(agent.id, agent.userId) + + // Calculate and set next run + const nextRun = calculateNextRun({ + frequency: agent.frequency, + scheduledTime: agent.scheduledTime, + scheduledDay: agent.scheduledDay, + timezone: agent.timezone, + }) + + await prisma.agent.update({ + where: { id: agent.id }, + data: { nextRun }, + }) + + results.push({ id: agent.id, success: result.success, error: result.error }) + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error' + console.error(`[CronAgents] Agent ${agent.id} failed:`, msg) + results.push({ id: agent.id, success: false, error: msg }) + + // Still schedule next run even on failure + const nextRun = calculateNextRun({ + frequency: agent.frequency, + scheduledTime: agent.scheduledTime, + scheduledDay: agent.scheduledDay, + timezone: agent.timezone, + }) + await prisma.agent.update({ + where: { id: agent.id }, + data: { nextRun }, + }) + } + } + + return NextResponse.json({ + success: true, + executed: results.length, + results, + }) + } catch (error) { + console.error('[CronAgents] Error:', error) + return NextResponse.json( + { success: false, error: 'Internal Server Error' }, + { status: 500 } + ) + } +} diff --git a/memento-note/components/agents/agent-card.tsx b/memento-note/components/agents/agent-card.tsx index 6a88c57..84a98c1 100644 --- a/memento-note/components/agents/agent-card.tsx +++ b/memento-note/components/agents/agent-card.tsx @@ -38,6 +38,7 @@ interface AgentCardProps { isEnabled: boolean frequency: string lastRun: string | Date | null + nextRun?: string | Date | null createdAt: string | Date updatedAt: string | Date _count: { actions: number } @@ -196,6 +197,16 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps {t('agents.metadata.executions', { count: agent._count.actions })} + {agent.frequency !== 'manual' && agent.nextRun && ( +
+ + {t('agents.schedule.nextRun')}{' '} + {mounted + ? formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale }) + : new Date(agent.nextRun).toISOString().split('T')[0]} +
+ )} + {lastAction && (
Promise @@ -88,6 +91,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '') const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '') const [frequency, setFrequency] = useState(agent?.frequency || 'manual') + const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00') + const [scheduledDay, setScheduledDay] = useState(agent?.scheduledDay ?? 1) + const [timezone] = useState(() => { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' } + }) const [selectedTools, setSelectedTools] = useState(() => { if (agent?.tools) { try { @@ -177,6 +185,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps formData.set('maxSteps', String(maxSteps)) formData.set('notifyEmail', String(notifyEmail)) formData.set('includeImages', String(includeImages)) + formData.set('scheduledTime', scheduledTime) + formData.set('scheduledDay', String(scheduledDay)) + formData.set('timezone', timezone) await onSave(formData) } catch { @@ -356,6 +367,59 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
+ {/* Schedule config: time + day pickers (hidden for manual/hourly) */} + {frequency !== 'manual' && frequency !== 'hourly' && ( +
+ {/* Day selector (weekly/monthly only) */} + {frequency === 'weekly' && ( +
+ + +
+ )} + {frequency === 'monthly' && ( +
+ + +
+ )} + {/* Time picker */} +
+ + setScheduledTime(e.target.value)} + className={inputCls} + /> +
+
+ )} + {/* Email Notification */}
setNotifyEmail(!notifyEmail)} diff --git a/memento-note/lib/agents/schedule.ts b/memento-note/lib/agents/schedule.ts new file mode 100644 index 0000000..53e920a --- /dev/null +++ b/memento-note/lib/agents/schedule.ts @@ -0,0 +1,162 @@ +/** + * Agent Schedule Helper + * Computes the next run date for an agent based on its frequency, scheduled time, and timezone. + */ + +interface ScheduleParams { + frequency: string // manual, hourly, daily, weekly, monthly + scheduledTime?: string | null // "HH:mm" in user's local time + scheduledDay?: number | null // 0-6 (Mon-Sun) for weekly, 1-31 for monthly + timezone?: string | null // IANA timezone, e.g. "Europe/Paris" +} + +/** + * Parse "HH:mm" into hours and minutes. + */ +function parseTime(time: string): { hours: number; minutes: number } { + const [h, m] = time.split(':').map(Number) + return { hours: h, minutes: m || 0 } +} + +/** + * Calculate the next run date for an agent. + * Returns a UTC Date, or null for manual frequency. + */ +export function calculateNextRun(params: ScheduleParams): Date | null { + const { frequency, scheduledTime, scheduledDay, timezone } = params + + if (frequency === 'manual') return null + + const tz = timezone || 'UTC' + const time = scheduledTime || '08:00' + const { hours, minutes } = parseTime(time) + const now = new Date() + + if (frequency === 'hourly') { + return new Date(now.getTime() + 60 * 60 * 1000) + } + + // For daily/weekly/monthly, build the target in the user's timezone then convert to UTC + const fmt = (d: Date) => + new Intl.DateTimeFormat('en-US', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, timeZone: tz, + }).format(d) + + // Helper: create a Date in the user's timezone for a given local date/time + const makeDateInTZ = (year: number, month: number, day: number, h: number, m: number): Date => { + // Build a string like "2026-04-26 09:00" in the user's timezone + const localStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00` + // Parse as if it's in the user's timezone by using Intl to find the UTC offset + // We use a trick: format the date in the TZ, then find the offset + const fakeUtc = new Date(localStr + 'Z') + // Get the UTC offset for this time in the timezone + const offsetMs = getTimezoneOffset(tz, fakeUtc) + return new Date(fakeUtc.getTime() - offsetMs) + } + + if (frequency === 'daily') { + // Try today first, if past → tomorrow + const todayStr = fmt(now) + const todayParts = todayStr.match(/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2}):(\d{2})/) + if (!todayParts) return new Date(now.getTime() + 24 * 60 * 60 * 1000) + + const todayDay = parseInt(todayParts[1]) + const todayMonth = parseInt(todayParts[2]) - 1 + const todayYear = parseInt(todayParts[3]) + + const target = makeDateInTZ(todayYear, todayMonth, todayDay, hours, minutes) + if (target > now) return target + + // Tomorrow + const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000) + const tmStr = fmt(tomorrow) + const tmParts = tmStr.match(/(\d{2})\/(\d{2})\/(\d{4}),/) + if (!tmParts) return new Date(now.getTime() + 24 * 60 * 60 * 1000) + return makeDateInTZ(parseInt(tmParts[3]), parseInt(tmParts[2]) - 1, parseInt(tmParts[1]), hours, minutes) + } + + if (frequency === 'weekly') { + const targetDay = scheduledDay ?? 0 // 0=Monday + // JS getDay(): 0=Sun, 1=Mon, ... 6=Sat + // Our convention: 0=Mon, 1=Tue, ... 6=Sun + const jsDay = targetDay === 6 ? 0 : targetDay + 1 + + // Start from today, scan forward up to 7 days + for (let i = 0; i <= 7; i++) { + const candidate = new Date(now.getTime() + i * 24 * 60 * 60 * 1000) + const candidateStr = fmt(candidate) + const parts = candidateStr.match(/(\d{2})\/(\d{2})\/(\d{4}),/) + if (!parts) continue + + const candidateDay = parseInt(parts[1]) + const candidateMonth = parseInt(parts[2]) - 1 + const candidateYear = parseInt(parts[3]) + + // Get day of week in the user's timezone + const dayOfWeek = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: tz }).format(candidate) + + // Map to our convention + const dayMap: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 } + const thisDayIndex = dayMap[dayOfWeek] + + if (thisDayIndex === targetDay) { + const target = makeDateInTZ(candidateYear, candidateMonth, candidateDay, hours, minutes) + if (target > now) return target + } + } + + // Fallback: next week same day + return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + } + + if (frequency === 'monthly') { + const targetDayOfMonth = scheduledDay ?? 1 + + // Try this month, then next month, then the month after + for (let offset = 0; offset <= 2; offset++) { + const base = new Date(now) + base.setMonth(base.getMonth() + offset) + + const year = base.getFullYear() + const month = base.getMonth() + // Clamp to max days in month + const maxDays = new Date(year, month + 1, 0).getDate() + const day = Math.min(targetDayOfMonth, maxDays) + + const target = makeDateInTZ(year, month, day, hours, minutes) + if (target > now) return target + } + + // Fallback + return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + } + + return null +} + +/** + * Get the timezone offset in ms for a given timezone at a specific UTC time. + * Positive offset means the timezone is ahead of UTC. + */ +function getTimezoneOffset(tz: string, utcDate: Date): number { + // Get local time in the target timezone + const localStr = new Intl.DateTimeFormat('en-US', { + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, timeZone: tz, + }).format(utcDate) + + // Parse the local time + const parts = localStr.match(/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2}):(\d{2})/) + if (!parts) return 0 + + const localDate = new Date( + parseInt(parts[3]), parseInt(parts[1]) - 1, parseInt(parts[2]), + parseInt(parts[4]) === 24 ? 0 : parseInt(parts[4]), parseInt(parts[5]), parseInt(parts[6]) + ) + + // Offset = local - UTC (in ms) + return localDate.getTime() - utcDate.getTime() +} diff --git a/memento-note/lib/ai/services/agent-executor.service.ts b/memento-note/lib/ai/services/agent-executor.service.ts index 0a3b5df..adba701 100644 --- a/memento-note/lib/ai/services/agent-executor.service.ts +++ b/memento-note/lib/ai/services/agent-executor.service.ts @@ -14,6 +14,7 @@ import { toolRegistry } from '../tools' import { sendEmail } from '@/lib/mail' import { getAgentEmailTemplate } from '@/lib/agent-email-template' import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images' +import { calculateNextRun } from '@/lib/agents/schedule' // Import tools for side-effect registration import '../tools' @@ -1094,9 +1095,19 @@ export async function executeAgent(agentId: string, userId: string, promptOverri } } + const nextRunUpdate: Record = {} + if (agent.frequency !== 'manual') { + nextRunUpdate.nextRun = calculateNextRun({ + frequency: agent.frequency, + scheduledTime: agent.scheduledTime, + scheduledDay: agent.scheduledDay, + timezone: agent.timezone, + }) + } + await prisma.agent.update({ where: { id: agentId }, - data: { lastRun: new Date() } + data: { lastRun: new Date(), ...nextRunUpdate } }) if (result.success && agent.notifyEmail) { diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 8efea82..6b08ed1 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -1254,6 +1254,21 @@ "weekly": "Weekly", "monthly": "Monthly" }, + "schedule": { + "nextRun": "Next run", + "time": "Time", + "dayOfWeek": "Day of week", + "dayOfMonth": "Day of month", + "days": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + } + }, "status": { "success": "Succeeded", "failure": "Failed", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index e7df0ed..53b5d91 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -1250,6 +1250,21 @@ "weekly": "Hebdomadaire", "monthly": "Mensuel" }, + "schedule": { + "nextRun": "Prochaine exécution", + "time": "Heure", + "dayOfWeek": "Jour de la semaine", + "dayOfMonth": "Jour du mois", + "days": { + "mon": "Lundi", + "tue": "Mardi", + "wed": "Mercredi", + "thu": "Jeudi", + "fri": "Vendredi", + "sat": "Samedi", + "sun": "Dimanche" + } + }, "status": { "success": "Réussi", "failure": "Échoué", diff --git a/memento-note/prisma/schema.prisma b/memento-note/prisma/schema.prisma index 6b6f7f9..6b4a622 100644 --- a/memento-note/prisma/schema.prisma +++ b/memento-note/prisma/schema.prisma @@ -275,6 +275,9 @@ model Agent { frequency String @default("manual") // manual, hourly, daily, weekly, monthly lastRun DateTime? nextRun DateTime? + scheduledTime String? @default("08:00") // HH:mm in user's local time + scheduledDay Int? // 0-6 for weekly (Mon=0), 1-31 for monthly + timezone String? // IANA timezone, e.g. "Europe/Paris" isEnabled Boolean @default(true) targetNotebookId String? sourceNotebookId String? // For monitor type: notebook to watch