Files
meeting-room/meeting_room/events.py
vitya 1dffaae822 feat: Phase 2 — web server, REST API, WebSocket/SSE, UI [v0.2.0]
AsyncEventBridge bridges sync EventEmitter to asyncio consumers.
FastAPI app with session management, discussion control, roles/providers.
WebSocket for real-time events + inject, SSE for read-only streaming.
Minimal dark-theme HTML/JS UI. `meeting-room serve` CLI subcommand.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 08:50:04 +03:00

247 lines
7.6 KiB
Python

"""EventEmitter and event types for the Meeting Room discussion engine.
Synchronous event bus with AsyncEventBridge for forwarding events
to asyncio consumers (WebSocket / SSE).
"""
from __future__ import annotations
import asyncio
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Event type constants
# ---------------------------------------------------------------------------
DISCUSSION_START = "discussion_start"
ROUND_START = "round_start"
AGENT_TURN_START = "agent_turn_start"
AGENT_MESSAGE = "agent_message"
TOOL_CALL = "tool_call"
TOOL_RESULT = "tool_result"
AGENT_ERROR = "agent_error"
ROUND_END = "round_end"
DISCUSSION_END = "discussion_end"
BOSS_TURN = "boss_turn"
# ---------------------------------------------------------------------------
# Event dataclass
# ---------------------------------------------------------------------------
@dataclass
class Event:
"""A single event emitted by the discussion engine."""
type: str
data: dict[str, Any] = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
# ---------------------------------------------------------------------------
# EventEmitter
# ---------------------------------------------------------------------------
# Type alias for handler callbacks
Handler = Callable[[Event], None]
class EventEmitter:
"""Synchronous pub/sub event bus.
Usage::
bus = EventEmitter()
def on_message(event: Event):
print(event.data)
bus.on(AGENT_MESSAGE, on_message)
bus.emit(AGENT_MESSAGE, text="Hello", agent="Alice")
bus.off(AGENT_MESSAGE, on_message)
"""
def __init__(self) -> None:
self._handlers: dict[str, list[Handler]] = {}
# -- subscription --------------------------------------------------------
def on(self, event_type: str, handler: Handler) -> Handler:
"""Subscribe *handler* to *event_type*.
Returns the handler so callers can use ``off`` with the same
reference, or chain calls.
"""
self._handlers.setdefault(event_type, []).append(handler)
return handler
def off(self, event_type: str, handler: Handler) -> None:
"""Unsubscribe *handler* from *event_type*.
Silently ignores unknown event types or missing handlers.
"""
handlers = self._handlers.get(event_type)
if handlers is None:
return
try:
handlers.remove(handler)
except ValueError:
pass
# Clean up empty lists to avoid unbounded growth
if not handlers:
del self._handlers[event_type]
# -- emission ------------------------------------------------------------
def emit(self, event_type: str, **data: Any) -> Event:
"""Create an Event and call every subscribed handler synchronously.
Handlers that raise exceptions are caught and logged; the emitter
never crashes due to a bad handler.
Returns the Event that was emitted (useful for testing).
"""
event = Event(type=event_type, data=data)
handlers = self._handlers.get(event_type, [])
for handler in handlers:
try:
handler(event)
except Exception:
logger.exception(
"Handler %r raised for event %s",
handler,
event_type,
)
return event
# -- introspection -------------------------------------------------------
def handlers(self, event_type: str) -> list[Handler]:
"""Return a *copy* of the handler list for *event_type*."""
return list(self._handlers.get(event_type, []))
def clear(self) -> None:
"""Remove all subscriptions."""
self._handlers.clear()
# ---------------------------------------------------------------------------
# AsyncEventBridge
# ---------------------------------------------------------------------------
# All event types the bridge forwards.
_ALL_EVENT_TYPES = [
DISCUSSION_START,
ROUND_START,
AGENT_TURN_START,
AGENT_MESSAGE,
TOOL_CALL,
TOOL_RESULT,
AGENT_ERROR,
ROUND_END,
DISCUSSION_END,
BOSS_TURN,
]
class AsyncEventBridge:
"""Bridges synchronous EventEmitter events to asyncio consumers.
Subscribes to all event types on a sync ``EventEmitter`` and forwards
each event into ``asyncio.Queue`` objects — one per consumer. Designed
for running ``DiscussionEngine`` in a worker thread via
``asyncio.to_thread()`` while WebSocket / SSE handlers await events
on the async side.
Usage::
bus = EventEmitter()
bridge = AsyncEventBridge(bus)
bridge.start(asyncio.get_event_loop())
# In an async handler:
queue = bridge.subscribe()
try:
while True:
event = await queue.get()
await websocket.send_json(event)
finally:
bridge.unsubscribe(queue)
# Run engine in a thread:
await asyncio.to_thread(engine.run, scenario)
# Cleanup:
bridge.stop()
"""
def __init__(self, event_bus: EventEmitter) -> None:
self._bus = event_bus
self._loop: asyncio.AbstractEventLoop | None = None
self._queues: list[asyncio.Queue[dict]] = []
self._lock = threading.Lock()
self._handler = self._on_sync_event
# -- lifecycle -----------------------------------------------------------
def start(self, loop: asyncio.AbstractEventLoop) -> None:
"""Subscribe to all event types on the bus."""
self._loop = loop
for event_type in _ALL_EVENT_TYPES:
self._bus.on(event_type, self._handler)
def stop(self) -> None:
"""Unsubscribe from all event types."""
for event_type in _ALL_EVENT_TYPES:
self._bus.off(event_type, self._handler)
# -- consumer management ------------------------------------------------
def subscribe(self) -> asyncio.Queue[dict]:
"""Create a consumer queue that receives all events.
Call from async context. The caller owns the queue and must
call ``unsubscribe()`` when done.
"""
q: asyncio.Queue[dict] = asyncio.Queue(maxsize=1000)
with self._lock:
self._queues.append(q)
return q
def unsubscribe(self, q: asyncio.Queue[dict]) -> None:
"""Remove a consumer queue. Safe to call multiple times."""
with self._lock:
try:
self._queues.remove(q)
except ValueError:
pass
# -- internal ------------------------------------------------------------
def _on_sync_event(self, event: Event) -> None:
"""Sync handler called by EventEmitter on any thread."""
payload = {
"type": event.type,
"data": event.data,
"timestamp": event.timestamp.isoformat(),
}
if self._loop is None or not self._loop.is_running():
return
with self._lock:
queues = list(self._queues)
for q in queues:
self._loop.call_soon_threadsafe(self._safe_put, q, payload)
@staticmethod
def _safe_put(q: asyncio.Queue[dict], payload: dict) -> None:
"""Put payload into queue, dropping if full."""
try:
q.put_nowait(payload)
except asyncio.QueueFull:
logger.warning("AsyncEventBridge: dropping event, queue full")