← back to Dw Boardroom Governance

src/engine/meetingEngine.ts

206 lines

/**
 * Meeting Engine — Phase orchestrator
 * Ported from /root/DW-Agents/boardroom-agent/lib/meeting-engine.js
 *
 * 8-phase structure: Caucus → Call to Order → Old Business → Current Business
 *   → Breakout → New Business → Minutes → Adjournment
 *
 * Features:
 *   - Mutex lock (one meeting at a time)
 *   - Phase timeouts (10min adhoc, 20min scheduled)
 *   - WebSocket broadcast on phase transitions
 */

import { query, queryOne, execute } from '../db/db';
import { broadcast } from '../ws/broadcast';
import { Meeting, MeetingPhase, MEETING_PHASES, MeetingStatus, AgendaItem } from '../types';
import { v4 as uuidv4 } from 'uuid';

// Mutex: only one meeting at a time
let activeMeetingId: string | null = null;
let phaseTimer: NodeJS.Timeout | null = null;

const PHASE_DURATION_MS: Record<string, number> = {
  caucus: 60_000,
  call_to_order: 30_000,
  old_business: 120_000,
  current_business: 120_000,
  breakout: 90_000,
  new_business: 120_000,
  minutes: 60_000,
  adjournment: 30_000,
};

const MAX_MEETING_DURATION_MS: Record<string, number> = {
  emergency: 10 * 60_000,
  adhoc: 10 * 60_000,
  strategic: 20 * 60_000,
  initiative: 15 * 60_000,
  ops: 15 * 60_000,
  standup: 10 * 60_000,
};

export function isRunning(): boolean {
  return activeMeetingId !== null;
}

export function getActiveMeetingId(): string | null {
  return activeMeetingId;
}

export async function startMeeting(meetingType: string, agendaItems?: AgendaItem[]): Promise<Meeting> {
  if (activeMeetingId) {
    throw new Error('Meeting already in progress. Halt current meeting first.');
  }

  const id = uuidv4();
  const now = new Date().toISOString();

  await query(
    `INSERT INTO gov_meetings (id, meeting_type, phase, started_at, status, action_items)
     VALUES ($1, $2, $3, $4, $5, $6)`,
    [id, meetingType, 'caucus', now, 'in_progress', JSON.stringify(agendaItems || [])]
  );

  activeMeetingId = id;

  // Add opening message
  await addMessage(id, 'system', 'Governance', `${meetingType} meeting called to order.`, 'system');

  broadcast('meeting_started', { meetingId: id, meetingType, phase: 'caucus' });
  console.log(`[Meeting] Started ${meetingType} meeting: ${id}`);

  // Start auto-advance timer
  schedulePhaseAdvance(id, 'caucus');

  return (await queryOne<Meeting>('SELECT * FROM gov_meetings WHERE id = $1', [id]))!;
}

export async function advancePhase(meetingId: string): Promise<MeetingPhase | null> {
  const meeting = await queryOne<Meeting>('SELECT * FROM gov_meetings WHERE id = $1', [meetingId]);
  if (!meeting || meeting.status !== 'in_progress') return null;

  const currentIdx = MEETING_PHASES.indexOf(meeting.phase as MeetingPhase);
  if (currentIdx === -1 || currentIdx >= MEETING_PHASES.length - 1) {
    // Last phase — complete the meeting
    await completeMeeting(meetingId);
    return null;
  }

  const nextPhase = MEETING_PHASES[currentIdx + 1];

  await execute(
    'UPDATE gov_meetings SET phase = $1 WHERE id = $2',
    [nextPhase, meetingId]
  );

  await addMessage(meetingId, 'system', 'Governance', `Phase: ${formatPhase(nextPhase)}`, 'phase_change');
  broadcast('meeting_phase', { meetingId, phase: nextPhase, phaseIndex: currentIdx + 1 });

  console.log(`[Meeting] ${meetingId} → ${nextPhase}`);

  // Schedule next advance
  schedulePhaseAdvance(meetingId, nextPhase);

  return nextPhase;
}

export async function haltMeeting(meetingId: string): Promise<void> {
  if (phaseTimer) {
    clearTimeout(phaseTimer);
    phaseTimer = null;
  }

  await execute(
    "UPDATE gov_meetings SET status = 'halted', ended_at = NOW() WHERE id = $1",
    [meetingId]
  );

  await addMessage(meetingId, 'system', 'Governance', 'Meeting halted by operator.', 'system');
  broadcast('meeting_halted', { meetingId });

  if (activeMeetingId === meetingId) {
    activeMeetingId = null;
  }

  console.log(`[Meeting] Halted: ${meetingId}`);
}

async function completeMeeting(meetingId: string): Promise<void> {
  if (phaseTimer) {
    clearTimeout(phaseTimer);
    phaseTimer = null;
  }

  await execute(
    "UPDATE gov_meetings SET status = 'completed', phase = 'adjournment', ended_at = NOW() WHERE id = $1",
    [meetingId]
  );

  await addMessage(meetingId, 'system', 'Governance', 'Meeting adjourned.', 'system');
  broadcast('meeting_completed', { meetingId });

  if (activeMeetingId === meetingId) {
    activeMeetingId = null;
  }

  console.log(`[Meeting] Completed: ${meetingId}`);
}

export async function addMessage(
  meetingId: string,
  agentId: string,
  agentName: string,
  message: string,
  messageType: string = 'update'
): Promise<void> {
  await query(
    `INSERT INTO gov_meeting_messages (meeting_id, agent_id, agent_name, message, message_type)
     VALUES ($1, $2, $3, $4, $5)`,
    [meetingId, agentId, agentName, message, messageType]
  );

  broadcast('meeting_message', { meetingId, agentId, agentName, message, messageType });
}

function schedulePhaseAdvance(meetingId: string, phase: string): void {
  if (phaseTimer) clearTimeout(phaseTimer);

  // Auto-advance after this phase's configured duration
  const duration = PHASE_DURATION_MS[phase] || 120_000;
  phaseTimer = setTimeout(async () => {
    try {
      const meeting = await queryOne<Meeting>('SELECT * FROM gov_meetings WHERE id = $1', [meetingId]);
      if (meeting && meeting.status === 'in_progress') {
        await advancePhase(meetingId);
      }
    } catch (err: any) {
      console.error('[Meeting] Auto-advance error:', err.message);
    }
  }, duration);
}

function formatPhase(phase: string): string {
  return phase.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

// ─── Queries ─────────────────────────────────────

export async function listMeetings(limit: number = 20): Promise<Meeting[]> {
  return query<Meeting>(
    'SELECT * FROM gov_meetings ORDER BY created_at DESC LIMIT $1',
    [limit]
  );
}

export async function getMeeting(id: string): Promise<Meeting | null> {
  return queryOne<Meeting>('SELECT * FROM gov_meetings WHERE id = $1', [id]);
}

export async function getMessages(meetingId: string): Promise<any[]> {
  return query(
    'SELECT * FROM gov_meeting_messages WHERE meeting_id = $1 ORDER BY created_at ASC',
    [meetingId]
  );
}