mission-control/lib/services/trigger-rules.service.ts

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 { createClient } 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 = createClient();
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.created_at || 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 = createClient();
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 = createClient();
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 = createClient();
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 = createClient();
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 = createClient();
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 || [];
}