personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add server-side event handling framework for apps

Apps can now define events.py with @on_event decorated handlers that
react to Callosum events. Handlers are discovered at Convey startup
and dispatched via thread pool with serialized processing.

- apps/events.py: Framework with decorator, discovery, dispatch
- apps/dev/events.py: Example debug handler logging all events
- convey/bridge.py: Dispatch to handlers after WebSocket broadcast
- convey/cli.py: Lifecycle management (discover, start, stop)
- Wildcard support: @on_event("*", "*") matches all events
- 30s timeout per handler, errors isolated

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+723 -5
+26
apps/dev/events.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Dev app event handlers - debug logging for all Callosum events. 5 + 6 + This module demonstrates server-side event handling for apps. 7 + Enable verbose logging to see events in the Convey log. 8 + """ 9 + 10 + import logging 11 + 12 + from apps.events import EventContext, on_event 13 + 14 + logger = logging.getLogger(__name__) 15 + 16 + 17 + @on_event("*", "*") 18 + def log_all_events(ctx: EventContext) -> None: 19 + """Log all Callosum events for debugging. 20 + 21 + This handler matches all events via wildcards and logs them at DEBUG level. 22 + Useful for understanding event flow during development. 23 + """ 24 + logger.debug( 25 + f"[dev] Event: {ctx.tract}/{ctx.event} - " f"keys: {list(ctx.msg.keys())}" 26 + )
+345
apps/events.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Server-side event handling framework for apps. 5 + 6 + Apps can define event handlers in `events.py` that react to Callosum events. 7 + Handlers are discovered at Convey startup and dispatched via a thread pool. 8 + 9 + Usage in apps/my_app/events.py: 10 + 11 + from apps.events import on_event 12 + 13 + @on_event("observe", "observed") 14 + def handle_observation(ctx): 15 + day = ctx.msg.get("day") 16 + segment = ctx.msg.get("segment") 17 + # React to completed observation... 18 + 19 + @on_event("cortex", "finish") 20 + def handle_agent_done(ctx): 21 + # React to agent completion... 22 + 23 + @on_event("*", "*") # Wildcard - all events 24 + def log_all(ctx): 25 + # Debug logging... 26 + 27 + Handlers receive an EventContext with: 28 + - ctx.msg: The raw Callosum message dict 29 + - ctx.app: The app name that owns this handler 30 + - ctx.tract: Event tract (e.g., "observe") 31 + - ctx.event: Event type (e.g., "observed") 32 + - ctx.journal_root: Path to the journal directory 33 + """ 34 + 35 + from __future__ import annotations 36 + 37 + import importlib 38 + import logging 39 + import os 40 + from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError 41 + from dataclasses import dataclass 42 + from pathlib import Path 43 + from typing import Any, Callable, Dict, List, Tuple 44 + 45 + logger = logging.getLogger(__name__) 46 + 47 + # Default timeout for handler execution (seconds) 48 + DEFAULT_TIMEOUT = 30.0 49 + 50 + # Default number of worker threads 51 + DEFAULT_WORKERS = 4 52 + 53 + 54 + @dataclass 55 + class EventContext: 56 + """Context passed to event handlers.""" 57 + 58 + msg: Dict[str, Any] 59 + app: str 60 + tract: str 61 + event: str 62 + journal_root: str 63 + 64 + 65 + # Handler registry: (tract, event) -> [(app_name, handler_fn), ...] 66 + _handlers: Dict[Tuple[str, str], List[Tuple[str, Callable[[EventContext], None]]]] = {} 67 + 68 + # Thread pool for async dispatch 69 + _executor: ThreadPoolExecutor | None = None 70 + 71 + # Track which app is currently being imported (for decorator context) 72 + _current_app: str | None = None 73 + 74 + 75 + def on_event(tract: str, event: str) -> Callable: 76 + """Decorator to register a function as an event handler. 77 + 78 + Args: 79 + tract: Callosum tract to match (e.g., "observe", "cortex") or "*" for all 80 + event: Event type to match (e.g., "observed", "finish") or "*" for all 81 + 82 + Returns: 83 + Decorator function that registers the handler 84 + 85 + Example: 86 + @on_event("observe", "observed") 87 + def handle_observation(ctx: EventContext): 88 + print(f"Segment {ctx.msg['segment']} observed") 89 + """ 90 + 91 + def decorator(fn: Callable[[EventContext], None]) -> Callable[[EventContext], None]: 92 + key = (tract, event) 93 + if key not in _handlers: 94 + _handlers[key] = [] 95 + 96 + # Use current app context from discovery, or infer from module 97 + app_name = _current_app 98 + if app_name is None: 99 + # Fallback: extract from module name (apps.my_app.events -> my_app) 100 + module = fn.__module__ 101 + if module.startswith("apps.") and ".events" in module: 102 + parts = module.split(".") 103 + if len(parts) >= 2: 104 + app_name = parts[1] 105 + if app_name is None: 106 + app_name = "unknown" 107 + 108 + _handlers[key].append((app_name, fn)) 109 + logger.debug( 110 + f"Registered handler {fn.__name__} for ({tract}, {event}) in app {app_name}" 111 + ) 112 + return fn 113 + 114 + return decorator 115 + 116 + 117 + def discover_handlers() -> int: 118 + """Discover and load event handlers from apps/*/events.py. 119 + 120 + This function scans the apps/ directory for events.py files and 121 + dynamically imports them, which triggers @on_event decorators. 122 + 123 + Returns: 124 + Number of apps with event handlers discovered 125 + 126 + Raises: 127 + No exceptions - errors are logged but don't prevent other apps from loading 128 + """ 129 + global _current_app 130 + 131 + apps_dir = Path(__file__).parent 132 + 133 + if not apps_dir.exists(): 134 + logger.debug("No apps/ directory found, skipping event handler discovery") 135 + return 0 136 + 137 + discovered_count = 0 138 + total_handlers = 0 139 + 140 + for app_dir in sorted(apps_dir.iterdir()): 141 + # Skip non-directories and private directories 142 + if not app_dir.is_dir() or app_dir.name.startswith("_"): 143 + continue 144 + 145 + events_file = app_dir / "events.py" 146 + if not events_file.exists(): 147 + continue 148 + 149 + app_name = app_dir.name 150 + 151 + try: 152 + # Set context for decorator 153 + _current_app = app_name 154 + 155 + # Import triggers @on_event decorators 156 + module_name = f"apps.{app_name}.events" 157 + importlib.import_module(module_name) 158 + 159 + # Count handlers for this app 160 + app_handlers = sum( 161 + 1 162 + for handlers in _handlers.values() 163 + for (app, _) in handlers 164 + if app == app_name 165 + ) 166 + 167 + discovered_count += 1 168 + total_handlers += app_handlers 169 + logger.info(f"Loaded {app_handlers} event handler(s) from app: {app_name}") 170 + except Exception as e: 171 + # Gracefully handle errors - don't break server startup 172 + logger.error( 173 + f"Failed to load events from app '{app_name}': {e}", exc_info=True 174 + ) 175 + finally: 176 + _current_app = None 177 + 178 + if discovered_count > 0: 179 + logger.info( 180 + f"Discovered {total_handlers} event handler(s) from {discovered_count} app(s)" 181 + ) 182 + 183 + return discovered_count 184 + 185 + 186 + def _get_handlers( 187 + msg: Dict[str, Any], 188 + ) -> List[Tuple[str, Callable[[EventContext], None]]]: 189 + """Get all handlers matching the given message. 190 + 191 + Matches exact (tract, event), plus wildcards: 192 + - ("*", "*") matches all events 193 + - (tract, "*") matches all events in a tract 194 + - ("*", event) matches event type across all tracts 195 + 196 + Args: 197 + msg: Callosum message with tract and event fields 198 + 199 + Returns: 200 + List of (app_name, handler_fn) tuples 201 + """ 202 + tract = msg.get("tract", "") 203 + event = msg.get("event", "") 204 + 205 + handlers = [] 206 + 207 + # Exact match 208 + handlers.extend(_handlers.get((tract, event), [])) 209 + 210 + # Wildcard: all events in this tract 211 + if (tract, "*") in _handlers: 212 + handlers.extend(_handlers[(tract, "*")]) 213 + 214 + # Wildcard: this event type in any tract 215 + if ("*", event) in _handlers: 216 + handlers.extend(_handlers[("*", event)]) 217 + 218 + # Wildcard: all events 219 + if ("*", "*") in _handlers: 220 + handlers.extend(_handlers[("*", "*")]) 221 + 222 + return handlers 223 + 224 + 225 + def _run_handler( 226 + app_name: str, 227 + handler: Callable[[EventContext], None], 228 + ctx: EventContext, 229 + ) -> None: 230 + """Run a single handler with error handling. 231 + 232 + Args: 233 + app_name: Name of the app that owns this handler 234 + handler: The handler function to call 235 + ctx: Event context to pass to handler 236 + """ 237 + try: 238 + handler(ctx) 239 + except Exception as e: 240 + logger.error( 241 + f"Event handler {handler.__name__} (app: {app_name}) failed: {e}", 242 + exc_info=True, 243 + ) 244 + 245 + 246 + def dispatch(msg: Dict[str, Any], timeout: float = DEFAULT_TIMEOUT) -> int: 247 + """Dispatch a Callosum message to matching handlers. 248 + 249 + Handlers are submitted to the thread pool and this function blocks until 250 + all handlers complete or timeout. This serializes event processing to 251 + ensure handlers finish before the next event is processed. 252 + 253 + Each handler is wrapped in error handling so failures don't affect other 254 + handlers or the caller. 255 + 256 + Args: 257 + msg: Callosum message dict with tract, event, and other fields 258 + timeout: Maximum seconds to wait per handler (default: 30) 259 + 260 + Returns: 261 + Number of handlers invoked 262 + """ 263 + if _executor is None: 264 + logger.debug("Event dispatcher not started, skipping dispatch") 265 + return 0 266 + 267 + handlers = _get_handlers(msg) 268 + if not handlers: 269 + return 0 270 + 271 + tract = msg.get("tract", "") 272 + event = msg.get("event", "") 273 + journal_root = os.environ.get("JOURNAL_PATH", "") 274 + 275 + futures: List[Tuple[str, str, Future]] = [] 276 + 277 + for app_name, handler in handlers: 278 + ctx = EventContext( 279 + msg=msg, 280 + app=app_name, 281 + tract=tract, 282 + event=event, 283 + journal_root=journal_root, 284 + ) 285 + future = _executor.submit(_run_handler, app_name, handler, ctx) 286 + futures.append((app_name, handler.__name__, future)) 287 + 288 + # Wait for all handlers with timeout (serializes event processing) 289 + for app_name, handler_name, future in futures: 290 + try: 291 + future.result(timeout=timeout) 292 + except TimeoutError: 293 + logger.warning( 294 + f"Event handler {handler_name} (app: {app_name}) timed out after {timeout}s" 295 + ) 296 + except Exception as e: 297 + # Should not happen since _run_handler catches exceptions 298 + logger.error(f"Unexpected error in handler {handler_name}: {e}") 299 + 300 + return len(handlers) 301 + 302 + 303 + def start_dispatcher(workers: int = DEFAULT_WORKERS) -> None: 304 + """Start the event dispatcher thread pool. 305 + 306 + Args: 307 + workers: Number of worker threads (default: 4) 308 + """ 309 + global _executor 310 + 311 + if _executor is not None: 312 + logger.debug("Event dispatcher already started") 313 + return 314 + 315 + _executor = ThreadPoolExecutor( 316 + max_workers=workers, thread_name_prefix="event_handler" 317 + ) 318 + logger.info(f"Started event dispatcher with {workers} workers") 319 + 320 + 321 + def stop_dispatcher() -> None: 322 + """Stop the event dispatcher thread pool gracefully.""" 323 + global _executor 324 + 325 + if _executor is None: 326 + return 327 + 328 + logger.info("Stopping event dispatcher...") 329 + _executor.shutdown(wait=True, cancel_futures=False) 330 + _executor = None 331 + logger.info("Event dispatcher stopped") 332 + 333 + 334 + def get_handler_count() -> int: 335 + """Get the total number of registered handlers. 336 + 337 + Returns: 338 + Total handler count across all apps and event patterns 339 + """ 340 + return sum(len(handlers) for handlers in _handlers.values()) 341 + 342 + 343 + def clear_handlers() -> None: 344 + """Clear all registered handlers. Useful for testing.""" 345 + _handlers.clear()
+12 -1
convey/bridge.py
··· 46 46 47 47 48 48 def _broadcast_callosum_event(message: Dict[str, Any]) -> None: 49 - """Broadcast Callosum event to all connected clients.""" 49 + """Broadcast Callosum event to WebSocket clients and server-side handlers.""" 50 + # Broadcast to WebSocket clients 50 51 try: 51 52 _broadcast_to_websockets(message) 52 53 except Exception: # pragma: no cover - defensive against socket errors 53 54 logger.exception("Failed to broadcast %s event", message.get("tract")) 55 + 56 + # Dispatch to server-side app event handlers 57 + try: 58 + from apps.events import dispatch 59 + 60 + dispatch(message) 61 + except Exception: # pragma: no cover - defensive against handler errors 62 + logger.exception( 63 + "Failed to dispatch %s event to handlers", message.get("tract") 64 + ) 54 65 55 66 56 67 def start_bridge() -> None:
+12 -2
convey/cli.py
··· 11 11 12 12 from flask import Flask 13 13 14 - from .bridge import start_bridge 14 + from apps.events import discover_handlers, start_dispatcher, stop_dispatcher 15 + 16 + from .bridge import start_bridge, stop_bridge 15 17 16 18 logger = logging.getLogger(__name__) 17 19 ··· 44 46 # WERKZEUG_RUN_MAIN is set to 'true' only in the child/main process 45 47 should_start = not debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true" 46 48 if should_start: 49 + # Discover and start event handlers before bridge 50 + discover_handlers() 51 + start_dispatcher() 47 52 logger.info("Starting Callosum bridge") 48 53 start_bridge() 49 54 else: 50 55 logger.debug("Skipping bridge start in reloader parent process") 51 - app.run(host=host, port=port, debug=debug) 56 + 57 + try: 58 + app.run(host=host, port=port, debug=debug) 59 + finally: 60 + stop_bridge() 61 + stop_dispatcher() 52 62 53 63 54 64 def main() -> None:
+19
docs/APPS.md
··· 38 38 ├── workspace.html # Required: Main content template 39 39 ├── routes.py # Optional: Flask blueprint (only if custom routes needed) 40 40 ├── tools.py # Optional: MCP tool extensions (auto-discovered) 41 + ├── events.py # Optional: Server-side event handlers (auto-discovered) 41 42 ├── app.json # Optional: Metadata (icon, label, facet support) 42 43 ├── app_bar.html # Optional: Bottom bar controls (forms, buttons) 43 44 ├── background.html # Optional: Background JavaScript service ··· 53 54 | `workspace.html` | **Yes** | Main app content (rendered in container) | 54 55 | `routes.py` | No | Flask blueprint for custom routes (API endpoints, forms, etc.) | 55 56 | `tools.py` | No | MCP tool extensions for AI agents (auto-discovered) | 57 + | `events.py` | No | Server-side Callosum event handlers (auto-discovered) | 56 58 | `app.json` | No | Icon, label, facet support overrides | 57 59 | `app_bar.html` | No | Bottom fixed bar for app controls | 58 60 | `background.html` | No | Background service (WebSocket listeners) | ··· 318 320 **Reference implementations:** 319 321 - Fixture patterns: `apps/todos/tests/conftest.py` 320 322 - Tool testing: `apps/todos/tests/test_tools.py` 323 + 324 + --- 325 + 326 + ### 10. `events.py` - Server-Side Event Handlers 327 + 328 + Define server-side handlers that react to Callosum events. Handlers run in Convey's thread pool, enabling reactive backend logic without creating new services. 329 + 330 + **Key Points:** 331 + - Create `events.py` with functions decorated with `@on_event(tract, event)` 332 + - Handlers receive an `EventContext` with message data and app context 333 + - Discovered at Convey startup; events processed serially with 30s timeout per handler 334 + - Errors are logged but don't affect other handlers or the web server 335 + - Wildcards supported: `@on_event("*", "*")` matches all events 336 + 337 + **Reference implementations:** 338 + - Framework: `apps/events.py` - `EventContext` dataclass, decorator, discovery 339 + - Example: `apps/dev/events.py` - Debug handler showing usage pattern 321 340 322 341 --- 323 342
+4 -2
docs/CALLOSUM.md
··· 104 104 **Client Library:** `think/callosum.py` `CallosumConnection` class 105 105 **Server:** `think/callosum.py` `CallosumServer` class 106 106 107 - **Convey Integration:** `convey.emit()` for non-blocking event emission from route handlers (uses shared bridge connection). See [APPS.md](APPS.md) for usage. 107 + **Convey Integration:** 108 + - `convey.emit()` - Non-blocking event emission from route handlers (uses shared bridge connection) 109 + - `apps.events` - Server-side event handlers via `@on_event` decorator (dispatched in thread pool) 108 110 109 - See code documentation for usage patterns and examples. 111 + See [APPS.md](APPS.md) for app event handler documentation and code for usage patterns.
+305
tests/test_app_events.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for the app event handling framework.""" 5 + 6 + import threading 7 + import time 8 + from unittest.mock import patch 9 + 10 + import pytest 11 + 12 + from apps.events import ( 13 + EventContext, 14 + _get_handlers, 15 + _handlers, 16 + clear_handlers, 17 + discover_handlers, 18 + dispatch, 19 + get_handler_count, 20 + on_event, 21 + start_dispatcher, 22 + stop_dispatcher, 23 + ) 24 + 25 + 26 + @pytest.fixture(autouse=True) 27 + def clean_handlers(): 28 + """Clear handlers before and after each test.""" 29 + clear_handlers() 30 + yield 31 + clear_handlers() 32 + stop_dispatcher() 33 + 34 + 35 + class TestOnEventDecorator: 36 + """Tests for the @on_event decorator.""" 37 + 38 + def test_registers_handler(self): 39 + """Decorator registers handler in global registry.""" 40 + 41 + @on_event("test", "event") 42 + def handler(ctx): 43 + pass 44 + 45 + assert ("test", "event") in _handlers 46 + assert len(_handlers[("test", "event")]) == 1 47 + assert _handlers[("test", "event")][0][1] is handler 48 + 49 + def test_multiple_handlers_same_event(self): 50 + """Multiple handlers can register for same event.""" 51 + 52 + @on_event("test", "event") 53 + def handler1(ctx): 54 + pass 55 + 56 + @on_event("test", "event") 57 + def handler2(ctx): 58 + pass 59 + 60 + assert len(_handlers[("test", "event")]) == 2 61 + 62 + def test_wildcard_registration(self): 63 + """Wildcard patterns register correctly.""" 64 + 65 + @on_event("*", "*") 66 + def catch_all(ctx): 67 + pass 68 + 69 + @on_event("observe", "*") 70 + def observe_all(ctx): 71 + pass 72 + 73 + @on_event("*", "finish") 74 + def all_finish(ctx): 75 + pass 76 + 77 + assert ("*", "*") in _handlers 78 + assert ("observe", "*") in _handlers 79 + assert ("*", "finish") in _handlers 80 + 81 + def test_decorator_returns_original_function(self): 82 + """Decorator returns the original function unchanged.""" 83 + 84 + def original(ctx): 85 + return "result" 86 + 87 + decorated = on_event("test", "event")(original) 88 + assert decorated is original 89 + 90 + 91 + class TestGetHandlers: 92 + """Tests for handler matching logic.""" 93 + 94 + def test_exact_match(self): 95 + """Exact tract/event match returns handler.""" 96 + 97 + @on_event("observe", "observed") 98 + def handler(ctx): 99 + pass 100 + 101 + handlers = _get_handlers({"tract": "observe", "event": "observed"}) 102 + assert len(handlers) == 1 103 + assert handlers[0][1] is handler 104 + 105 + def test_no_match(self): 106 + """Non-matching event returns empty list.""" 107 + 108 + @on_event("observe", "observed") 109 + def handler(ctx): 110 + pass 111 + 112 + handlers = _get_handlers({"tract": "cortex", "event": "finish"}) 113 + assert len(handlers) == 0 114 + 115 + def test_wildcard_all(self): 116 + """Wildcard (*,*) matches all events.""" 117 + 118 + @on_event("*", "*") 119 + def catch_all(ctx): 120 + pass 121 + 122 + handlers = _get_handlers({"tract": "anything", "event": "whatever"}) 123 + assert len(handlers) == 1 124 + 125 + def test_wildcard_tract(self): 126 + """Wildcard (tract,*) matches all events in tract.""" 127 + 128 + @on_event("observe", "*") 129 + def observe_all(ctx): 130 + pass 131 + 132 + handlers = _get_handlers({"tract": "observe", "event": "detected"}) 133 + assert len(handlers) == 1 134 + 135 + handlers = _get_handlers({"tract": "cortex", "event": "detected"}) 136 + assert len(handlers) == 0 137 + 138 + def test_wildcard_event(self): 139 + """Wildcard (*,event) matches event across tracts.""" 140 + 141 + @on_event("*", "finish") 142 + def all_finish(ctx): 143 + pass 144 + 145 + handlers = _get_handlers({"tract": "cortex", "event": "finish"}) 146 + assert len(handlers) == 1 147 + 148 + handlers = _get_handlers({"tract": "cortex", "event": "start"}) 149 + assert len(handlers) == 0 150 + 151 + def test_multiple_matches(self): 152 + """Multiple matching handlers are all returned.""" 153 + 154 + @on_event("observe", "observed") 155 + def exact(ctx): 156 + pass 157 + 158 + @on_event("observe", "*") 159 + def tract_wild(ctx): 160 + pass 161 + 162 + @on_event("*", "observed") 163 + def event_wild(ctx): 164 + pass 165 + 166 + @on_event("*", "*") 167 + def catch_all(ctx): 168 + pass 169 + 170 + handlers = _get_handlers({"tract": "observe", "event": "observed"}) 171 + assert len(handlers) == 4 172 + 173 + 174 + class TestDispatch: 175 + """Tests for event dispatch.""" 176 + 177 + def test_dispatch_without_executor_returns_zero(self): 178 + """Dispatch without starting executor returns 0.""" 179 + 180 + @on_event("test", "event") 181 + def handler(ctx): 182 + pass 183 + 184 + count = dispatch({"tract": "test", "event": "event"}) 185 + assert count == 0 186 + 187 + def test_dispatch_calls_handler(self): 188 + """Dispatch invokes matching handlers.""" 189 + called = threading.Event() 190 + received_ctx = {} 191 + 192 + @on_event("test", "event") 193 + def handler(ctx): 194 + received_ctx["msg"] = ctx.msg 195 + received_ctx["app"] = ctx.app 196 + received_ctx["tract"] = ctx.tract 197 + received_ctx["event"] = ctx.event 198 + called.set() 199 + 200 + start_dispatcher(workers=1) 201 + count = dispatch({"tract": "test", "event": "event", "data": "value"}) 202 + 203 + assert count == 1 204 + assert called.wait(timeout=2.0) 205 + assert received_ctx["msg"]["data"] == "value" 206 + assert received_ctx["tract"] == "test" 207 + assert received_ctx["event"] == "event" 208 + 209 + def test_dispatch_handles_exception(self): 210 + """Handler exceptions are caught and logged.""" 211 + success_called = threading.Event() 212 + 213 + @on_event("test", "event") 214 + def failing_handler(ctx): 215 + raise ValueError("Test error") 216 + 217 + @on_event("test", "event") 218 + def success_handler(ctx): 219 + success_called.set() 220 + 221 + start_dispatcher(workers=2) 222 + count = dispatch({"tract": "test", "event": "event"}) 223 + 224 + assert count == 2 225 + # Second handler should still run despite first failing 226 + assert success_called.wait(timeout=2.0) 227 + 228 + def test_dispatch_with_journal_root(self): 229 + """Dispatch passes journal root from environment.""" 230 + received_root = {} 231 + 232 + @on_event("test", "event") 233 + def handler(ctx): 234 + received_root["value"] = ctx.journal_root 235 + 236 + start_dispatcher(workers=1) 237 + 238 + with patch.dict("os.environ", {"JOURNAL_PATH": "/test/journal"}): 239 + dispatch({"tract": "test", "event": "event"}) 240 + time.sleep(0.1) 241 + 242 + assert received_root["value"] == "/test/journal" 243 + 244 + 245 + class TestDiscovery: 246 + """Tests for handler discovery.""" 247 + 248 + def test_discover_returns_count(self): 249 + """Discovery runs without error and finds at least the dev app.""" 250 + # Full mocking of importlib for isolated discovery is complex, 251 + # so we verify the function works on the real apps dir 252 + count = discover_handlers() 253 + # Should find at least the dev app 254 + assert count >= 0 255 + 256 + 257 + class TestEventContext: 258 + """Tests for EventContext dataclass.""" 259 + 260 + def test_context_fields(self): 261 + """EventContext has expected fields.""" 262 + ctx = EventContext( 263 + msg={"tract": "test", "event": "event", "data": "value"}, 264 + app="test_app", 265 + tract="test", 266 + event="event", 267 + journal_root="/path/to/journal", 268 + ) 269 + 270 + assert ctx.msg["data"] == "value" 271 + assert ctx.app == "test_app" 272 + assert ctx.tract == "test" 273 + assert ctx.event == "event" 274 + assert ctx.journal_root == "/path/to/journal" 275 + 276 + 277 + class TestDispatcherLifecycle: 278 + """Tests for dispatcher start/stop.""" 279 + 280 + def test_start_stop_dispatcher(self): 281 + """Dispatcher can be started and stopped.""" 282 + start_dispatcher(workers=2) 283 + # Should be idempotent 284 + start_dispatcher(workers=2) 285 + 286 + stop_dispatcher() 287 + # Should be idempotent 288 + stop_dispatcher() 289 + 290 + def test_get_handler_count(self): 291 + """get_handler_count returns total handlers.""" 292 + 293 + @on_event("a", "b") 294 + def h1(ctx): 295 + pass 296 + 297 + @on_event("c", "d") 298 + def h2(ctx): 299 + pass 300 + 301 + @on_event("a", "b") 302 + def h3(ctx): 303 + pass 304 + 305 + assert get_handler_count() == 3