veas
Version:
Veas CLI - Command-line interface for Veas platform
535 lines ⢠23.8 kB
JavaScript
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