← back to Dw Boardroom Governance

src/agents/escalation.ts

130 lines

/**
 * Escalation Agent — 12-hour threshold monitor
 * Ported from /root/DW-Agents/boardroom-agent/lib/escalation.js
 *
 * Checks every 5 minutes for pending decisions past threshold.
 * Logs escalations to gov_escalation_log.
 */

import { query, execute } from '../db/db';
import { broadcast } from '../ws/broadcast';
import { Decision } from '../types';

const CHECK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_THRESHOLD_HOURS = 12;

let escalationTimer: NodeJS.Timeout | null = null;

export async function getThresholdHours(): Promise<number> {
  const row = await query<{ value: any }>(
    "SELECT value FROM gov_config WHERE key = 'escalation_threshold_hours'"
  );
  return row[0] ? Number(row[0].value) : DEFAULT_THRESHOLD_HOURS;
}

export async function checkEscalations(): Promise<{
  escalated: number;
  checked: number;
}> {
  const thresholdHours = await getThresholdHours();

  // Find pending decisions older than threshold
  const overdue = await query<Decision>(
    `SELECT * FROM gov_decision_log
     WHERE status = 'pending'
     AND created_at < NOW() - INTERVAL '1 hour' * $1
     AND id NOT IN (
       SELECT decision_id FROM gov_escalation_log
       WHERE decision_id IS NOT NULL
       AND created_at > NOW() - INTERVAL '2 hours'
     )
     ORDER BY created_at ASC
     LIMIT 10`,
    [thresholdHours]
  );

  let escalated = 0;

  for (const decision of overdue) {
    const hoursElapsed = (Date.now() - new Date(decision.created_at).getTime()) / (1000 * 60 * 60);

    await query(
      `INSERT INTO gov_escalation_log (decision_id, escalation_type, hours_elapsed, action_taken, escalated_to)
       VALUES ($1, $2, $3, $4, $5)`,
      [
        decision.id,
        `${thresholdHours}hr`,
        Math.round(hoursElapsed * 10) / 10,
        'auto_escalated',
        'human_operator',
      ]
    );

    // Upgrade decision type to C (Controlled = requires human)
    if (decision.decision_type !== 'C') {
      await execute(
        "UPDATE gov_decision_log SET decision_type = 'C', reasoning = COALESCE(reasoning, '') || ' [Auto-escalated after ' || $1 || 'h]' WHERE id = $2",
        [Math.round(hoursElapsed), decision.id]
      );
    }

    broadcast('escalation', {
      decisionId: decision.id,
      topic: decision.topic,
      hoursElapsed: Math.round(hoursElapsed * 10) / 10,
    });

    escalated++;
    console.log(`[Escalation] Decision #${decision.id} escalated after ${Math.round(hoursElapsed)}h`);
  }

  return { escalated, checked: overdue.length };
}

export async function getActiveEscalations(): Promise<any[]> {
  return query(
    `SELECT e.*, d.topic, d.owner, d.status as decision_status, d.decision_type
     FROM gov_escalation_log e
     LEFT JOIN gov_decision_log d ON e.decision_id = d.id
     WHERE d.status = 'pending'
     ORDER BY e.created_at DESC
     LIMIT 20`
  );
}

export async function getEscalationLog(limit: number = 50): Promise<any[]> {
  return query(
    `SELECT e.*, d.topic, d.owner
     FROM gov_escalation_log e
     LEFT JOIN gov_decision_log d ON e.decision_id = d.id
     ORDER BY e.created_at DESC
     LIMIT $1`,
    [limit]
  );
}

export function startEscalationMonitor(): void {
  if (escalationTimer) return;

  escalationTimer = setInterval(async () => {
    try {
      const result = await checkEscalations();
      if (result.escalated > 0) {
        console.log(`[Escalation] Cycle: ${result.escalated} escalated of ${result.checked} checked`);
      }
    } catch (err: any) {
      console.error('[Escalation] Check error:', err.message);
    }
  }, CHECK_INTERVAL_MS);

  console.log(`[Escalation] Monitor started (check every ${CHECK_INTERVAL_MS / 60000}min)`);
}

export function stopEscalationMonitor(): void {
  if (escalationTimer) {
    clearInterval(escalationTimer);
    escalationTimer = null;
    console.log('[Escalation] Monitor stopped');
  }
}