UNPKG

veas

Version:

Veas CLI - Command-line interface for Veas platform

535 lines • 23.8 kB
import chalk from 'chalk'; import { TaskExecutor } from './task-executor.js'; export class ScheduleMonitor { supabase; destinationId; organizationId; taskExecutor; channels = new Map(); checkInterval; heartbeatInterval; verbose = false; constructor(supabase, destinationId, organizationId, verbose = false) { this.supabase = supabase; this.destinationId = destinationId; this.organizationId = organizationId; this.verbose = verbose; this.taskExecutor = new TaskExecutor(supabase, destinationId, organizationId); } async start() { console.log(chalk.blue('šŸ” Starting schedule monitor...')); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Initializing with:')); console.log(chalk.gray(`[VERBOSE] Destination ID: ${this.destinationId}`)); console.log(chalk.gray(`[VERBOSE] Organization ID: ${this.organizationId}`)); } await this.updateDestinationStatus('online'); this.startHeartbeat(); await this.checkPendingExecutions(); await this.subscribeToExecutions(); await this.subscribeToSchedules(); this.startScheduleChecker(); console.log(chalk.green('āœ… Schedule monitor started')); console.log(chalk.cyan(' Watching for:')); console.log(chalk.cyan(' • Executions assigned to this destination')); console.log(chalk.cyan(' • Unassigned executions for organization tasks')); console.log(chalk.cyan(' • Scheduled tasks that are due')); console.log(chalk.cyan(' • Manual task triggers\n')); } async stop() { console.log(chalk.yellow('Stopping schedule monitor...')); if (this.checkInterval) { clearInterval(this.checkInterval); } if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } for (const [name, channel] of this.channels) { console.log(chalk.gray(` Unsubscribing from ${name}...`)); await this.supabase.removeChannel(channel); } this.channels.clear(); await this.updateDestinationStatus('offline'); console.log(chalk.yellow('Schedule monitor stopped')); } async subscribeToExecutions() { try { const assignedChannel = this.supabase .channel(`executions-assigned-${this.destinationId}`) .on('postgres_changes', { event: '*', schema: 'agents', table: 'executions', filter: `destination_id=eq.${this.destinationId}`, }, async (payload) => { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Assigned execution event:')); console.log(chalk.gray(`[VERBOSE] Event: ${payload.eventType}`)); const newExec = payload.new; const oldExec = payload.old; console.log(chalk.gray(`[VERBOSE] Execution ID: ${newExec?.id || oldExec?.id}`)); } if (payload.eventType === 'INSERT' || payload.eventType === 'UPDATE') { const execution = payload.new; if (execution && (execution.status === 'pending' || execution.status === 'queued')) { console.log(chalk.cyan(`\nšŸ“Ø New assigned execution detected: ${execution.id}`)); await this.handleNewExecution(execution); } } }) .subscribe(status => { if (status === 'SUBSCRIBED') { console.log(chalk.gray(' āœ“ Subscribed to assigned executions (realtime)')); } else if (status === 'CHANNEL_ERROR') { console.log(chalk.yellow(' ⚠ Failed to subscribe to assigned executions, using polling only')); } }); this.channels.set('executions-assigned', assignedChannel); const { data: tasks } = await this.supabase .schema('agents') .from('tasks') .select('id') .eq('organization_id', this.organizationId); if (tasks && tasks.length > 0) { const unassignedChannel = this.supabase .channel(`executions-unassigned-${this.organizationId}`) .on('postgres_changes', { event: 'INSERT', schema: 'agents', table: 'executions', }, async (payload) => { const execution = payload.new; if (this.verbose) { console.log(chalk.gray('[VERBOSE] Unassigned execution event:')); console.log(chalk.gray(`[VERBOSE] Execution ID: ${execution.id}`)); console.log(chalk.gray(`[VERBOSE] Task ID: ${execution.task_id}`)); console.log(chalk.gray(`[VERBOSE] Destination ID: ${execution.destination_id || 'none'}`)); } if (!execution.destination_id && tasks.some(t => t.id === execution.task_id)) { if (execution.status === 'pending' || execution.status === 'queued') { console.log(chalk.yellow(`\nšŸ” New unassigned execution detected: ${execution.id}`)); await this.tryClaimExecution(execution); } } }) .subscribe(status => { if (status === 'SUBSCRIBED') { console.log(chalk.gray(' āœ“ Subscribed to unassigned executions (realtime)')); } else if (status === 'CHANNEL_ERROR') { console.log(chalk.yellow(' ⚠ Failed to subscribe to unassigned executions, using polling only')); } }); this.channels.set('executions-unassigned', unassignedChannel); } console.log(chalk.gray(' āœ“ Polling enabled as backup (30s interval)')); } catch (error) { console.error(chalk.red('Failed to set up execution subscriptions:'), error); console.log(chalk.yellow(' Falling back to polling-only mode')); } } async subscribeToSchedules() { try { const scheduleChannel = this.supabase .channel(`schedules-${this.destinationId}`) .on('postgres_changes', { event: '*', schema: 'agents', table: 'schedules', }, async (payload) => { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Schedule event:')); console.log(chalk.gray(`[VERBOSE] Event: ${payload.eventType}`)); const newSched = payload.new; const oldSched = payload.old; console.log(chalk.gray(`[VERBOSE] Schedule ID: ${newSched?.id || oldSched?.id}`)); } if ((payload.eventType === 'INSERT' || payload.eventType === 'UPDATE') && payload.new) { const schedule = payload.new; if (schedule.destination_id === this.destinationId || !schedule.destination_id) { if (schedule.is_enabled && schedule.next_run_at) { const nextRun = new Date(schedule.next_run_at); const now = new Date(); if (nextRun <= now) { console.log(chalk.blue(`\nā° Schedule ${schedule.id} is due`)); const { data: fullSchedule } = await this.supabase .schema('agents') .from('schedules') .select(` *, tasks!inner( id, name, organization_id, status ) `) .eq('id', schedule.id) .single(); if (fullSchedule && fullSchedule.tasks.organization_id === this.organizationId) { await this.triggerScheduledExecution(fullSchedule); } } } } } }) .subscribe(status => { if (status === 'SUBSCRIBED') { console.log(chalk.gray(' āœ“ Subscribed to schedule updates (realtime)')); } else if (status === 'CHANNEL_ERROR') { console.log(chalk.yellow(' ⚠ Failed to subscribe to schedules, using polling only')); } }); this.channels.set('schedules', scheduleChannel); console.log(chalk.gray(' āœ“ Schedule checking via polling enabled (30s interval)')); } catch (error) { console.error(chalk.red('Failed to set up schedule subscriptions:'), error); console.log(chalk.yellow(' Falling back to polling-only mode')); } } async handleNewExecution(execution) { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Handling new execution:')); console.log(chalk.gray(`[VERBOSE] ID: ${execution.id}`)); console.log(chalk.gray(`[VERBOSE] Task ID: ${execution.task_id}`)); console.log(chalk.gray(`[VERBOSE] Status: ${execution.status}`)); } if (execution.status === 'pending' || execution.status === 'queued') { await this.taskExecutor.executeTask(execution.id); } } async tryClaimExecution(execution) { console.log(chalk.gray(` Attempting to claim execution ${execution.id}...`)); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Execution details:')); console.log(chalk.gray(JSON.stringify(execution, null, 2))); } const { data: task, error: taskError } = await this.supabase .schema('agents') .from('tasks') .select('organization_id') .eq('id', execution.task_id) .single(); if (taskError || !task || task.organization_id !== this.organizationId) { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Task error or org mismatch:')); console.log(chalk.gray(`[VERBOSE] Task Error: ${taskError?.message || 'none'}`)); console.log(chalk.gray(`[VERBOSE] Task Org: ${task?.organization_id || 'N/A'}`)); console.log(chalk.gray(`[VERBOSE] Our Org: ${this.organizationId}`)); } console.log(chalk.gray(' Execution not for our organization, skipping')); return; } const { data: updated, error: claimError } = await this.supabase .schema('agents') .from('executions') .update({ destination_id: this.destinationId, assigned_at: new Date().toISOString(), }) .eq('id', execution.id) .is('destination_id', null) .select() .single(); if (!claimError && updated) { console.log(chalk.green(` āœ“ Successfully claimed execution ${execution.id}`)); await this.handleNewExecution(updated); } else if (claimError) { console.log(chalk.gray(` Could not claim execution (may be claimed by another destination)`)); } } async checkPendingExecutions() { console.log(chalk.gray(' Checking for pending executions...')); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Querying for pending executions...')); console.log(chalk.gray(`[VERBOSE] Destination: ${this.destinationId}`)); console.log(chalk.gray(`[VERBOSE] Organization: ${this.organizationId}`)); } const { data: assignedExecutions, error: assignedError } = await this.supabase .schema('agents') .from('executions') .select('*') .eq('destination_id', this.destinationId) .in('status', ['pending', 'queued']) .is('claimed_at', null); if (!assignedError && assignedExecutions && assignedExecutions.length > 0) { console.log(chalk.blue(` Found ${assignedExecutions.length} assigned pending execution(s)`)); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Assigned executions:')); console.log(chalk.gray(JSON.stringify(assignedExecutions, null, 2))); } for (const execution of assignedExecutions) { await this.handleNewExecution(execution); } } else if (this.verbose && assignedError) { console.log(chalk.gray(`[VERBOSE] Error fetching assigned executions: ${assignedError.message}`)); } const { data: tasks } = await this.supabase .schema('agents') .from('tasks') .select('id') .eq('organization_id', this.organizationId); if (tasks && tasks.length > 0) { const taskIds = tasks.map(t => t.id); const { data: unassignedExecutions, error: unassignedError } = await this.supabase .schema('agents') .from('executions') .select('*') .in('task_id', taskIds) .in('status', ['pending', 'queued']) .is('destination_id', null); if (!unassignedError && unassignedExecutions && unassignedExecutions.length > 0) { console.log(chalk.yellow(` Found ${unassignedExecutions.length} unassigned execution(s)`)); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Unassigned executions:')); console.log(chalk.gray(JSON.stringify(unassignedExecutions, null, 2))); } for (const execution of unassignedExecutions) { await this.tryClaimExecution(execution); } } else if (this.verbose && unassignedError) { console.log(chalk.gray(`[VERBOSE] Error fetching unassigned executions: ${unassignedError.message}`)); } } } startScheduleChecker() { console.log(chalk.gray(' Starting schedule checker (30s interval)...')); this.checkDueSchedules(); this.checkInterval = setInterval(() => { this.checkDueSchedules(); }, 30000); } async checkDueSchedules() { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Checking for due schedules...')); } await this.checkUnclaimedExecutions(); const { data: schedules, error } = await this.supabase .schema('agents') .from('schedules') .select(` *, tasks!inner( id, name, organization_id, status ) `) .eq('is_enabled', true) .eq('tasks.organization_id', this.organizationId) .eq('tasks.status', 'active') .or(`destination_id.eq.${this.destinationId},destination_id.is.null`) .lte('next_run_at', new Date().toISOString()); if (error) { console.error(chalk.red('Failed to fetch due schedules:'), error); return; } if (schedules && schedules.length > 0) { console.log(chalk.blue(`\nā° Found ${schedules.length} due schedule(s)`)); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Due schedules:')); console.log(chalk.gray(JSON.stringify(schedules, null, 2))); } for (const schedule of schedules) { await this.triggerScheduledExecution(schedule); } } else if (this.verbose && !error) { console.log(chalk.gray('[VERBOSE] No due schedules found')); } } async checkUnclaimedExecutions() { const { data: tasks } = await this.supabase .schema('agents') .from('tasks') .select('id') .eq('organization_id', this.organizationId); if (!tasks || tasks.length === 0) { return; } const taskIds = tasks.map(t => t.id); const { data: unclaimedExecutions } = await this.supabase .schema('agents') .from('executions') .select('*') .in('task_id', taskIds) .in('status', ['pending', 'queued']) .is('destination_id', null); if (unclaimedExecutions && unclaimedExecutions.length > 0) { console.log(chalk.yellow(`\nšŸ”„ Found ${unclaimedExecutions.length} unclaimed execution(s)`)); if (this.verbose) { console.log(chalk.gray('[VERBOSE] Unclaimed executions in periodic check:')); console.log(chalk.gray(JSON.stringify(unclaimedExecutions, null, 2))); } for (const execution of unclaimedExecutions) { await this.tryClaimExecution(execution); } } else if (this.verbose) { console.log(chalk.gray('[VERBOSE] No unclaimed executions found in periodic check')); } } async triggerScheduledExecution(schedule) { console.log(chalk.gray(` Triggering execution for task: ${schedule.tasks.name}`)); if (schedule.destination_id && schedule.destination_id !== this.destinationId) { console.log(chalk.gray(` Schedule is for a different destination, skipping`)); return; } const { data: execution, error } = await this.supabase .schema('agents') .from('executions') .insert({ task_id: schedule.task_id, schedule_id: schedule.id, destination_id: this.destinationId, status: 'pending', trigger: 'scheduled', trigger_source: `schedule:${schedule.id}`, input_params: {}, queued_at: new Date().toISOString(), }) .select() .single(); if (error) { console.error(chalk.red('Failed to create execution:'), error); return; } console.log(chalk.green(` āœ“ Created execution: ${execution.id}`)); await this.updateScheduleNextRun(schedule); await this.taskExecutor.executeTask(execution.id); } async updateScheduleNextRun(schedule) { let nextRunAt = null; switch (schedule.schedule_type) { case 'interval': if (schedule.interval_seconds) { nextRunAt = new Date(Date.now() + schedule.interval_seconds * 1000); } break; case 'calendar': nextRunAt = await this.calculateNextCalendarOccurrence(schedule); break; case 'once': await this.supabase.schema('agents').from('schedules').update({ is_enabled: false }).eq('id', schedule.id); return; case 'cron': nextRunAt = new Date(Date.now() + 3600000); break; default: return; } if (nextRunAt) { const { error } = await this.supabase .schema('agents') .from('schedules') .update({ next_run_at: nextRunAt.toISOString(), last_run_at: new Date().toISOString(), run_count: (schedule.run_count || 0) + 1, }) .eq('id', schedule.id); if (error) { console.error(chalk.red('Failed to update schedule:'), error); } } } startHeartbeat() { console.log(chalk.gray(' Starting heartbeat (60s interval)...')); this.sendHeartbeat(); this.heartbeatInterval = setInterval(() => { this.sendHeartbeat(); }, 60000); } async sendHeartbeat() { if (this.verbose) { console.log(chalk.gray('[VERBOSE] Sending heartbeat...')); } const { error } = await this.supabase.schema('agents').from('destination_heartbeats').insert({ destination_id: this.destinationId, status: 'online', active_tasks: 0, queued_tasks: 0, }); if (error) { console.error(chalk.red('Failed to send heartbeat:'), error); } else if (this.verbose) { console.log(chalk.gray('[VERBOSE] Heartbeat sent successfully')); } } async updateDestinationStatus(status) { const { error } = await this.supabase .schema('agents') .from('agent_destinations') .update({ status, last_heartbeat_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq('id', this.destinationId); if (error) { console.error(chalk.red(`Failed to update destination status: ${error.message}`)); } } async calculateNextCalendarOccurrence(schedule) { if (!schedule.recurrence_rule) { return null; } const now = new Date(); const rule = schedule.recurrence_rule.toUpperCase(); if (rule.includes('FREQ=DAILY')) { const interval = this.extractInterval(rule) || 1; return new Date(now.getTime() + interval * 24 * 60 * 60 * 1000); } else if (rule.includes('FREQ=WEEKLY')) { const interval = this.extractInterval(rule) || 1; return new Date(now.getTime() + interval * 7 * 24 * 60 * 60 * 1000); } else if (rule.includes('FREQ=MONTHLY')) { const interval = this.extractInterval(rule) || 1; const nextDate = new Date(now); nextDate.setMonth(nextDate.getMonth() + interval); return nextDate; } else if (rule.includes('FREQ=YEARLY')) { const interval = this.extractInterval(rule) || 1; const nextDate = new Date(now); nextDate.setFullYear(nextDate.getFullYear() + interval); return nextDate; } return null; } extractInterval(rule) { const match = rule.match(/INTERVAL=(\d+)/); return match?.[1] ? parseInt(match[1], 10) : null; } async getCalendarEvents(startDate, endDate) { const { data: schedules } = await this.supabase .schema('agents') .from('schedules') .select(` *, tasks!inner( id, name, organization_id ) `) .eq('schedule_type', 'calendar') .eq('tasks.organization_id', this.organizationId) .or(`destination_id.eq.${this.destinationId},destination_id.is.null`) .gte('start_time', startDate.toISOString()) .lte('start_time', endDate.toISOString()); return schedules || []; } } //# sourceMappingURL=schedule-monitor.js.map