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>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""EventEmitter and event types for the Meeting Room discussion engine.
|
||||
|
||||
Synchronous event bus. In Phase 2, an AsyncEventBridge will forward
|
||||
events to WebSocket clients.
|
||||
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
|
||||
@@ -126,4 +128,120 @@ class EventEmitter:
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all subscriptions."""
|
||||
self._handlers.clear()
|
||||
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")
|
||||
Reference in New Issue
Block a user