feat: implement agent scheduled execution with cron and time picker
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m9s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m9s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,9 @@ export function AgentsPageClient({
|
|||||||
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
||||||
notifyEmail: formData.get('notifyEmail') === 'true',
|
notifyEmail: formData.get('notifyEmail') === 'true',
|
||||||
includeImages: formData.get('includeImages') === '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) {
|
if (editingAgent) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { auth } from '@/auth'
|
import { auth } from '@/auth'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||||
|
|
||||||
// --- CRUD ---
|
// --- CRUD ---
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ export async function createAgent(data: {
|
|||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
notifyEmail?: boolean
|
notifyEmail?: boolean
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
|
scheduledTime?: string
|
||||||
|
scheduledDay?: number
|
||||||
|
timezone?: string
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
@@ -45,10 +49,30 @@ export async function createAgent(data: {
|
|||||||
maxSteps: data.maxSteps || 10,
|
maxSteps: data.maxSteps || 10,
|
||||||
notifyEmail: data.notifyEmail || false,
|
notifyEmail: data.notifyEmail || false,
|
||||||
includeImages: data.includeImages || false,
|
includeImages: data.includeImages || false,
|
||||||
|
scheduledTime: data.scheduledTime || '08:00',
|
||||||
|
scheduledDay: data.scheduledDay ?? null,
|
||||||
|
timezone: data.timezone || null,
|
||||||
userId: session.user.id,
|
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')
|
revalidatePath('/agents')
|
||||||
return { success: true, agent }
|
return { success: true, agent }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -71,6 +95,9 @@ export async function updateAgent(id: string, data: {
|
|||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
notifyEmail?: boolean
|
notifyEmail?: boolean
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
|
scheduledTime?: string
|
||||||
|
scheduledDay?: number | null
|
||||||
|
timezone?: string
|
||||||
}) {
|
}) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user?.id) {
|
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.maxSteps !== undefined) updateData.maxSteps = data.maxSteps
|
||||||
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
|
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
|
||||||
if (data.includeImages !== undefined) updateData.includeImages = data.includeImages
|
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({
|
const agent = await prisma.agent.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
102
memento-note/app/api/cron/agents/route.ts
Normal file
102
memento-note/app/api/cron/agents/route.ts
Normal file
@@ -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 <CRON_SECRET>
|
||||||
|
*/
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ interface AgentCardProps {
|
|||||||
isEnabled: boolean
|
isEnabled: boolean
|
||||||
frequency: string
|
frequency: string
|
||||||
lastRun: string | Date | null
|
lastRun: string | Date | null
|
||||||
|
nextRun?: string | Date | null
|
||||||
createdAt: string | Date
|
createdAt: string | Date
|
||||||
updatedAt: string | Date
|
updatedAt: string | Date
|
||||||
_count: { actions: number }
|
_count: { actions: number }
|
||||||
@@ -196,6 +197,16 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
|||||||
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{agent.frequency !== 'manual' && agent.nextRun && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-3">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('agents.schedule.nextRun')}{' '}
|
||||||
|
{mounted
|
||||||
|
? formatDistanceToNow(new Date(agent.nextRun), { addSuffix: true, locale: dateLocale })
|
||||||
|
: new Date(agent.nextRun).toISOString().split('T')[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{lastAction && (
|
{lastAction && (
|
||||||
<div className={`
|
<div className={`
|
||||||
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
|
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ interface AgentFormProps {
|
|||||||
maxSteps?: number
|
maxSteps?: number
|
||||||
notifyEmail?: boolean
|
notifyEmail?: boolean
|
||||||
includeImages?: boolean
|
includeImages?: boolean
|
||||||
|
scheduledTime?: string | null
|
||||||
|
scheduledDay?: number | null
|
||||||
|
timezone?: string | null
|
||||||
} | null
|
} | null
|
||||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||||
onSave: (data: FormData) => Promise<void>
|
onSave: (data: FormData) => Promise<void>
|
||||||
@@ -88,6 +91,11 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
||||||
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
||||||
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
||||||
|
const [scheduledTime, setScheduledTime] = useState(agent?.scheduledTime || '08:00')
|
||||||
|
const [scheduledDay, setScheduledDay] = useState<number>(agent?.scheduledDay ?? 1)
|
||||||
|
const [timezone] = useState(() => {
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
|
||||||
|
})
|
||||||
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
|
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
|
||||||
if (agent?.tools) {
|
if (agent?.tools) {
|
||||||
try {
|
try {
|
||||||
@@ -177,6 +185,9 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
formData.set('maxSteps', String(maxSteps))
|
formData.set('maxSteps', String(maxSteps))
|
||||||
formData.set('notifyEmail', String(notifyEmail))
|
formData.set('notifyEmail', String(notifyEmail))
|
||||||
formData.set('includeImages', String(includeImages))
|
formData.set('includeImages', String(includeImages))
|
||||||
|
formData.set('scheduledTime', scheduledTime)
|
||||||
|
formData.set('scheduledDay', String(scheduledDay))
|
||||||
|
formData.set('timezone', timezone)
|
||||||
|
|
||||||
await onSave(formData)
|
await onSave(formData)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -356,6 +367,59 @@ export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule config: time + day pickers (hidden for manual/hourly) */}
|
||||||
|
{frequency !== 'manual' && frequency !== 'hourly' && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{/* Day selector (weekly/monthly only) */}
|
||||||
|
{frequency === 'weekly' && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className={labelCls}>{t('agents.schedule.dayOfWeek')}</label>
|
||||||
|
<select
|
||||||
|
value={scheduledDay}
|
||||||
|
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ value: 0, label: t('agents.schedule.days.mon') },
|
||||||
|
{ value: 1, label: t('agents.schedule.days.tue') },
|
||||||
|
{ value: 2, label: t('agents.schedule.days.wed') },
|
||||||
|
{ value: 3, label: t('agents.schedule.days.thu') },
|
||||||
|
{ value: 4, label: t('agents.schedule.days.fri') },
|
||||||
|
{ value: 5, label: t('agents.schedule.days.sat') },
|
||||||
|
{ value: 6, label: t('agents.schedule.days.sun') },
|
||||||
|
].map(d => (
|
||||||
|
<option key={d.value} value={d.value}>{d.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{frequency === 'monthly' && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className={labelCls}>{t('agents.schedule.dayOfMonth')}</label>
|
||||||
|
<select
|
||||||
|
value={scheduledDay}
|
||||||
|
onChange={e => setScheduledDay(Number(e.target.value))}
|
||||||
|
className={selectCls}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 31 }, (_, i) => i + 1).map(d => (
|
||||||
|
<option key={d} value={d}>{d}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Time picker */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className={labelCls}>{t('agents.schedule.time')}</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={scheduledTime}
|
||||||
|
onChange={e => setScheduledTime(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Notification */}
|
{/* Email Notification */}
|
||||||
<div
|
<div
|
||||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||||
|
|||||||
162
memento-note/lib/agents/schedule.ts
Normal file
162
memento-note/lib/agents/schedule.ts
Normal file
@@ -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<string, number> = { 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()
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { toolRegistry } from '../tools'
|
|||||||
import { sendEmail } from '@/lib/mail'
|
import { sendEmail } from '@/lib/mail'
|
||||||
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
|
import { getAgentEmailTemplate } from '@/lib/agent-email-template'
|
||||||
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
|
import { extractAndDownloadImages, extractImageUrlsFromHtml, downloadImage } from '../tools/extract-images'
|
||||||
|
import { calculateNextRun } from '@/lib/agents/schedule'
|
||||||
|
|
||||||
// Import tools for side-effect registration
|
// Import tools for side-effect registration
|
||||||
import '../tools'
|
import '../tools'
|
||||||
@@ -1094,9 +1095,19 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextRunUpdate: Record<string, Date | null> = {}
|
||||||
|
if (agent.frequency !== 'manual') {
|
||||||
|
nextRunUpdate.nextRun = calculateNextRun({
|
||||||
|
frequency: agent.frequency,
|
||||||
|
scheduledTime: agent.scheduledTime,
|
||||||
|
scheduledDay: agent.scheduledDay,
|
||||||
|
timezone: agent.timezone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.agent.update({
|
await prisma.agent.update({
|
||||||
where: { id: agentId },
|
where: { id: agentId },
|
||||||
data: { lastRun: new Date() }
|
data: { lastRun: new Date(), ...nextRunUpdate }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.success && agent.notifyEmail) {
|
if (result.success && agent.notifyEmail) {
|
||||||
|
|||||||
@@ -1254,6 +1254,21 @@
|
|||||||
"weekly": "Weekly",
|
"weekly": "Weekly",
|
||||||
"monthly": "Monthly"
|
"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": {
|
"status": {
|
||||||
"success": "Succeeded",
|
"success": "Succeeded",
|
||||||
"failure": "Failed",
|
"failure": "Failed",
|
||||||
|
|||||||
@@ -1250,6 +1250,21 @@
|
|||||||
"weekly": "Hebdomadaire",
|
"weekly": "Hebdomadaire",
|
||||||
"monthly": "Mensuel"
|
"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": {
|
"status": {
|
||||||
"success": "Réussi",
|
"success": "Réussi",
|
||||||
"failure": "Échoué",
|
"failure": "Échoué",
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ model Agent {
|
|||||||
frequency String @default("manual") // manual, hourly, daily, weekly, monthly
|
frequency String @default("manual") // manual, hourly, daily, weekly, monthly
|
||||||
lastRun DateTime?
|
lastRun DateTime?
|
||||||
nextRun 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)
|
isEnabled Boolean @default(true)
|
||||||
targetNotebookId String?
|
targetNotebookId String?
|
||||||
sourceNotebookId String? // For monitor type: notebook to watch
|
sourceNotebookId String? // For monitor type: notebook to watch
|
||||||
|
|||||||
Reference in New Issue
Block a user