Coverage for little_loops / events.py: 39%
72 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:20 -0500
1"""Event system for little-loops extension architecture.
3Provides structured event types and a multi-observer event bus that extensions
4can subscribe to for receiving lifecycle events from FSM loops, issue management,
5and automation workflows.
7Public exports:
8 LLEvent: Structured event dataclass with type, timestamp, and payload
9 EventBus: Multi-observer event dispatcher with optional JSONL file sink
10"""
12from __future__ import annotations
14import fnmatch
15import json
16import logging
17from collections.abc import Callable
18from dataclasses import dataclass, field
19from pathlib import Path
20from typing import Any
22logger = logging.getLogger(__name__)
24# Type for event callback (matches existing EventCallback in fsm/executor.py)
25EventCallback = Callable[[dict[str, Any]], None]
28@dataclass
29class LLEvent:
30 """Structured event emitted by little-loops subsystems.
32 Attributes:
33 type: Event type identifier (e.g. "fsm.state_enter", "fsm.loop_complete")
34 timestamp: ISO 8601 timestamp string
35 payload: Type-specific event data
36 """
38 type: str
39 timestamp: str
40 payload: dict[str, Any] = field(default_factory=dict)
42 def to_dict(self) -> dict[str, Any]:
43 """Convert to dictionary for JSON serialization.
45 Produces a flat dict compatible with the existing FSM event format:
46 {"event": type, "ts": timestamp, ...payload}
47 """
48 return {"event": self.type, "ts": self.timestamp, **self.payload}
50 @classmethod
51 def from_dict(cls, data: dict[str, Any]) -> LLEvent:
52 """Create from dictionary (JSON deserialization).
54 Accepts the flat dict format: {"event": ..., "ts": ..., ...payload}
55 """
56 copy = dict(data)
57 event_type = copy.pop("event", copy.pop("type", "unknown"))
58 ts = copy.pop("ts", copy.pop("timestamp", ""))
59 return cls(type=event_type, timestamp=ts, payload=copy)
61 @classmethod
62 def from_raw_event(cls, raw: dict[str, Any]) -> LLEvent:
63 """Convert existing executor event dict to LLEvent without mutating the original."""
64 return cls.from_dict(dict(raw))
67class EventBus:
68 """Multi-observer event dispatcher.
70 Dispatches event dicts to all registered observers. Exceptions in individual
71 observers are caught and logged, not propagated.
72 """
74 def __init__(self) -> None:
75 self._observers: list[tuple[EventCallback, list[str] | None]] = []
76 self._file_sinks: list[Path] = []
78 def register(self, callback: EventCallback, filter: str | list[str] | None = None) -> None:
79 """Register an observer to receive events.
81 Args:
82 callback: Callable that receives raw event dicts.
83 filter: Optional glob pattern(s) to match against the event's ``"event"`` key.
84 A single string (e.g. ``"issue.*"``) or a list of strings
85 (e.g. ``["issue.*", "parallel.*"]``). ``None`` (default) means
86 the observer receives every event — preserving existing behaviour.
87 FSM executor events use bare names (``"state_enter"``, ``"loop_*"``);
88 other subsystems use dotted namespaces (``"issue.*"``, ``"parallel.*"``).
89 """
90 patterns: list[str] | None = None
91 if filter is not None:
92 patterns = [filter] if isinstance(filter, str) else list(filter)
93 self._observers.append((callback, patterns))
95 def unregister(self, callback: EventCallback) -> None:
96 """Remove an observer. No-op if not registered."""
97 for i, (cb, _) in enumerate(self._observers):
98 if cb is callback:
99 del self._observers[i]
100 return
102 def add_file_sink(self, path: Path) -> None:
103 """Add a JSONL file sink. Events will be appended to this file."""
104 path.parent.mkdir(parents=True, exist_ok=True)
105 self._file_sinks.append(path)
107 def emit(self, event: dict[str, Any]) -> None:
108 """Dispatch event to all observers and file sinks.
110 Observer exceptions are caught and logged to prevent one observer
111 from blocking others.
112 """
113 event_type = event.get("event", "")
114 for observer, filter_patterns in self._observers:
115 if filter_patterns is not None and not any(
116 fnmatch.fnmatch(event_type, p) for p in filter_patterns
117 ):
118 continue
119 try:
120 observer(event)
121 except Exception:
122 logger.warning("EventBus observer raised an exception", exc_info=True)
124 for sink_path in self._file_sinks:
125 try:
126 with open(sink_path, "a", encoding="utf-8") as f:
127 f.write(json.dumps(event) + "\n")
128 except Exception:
129 logger.warning("EventBus file sink write failed: %s", sink_path, exc_info=True)
131 @staticmethod
132 def read_events(path: Path) -> list[LLEvent]:
133 """Read events from a JSONL file.
135 Returns:
136 List of LLEvent instances, empty if file doesn't exist.
137 Malformed lines are silently skipped.
138 """
139 if not path.exists():
140 return []
141 events: list[LLEvent] = []
142 with open(path, encoding="utf-8") as f:
143 for line in f:
144 line = line.strip()
145 if line:
146 try:
147 events.append(LLEvent.from_dict(json.loads(line)))
148 except json.JSONDecodeError:
149 continue
150 return events