588 lines
16 KiB
TypeScript
588 lines
16 KiB
TypeScript
/**
|
|
* Voxyz Trigger Rules Service
|
|
* Monitors conditions and triggers mission proposals
|
|
* Phase 10: Autonomous Architecture
|
|
*/
|
|
|
|
import {
|
|
TriggerRule,
|
|
TriggerEvaluationContext,
|
|
TriggerEvaluationResult,
|
|
MissionProposalInsert,
|
|
TriggerType,
|
|
TriggerLog
|
|
} from '@/lib/supabase/voxyz.types';
|
|
import { Task } from '@/lib/data/tasks';
|
|
import { getServiceSupabase } from '@/lib/supabase/client';
|
|
import { startExecutionLog, completeExecutionLog } from '@/lib/services/daily-mission-agent';
|
|
|
|
/**
|
|
* Get all active trigger rules
|
|
*/
|
|
export async function getActiveTriggerRules(): Promise<TriggerRule[]> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
const { data, error } = await supabase
|
|
.from('ops_trigger_rules')
|
|
.select('*')
|
|
.eq('is_active', true)
|
|
.order('priority', { ascending: false });
|
|
|
|
if (error) {
|
|
console.error('Error fetching trigger rules:', error);
|
|
return [];
|
|
}
|
|
|
|
return data || [];
|
|
}
|
|
|
|
/**
|
|
* Evaluate all trigger rules against current context
|
|
*/
|
|
export async function evaluateAllTriggers(
|
|
context: TriggerEvaluationContext
|
|
): Promise<TriggerEvaluationResult[]> {
|
|
const rules = await getActiveTriggerRules();
|
|
const results: TriggerEvaluationResult[] = [];
|
|
|
|
for (const rule of rules) {
|
|
// Check cooldown
|
|
if (await isOnCooldown(rule)) {
|
|
continue;
|
|
}
|
|
|
|
// Check daily limit
|
|
if (await hasReachedDailyLimit(rule)) {
|
|
continue;
|
|
}
|
|
|
|
const result = await evaluateTrigger(rule, context);
|
|
|
|
if (result.triggered) {
|
|
results.push(result);
|
|
|
|
// Log the trigger
|
|
await logTrigger(rule, result, true);
|
|
|
|
// Update last triggered time
|
|
await updateTriggerTimestamp(rule.id);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a single trigger rule
|
|
*/
|
|
async function evaluateTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext
|
|
): Promise<TriggerEvaluationResult> {
|
|
const conditions = rule.conditions;
|
|
|
|
switch (rule.trigger_type) {
|
|
case 'deadline_approaching':
|
|
return evaluateDeadlineTrigger(rule, context, conditions);
|
|
|
|
case 'task_blocked':
|
|
return evaluateBlockedTrigger(rule, context, conditions);
|
|
|
|
case 'high_priority_created':
|
|
return evaluateHighPriorityTrigger(rule, context, conditions);
|
|
|
|
case 'stale_task':
|
|
return evaluateStaleTaskTrigger(rule, context, conditions);
|
|
|
|
case 'sprint_ending':
|
|
return evaluateSprintEndingTrigger(rule, context, conditions);
|
|
|
|
case 'daily_mission_time':
|
|
return evaluateDailyMissionTimeTrigger(rule, context, conditions);
|
|
|
|
case 'calendar_conflict':
|
|
return evaluateCalendarConflictTrigger(rule, context, conditions);
|
|
|
|
default:
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Evaluate deadline approaching trigger
|
|
*/
|
|
function evaluateDeadlineTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const hoursBefore = (conditions.hours_before as number) || 24;
|
|
const priorityFilter = conditions.priority as string | undefined;
|
|
|
|
const triggeredTasks: Task[] = [];
|
|
|
|
for (const task of context.activeTasks) {
|
|
if (!task.dueDate) continue;
|
|
|
|
const dueDate = new Date(task.dueDate);
|
|
const hoursUntil = (dueDate.getTime() - context.currentTime.getTime()) / (1000 * 60 * 60);
|
|
|
|
if (hoursUntil > 0 && hoursUntil <= hoursBefore) {
|
|
// Check priority filter if specified
|
|
if (priorityFilter && task.priority !== priorityFilter) {
|
|
continue;
|
|
}
|
|
|
|
triggeredTasks.push(task);
|
|
}
|
|
}
|
|
|
|
if (triggeredTasks.length === 0) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
// Pick the most urgent task
|
|
const mostUrgent = triggeredTasks.sort((a, b) => {
|
|
const aDue = new Date(a.dueDate!).getTime();
|
|
const bDue = new Date(b.dueDate!).getTime();
|
|
return aDue - bDue;
|
|
})[0];
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
hoursUntilDeadline: (new Date(mostUrgent.dueDate!).getTime() - context.currentTime.getTime()) / (1000 * 60 * 60),
|
|
task: mostUrgent,
|
|
},
|
|
proposalInput: {
|
|
title: mostUrgent.title,
|
|
description: mostUrgent.description || undefined,
|
|
sourceTaskIds: [mostUrgent.id],
|
|
sourceProjectIds: [mostUrgent.projectId],
|
|
},
|
|
priorityBoost: (rule.action_config.priority_boost as number) || 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate blocked task trigger
|
|
*/
|
|
function evaluateBlockedTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const blockedHours = (conditions.blocked_hours as number) || 24;
|
|
|
|
const triggeredTasks = context.activeTasks.filter(task => {
|
|
if (task.status !== 'blocked') return false;
|
|
|
|
// Check how long it's been blocked
|
|
const updatedAt = new Date(task.updatedAt);
|
|
const hoursBlocked = (context.currentTime.getTime() - updatedAt.getTime()) / (1000 * 60 * 60);
|
|
|
|
return hoursBlocked >= blockedHours;
|
|
});
|
|
|
|
if (triggeredTasks.length === 0) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
const task = triggeredTasks[0];
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
hoursBlocked: (context.currentTime.getTime() - new Date(task.updatedAt).getTime()) / (1000 * 60 * 60),
|
|
task,
|
|
},
|
|
proposalInput: {
|
|
title: `Unblock: ${task.title}`,
|
|
description: `This task has been blocked for an extended period. ${task.description || ''}`,
|
|
sourceTaskIds: [task.id],
|
|
sourceProjectIds: [task.projectId],
|
|
},
|
|
priorityBoost: (rule.action_config.priority_boost as number) || 30,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate high priority created trigger
|
|
*/
|
|
function evaluateHighPriorityTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const priorityFilter = (conditions.priority as string) || 'urgent';
|
|
|
|
// Look for recently created high priority tasks (within last hour)
|
|
const oneHourAgo = new Date(context.currentTime.getTime() - 60 * 60 * 1000);
|
|
|
|
const triggeredTasks = context.activeTasks.filter(task => {
|
|
if (task.priority !== priorityFilter) return false;
|
|
|
|
const createdAt = new Date(task.createdAt || task.updatedAt);
|
|
return createdAt > oneHourAgo;
|
|
});
|
|
|
|
if (triggeredTasks.length === 0) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
const task = triggeredTasks[0];
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
task,
|
|
createdRecently: true,
|
|
},
|
|
proposalInput: {
|
|
title: task.title,
|
|
description: task.description || undefined,
|
|
sourceTaskIds: [task.id],
|
|
sourceProjectIds: [task.projectId],
|
|
},
|
|
priorityBoost: (rule.action_config.priority_boost as number) || 25,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate stale task trigger
|
|
*/
|
|
function evaluateStaleTaskTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const staleDays = (conditions.stale_days as number) || 7;
|
|
const statusIn = (conditions.status_in as string[]) || ['todo', 'in-progress'];
|
|
|
|
const triggeredTasks = context.activeTasks.filter(task => {
|
|
if (!statusIn.includes(task.status)) return false;
|
|
|
|
const updatedAt = new Date(task.updatedAt);
|
|
const daysSinceUpdate = (context.currentTime.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
|
|
return daysSinceUpdate >= staleDays;
|
|
});
|
|
|
|
if (triggeredTasks.length === 0) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
// Pick the stalest task
|
|
const stalestTask = triggeredTasks.sort((a, b) => {
|
|
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
|
|
})[0];
|
|
|
|
const daysStale = Math.floor((context.currentTime.getTime() - new Date(stalestTask.updatedAt).getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
daysStale,
|
|
task: stalestTask,
|
|
},
|
|
proposalInput: {
|
|
title: `Review stale task: ${stalestTask.title}`,
|
|
description: `This task has been ${stalestTask.status} for ${daysStale} days without updates. ${stalestTask.description || ''}`,
|
|
sourceTaskIds: [stalestTask.id],
|
|
sourceProjectIds: [stalestTask.projectId],
|
|
},
|
|
priorityBoost: (rule.action_config.priority_boost as number) || 15,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate sprint ending trigger
|
|
*/
|
|
function evaluateSprintEndingTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const daysBefore = (conditions.days_before as number) || 3;
|
|
|
|
// This would require sprint data - simplified version
|
|
// In real implementation, fetch sprint end date from gantt-board
|
|
|
|
// For now, trigger if it's end of week (Friday) and there are incomplete tasks
|
|
const dayOfWeek = context.currentTime.getDay();
|
|
const isEndOfWeek = dayOfWeek === 5; // Friday
|
|
|
|
if (!isEndOfWeek) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
const incompleteTasks = context.activeTasks.filter(t =>
|
|
t.status !== 'done' && t.status !== 'canceled'
|
|
);
|
|
|
|
if (incompleteTasks.length === 0) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
// Find highest priority incomplete task
|
|
const priorityOrder = { 'urgent': 4, 'high': 3, 'medium': 2, 'low': 1 };
|
|
const topTask = incompleteTasks.sort((a, b) =>
|
|
(priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0)
|
|
)[0];
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
daysUntilSprintEnd: daysBefore,
|
|
incompleteTasks: incompleteTasks.length,
|
|
task: topTask,
|
|
},
|
|
proposalInput: {
|
|
title: `Sprint completion: ${topTask.title}`,
|
|
description: `Sprint ends soon. Focus on completing this high-priority task.`,
|
|
sourceTaskIds: [topTask.id],
|
|
sourceProjectIds: [topTask.projectId],
|
|
},
|
|
priorityBoost: (rule.action_config.priority_boost as number) || 20,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate daily mission time trigger
|
|
*/
|
|
function evaluateDailyMissionTimeTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
const hour = (conditions.hour as number) || 7;
|
|
const minute = (conditions.minute as number) || 0;
|
|
|
|
const currentHour = context.currentTime.getHours();
|
|
const currentMinute = context.currentTime.getMinutes();
|
|
|
|
// Trigger if it's within 5 minutes of the target time
|
|
const targetMinutes = hour * 60 + minute;
|
|
const currentMinutes = currentHour * 60 + currentMinute;
|
|
|
|
if (Math.abs(currentMinutes - targetMinutes) > 5) {
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
return {
|
|
triggered: true,
|
|
rule,
|
|
context: {
|
|
scheduledTime: `${hour}:${minute.toString().padStart(2, '0')}`,
|
|
},
|
|
// This trigger is handled by the daily mission agent, not proposal creation
|
|
proposalInput: undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate calendar conflict trigger
|
|
*/
|
|
function evaluateCalendarConflictTrigger(
|
|
rule: TriggerRule,
|
|
context: TriggerEvaluationContext,
|
|
conditions: Record<string, unknown>
|
|
): TriggerEvaluationResult {
|
|
// Check for conflicts between proposed missions and calendar
|
|
// This would require fetching calendar data
|
|
|
|
// Simplified: check if any active task has a due date that conflicts with meetings
|
|
// In real implementation, this would check against Google Calendar API
|
|
|
|
return { triggered: false, rule, context: {} };
|
|
}
|
|
|
|
/**
|
|
* Check if trigger is on cooldown
|
|
*/
|
|
async function isOnCooldown(rule: TriggerRule): Promise<boolean> {
|
|
if (!rule.last_triggered_at) return false;
|
|
|
|
const lastTriggered = new Date(rule.last_triggered_at);
|
|
const cooldownMs = (rule.cooldown_minutes || 60) * 60 * 1000;
|
|
const cooldownEnd = new Date(lastTriggered.getTime() + cooldownMs);
|
|
|
|
return new Date() < cooldownEnd;
|
|
}
|
|
|
|
/**
|
|
* Check if trigger has reached daily limit
|
|
*/
|
|
async function hasReachedDailyLimit(rule: TriggerRule): Promise<boolean> {
|
|
const supabase = getServiceSupabase();
|
|
const today = new Date().toISOString().split('T')[0];
|
|
|
|
const { count, error } = await supabase
|
|
.from('ops_trigger_log')
|
|
.select('*', { count: 'exact', head: true })
|
|
.eq('rule_id', rule.id)
|
|
.gte('triggered_at', `${today}T00:00:00Z`);
|
|
|
|
if (error) {
|
|
console.error('Error checking trigger limit:', error);
|
|
return false;
|
|
}
|
|
|
|
return (count || 0) >= (rule.max_triggers_per_day || 10);
|
|
}
|
|
|
|
/**
|
|
* Update trigger timestamp
|
|
*/
|
|
async function updateTriggerTimestamp(ruleId: string): Promise<void> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
await supabase
|
|
.from('ops_trigger_rules')
|
|
.update({
|
|
last_triggered_at: new Date().toISOString(),
|
|
trigger_count: supabase.rpc('increment_trigger_count', { rule_id: ruleId }),
|
|
})
|
|
.eq('id', ruleId);
|
|
}
|
|
|
|
/**
|
|
* Log a trigger execution
|
|
*/
|
|
async function logTrigger(
|
|
rule: TriggerRule,
|
|
result: TriggerEvaluationResult,
|
|
success: boolean
|
|
): Promise<void> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
await supabase
|
|
.from('ops_trigger_log')
|
|
.insert({
|
|
rule_id: rule.id,
|
|
rule_name: rule.name,
|
|
triggered_at: new Date().toISOString(),
|
|
trigger_type: rule.trigger_type,
|
|
context: result.context,
|
|
task_ids: result.proposalInput?.sourceTaskIds || [],
|
|
action_taken: rule.action_type,
|
|
action_result: success ? 'triggered' : 'failed',
|
|
success,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Run the trigger monitor agent
|
|
*/
|
|
export async function runTriggerMonitor(): Promise<void> {
|
|
const logId = await startExecutionLog('trigger_monitor', 'scheduled');
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
// Fetch active tasks
|
|
const { fetchAllTasks } = await import('@/lib/data/tasks');
|
|
const tasks = await fetchAllTasks();
|
|
|
|
const context: TriggerEvaluationContext = {
|
|
currentTime: new Date(),
|
|
activeTasks: tasks.filter(t => t.status !== 'done' && t.status !== 'canceled'),
|
|
recentProposals: [], // Would fetch from DB
|
|
todayMissions: [], // Would fetch from DB
|
|
userAvailability: [], // Would fetch from calendar
|
|
};
|
|
|
|
const triggered = await evaluateAllTriggers(context);
|
|
|
|
await completeExecutionLog(logId, {
|
|
status: 'success',
|
|
duration: Date.now() - startTime,
|
|
tasksAnalyzed: context.activeTasks.length,
|
|
outputSummary: {
|
|
triggersEvaluated: triggered.length,
|
|
triggersTriggered: triggered.filter(t => t.triggered).length,
|
|
},
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error running trigger monitor:', error);
|
|
|
|
await completeExecutionLog(logId, {
|
|
status: 'failed',
|
|
duration: Date.now() - startTime,
|
|
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a manual trigger
|
|
*/
|
|
export async function triggerManualEvaluation(
|
|
triggerType: TriggerType,
|
|
taskIds?: string[]
|
|
): Promise<TriggerEvaluationResult[]> {
|
|
// Fetch active trigger rules of this type
|
|
const supabase = getServiceSupabase();
|
|
|
|
const { data: rules } = await supabase
|
|
.from('ops_trigger_rules')
|
|
.select('*')
|
|
.eq('trigger_type', triggerType)
|
|
.eq('is_active', true);
|
|
|
|
if (!rules || rules.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const { fetchAllTasks } = await import('@/lib/data/tasks');
|
|
const tasks = await fetchAllTasks();
|
|
|
|
const filteredTasks = taskIds
|
|
? tasks.filter(t => taskIds.includes(t.id))
|
|
: tasks;
|
|
|
|
const context: TriggerEvaluationContext = {
|
|
currentTime: new Date(),
|
|
activeTasks: filteredTasks.filter(t => t.status !== 'done'),
|
|
recentProposals: [],
|
|
todayMissions: [],
|
|
userAvailability: [],
|
|
};
|
|
|
|
const results: TriggerEvaluationResult[] = [];
|
|
|
|
for (const rule of rules) {
|
|
const result = await evaluateTrigger(rule as TriggerRule, context);
|
|
if (result.triggered) {
|
|
results.push(result);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get recent trigger logs
|
|
*/
|
|
export async function getRecentTriggerLogs(limit: number = 50): Promise<TriggerLog[]> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
const { data, error } = await supabase
|
|
.from('ops_trigger_log')
|
|
.select('*')
|
|
.order('created_at', { ascending: false })
|
|
.limit(limit);
|
|
|
|
if (error) {
|
|
console.error('Error fetching trigger logs:', error);
|
|
return [];
|
|
}
|
|
|
|
return data || [];
|
|
}
|