Files
CoreRasurae 00881a3e5f
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 3m26s
CI / build (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 4m2s
CI / quality (pull_request) Successful in 4m2s
CI / security (pull_request) Successful in 4m23s
CI / integration_tests (pull_request) Successful in 9m35s
CI / unit_tests (pull_request) Successful in 9m39s
CI / docker (pull_request) Successful in 2m15s
CI / e2e_tests (pull_request) Successful in 12m32s
CI / coverage (pull_request) Successful in 11m41s
CI / status-check (pull_request) Successful in 1s
CI / build (push) Successful in 23s
CI / lint (push) Successful in 3m17s
CI / typecheck (push) Successful in 4m11s
CI / quality (push) Successful in 4m11s
CI / security (push) Successful in 4m28s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 9m13s
CI / unit_tests (push) Successful in 9m26s
CI / e2e_tests (push) Successful in 10m20s
CI / docker (push) Successful in 1m16s
CI / coverage (push) Successful in 12m2s
CI / status-check (push) Successful in 1s
CI / benchmark-publish (push) Successful in 33m17s
CI / benchmark-regression (pull_request) Successful in 57m23s
feat(observability): implement Event System Domain Event Taxonomy (full EventType enum + DomainEvent model)
Verified and completed the Event System Domain Event Taxonomy implementation.

Gap analysis:
- EventType enum (38 types across 12 domains): ALREADY EXISTED, verified complete
- DomainEvent Pydantic model (all 9 fields): ALREADY EXISTED, verified complete
- EventBus Protocol (emit + subscribe): ALREADY EXISTED, verified complete
- ReactiveEventBus (RxPY Subject, stream property): ALREADY EXISTED, verified
- LoggingEventBus (structlog-based): ALREADY EXISTED, verified complete

Added:
- In-memory audit_log on ReactiveEventBus (list[DomainEvent] with defensive copy)
- Emit ordering aligned with specification: RxPY stream push, then audit append,
  then handler dispatch (§Event System)
- Clarified audit_log docstring: volatile in-memory log, not durable SQLite
  persistence (durable persistence wired separately via audit service layer)
- Behave feature: features/observability/event_system_taxonomy.feature (36 scenarios)
- Step definitions: features/steps/event_system_taxonomy_steps.py
- Robot integration test: robot/event_system_taxonomy_integration.robot (13 test cases)
- Robot helper: robot/helper_event_system_taxonomy.py (13 subcommands)
- ASV benchmark: benchmarks/bench_event_bus.py (5 benchmark suites)
- vulture_whitelist.py: added audit_log property

Code review fixes applied:
- Reordered emit() to match spec: on_next → audit_log → handlers (was: audit → on_next → handlers)
- Clarified audit_log docstring to distinguish volatile in-memory log from spec SQLite table
- Fixed benchmark TaxonomyAuditLogSuite: parameterized setup instead of inline construction
- Added teardown to TaxonomyLoggingEmitSuite to restore logging.disable(NOTSET)
- Clear _received list between benchmark iterations to reduce noise
- Catch specific pydantic.ValidationError in frozen model mutation tests
- Consolidated duplicate singular/plural step definitions
- Tightened EventType count threshold from >=30 to >=38

Quality gates:
- lint: PASSED
- typecheck: PASSED (0 errors)
- unit_tests: 36/36 new scenarios PASSED (pre-existing 12 flaky failures unchanged)
- integration_tests: 1483/1483 PASSED
- security_scan: PASSED
- dead_code: PASSED

ISSUES CLOSED: #587
2026-03-26 11:52:52 +00:00

222 lines
6.7 KiB
Markdown

# Event Bus
The **EventBus** infrastructure provides a general-purpose domain event system
for CleverAgents. Domain services emit typed events at significant state changes
(plan creation, decision recording, phase transitions, etc.), enabling audit
logging, reactive subscribers, and observability without coupling services to
one another.
---
## Overview
The system consists of four cooperating components:
| Component | Role |
|-----------|------|
| `EventType` | `StrEnum` of all domain event identifiers |
| `DomainEvent` | Frozen Pydantic model carrying event data |
| `EventBus` | `Protocol` that all bus implementations satisfy |
| `ReactiveEventBus` | RxPY-backed in-process bus (default) |
| `LoggingEventBus` | Structured-log-only bus (audit / test) |
---
## EventType
`cleveragents.infrastructure.events.EventType` is a `StrEnum` whose members
cover all domains:
```python
from cleveragents.infrastructure.events import EventType
print(EventType.PLAN_CREATED) # "plan.created"
print(EventType.DECISION_CREATED) # "decision.created"
print(EventType.ACTOR_COMPLETED) # "actor.completed"
```
**Categories:**
| Category | Examples |
|----------|---------|
| Plan lifecycle | `plan.created`, `plan.phase_changed`, `plan.applied` |
| Decision | `decision.created`, `decision.superseded` |
| Actor | `actor.invoked`, `actor.completed`, `actor.errored` |
| Tool | `tool.invoked`, `tool.completed`, `tool.errored` |
| Resource | `resource.accessed`, `resource.modified` |
| Sandbox | `sandbox.created`, `checkpoint.restored` |
| Validation | `validation.started`, `validation.passed` |
| Session | `session.created`, `session.message_sent` |
| Budget | `budget.warning`, `budget.exceeded` |
---
## DomainEvent
All events are instances of the frozen Pydantic model
`cleveragents.infrastructure.events.DomainEvent`:
```python
from cleveragents.infrastructure.events import DomainEvent, EventType
event = DomainEvent(
event_type=EventType.PLAN_CREATED,
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
actor_name="strategists/planner",
details={"phase": "strategize"},
)
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `event_type` | `EventType` | required | The type of event |
| `timestamp` | `datetime` | `now(UTC)` | UTC time of emission |
| `correlation_id` | `str` | auto ULID | Links related events in a request chain |
| `plan_id` | `str \| None` | `None` | Associated plan ULID |
| `root_plan_id` | `str \| None` | `None` | Root plan in a hierarchy |
| `session_id` | `str \| None` | `None` | Associated session ULID |
| `actor_name` | `str \| None` | `None` | Actor that emitted the event |
| `project_name` | `str \| None` | `None` | Project context |
| `details` | `dict` | `{}` | Event-type-specific payload |
`DomainEvent` is **immutable** (`frozen=True`). Attempting to set an attribute
after construction raises a `ValidationError`.
---
## EventBus Protocol
`cleveragents.infrastructure.events.EventBus` is a `@runtime_checkable`
structural protocol. Any object with `emit()` and `subscribe()` methods
satisfies it:
```python
from cleveragents.infrastructure.events import EventBus, ReactiveEventBus
bus = ReactiveEventBus(max_audit_log_size=5000) # optional retention cap
assert isinstance(bus, EventBus) # True
```
**Interface:**
```python
class EventBus(Protocol):
def emit(self, event: DomainEvent) -> None: ...
def subscribe(self, event_type: EventType,
handler: Callable[[DomainEvent], None]) -> None: ...
```
---
## ReactiveEventBus
`cleveragents.infrastructure.events.ReactiveEventBus` is the default
in-process implementation. It uses an RxPY `Subject` as its hot observable
backbone.
```python
from cleveragents.infrastructure.events import (
DomainEvent, EventType, ReactiveEventBus,
)
bus = ReactiveEventBus()
# Subscribe a plain callback
bus.subscribe(EventType.PLAN_CREATED, lambda e: print("Plan created:", e.plan_id))
# Use the read-only RxPY stream for advanced operators
bus.stream.pipe(
rx.operators.filter(lambda e: e.plan_id is not None),
).subscribe(lambda e: print("Plan event:", e))
# Emit an event
bus.emit(DomainEvent(event_type=EventType.PLAN_CREATED, plan_id="01ABC..."))
```
- `emit(event)` pushes to the RxPY Subject **and** calls all type-specific
handlers synchronously.
- `subscribe(event_type, handler)` registers a callback for one event type.
- `stream` exposes a read-only `rx.Observable` view for RxPY operators.
- `clear_audit_log()` clears retained in-memory events when needed.
---
## LoggingEventBus
`cleveragents.infrastructure.events.LoggingEventBus` logs every event to
`structlog` at `INFO` level. It is useful for audit trails, CI pipelines, and
environments where RxPY overhead is undesirable.
```python
from cleveragents.infrastructure.events import LoggingEventBus, EventType, DomainEvent
bus = LoggingEventBus()
bus.emit(DomainEvent(event_type=EventType.DECISION_CREATED, plan_id="01ABC..."))
# → structured log: {"event": "domain_event", "event_type": "decision.created", ...}
```
---
## Dependency Injection
The DI container (`cleveragents.application.container.Container`) registers
`ReactiveEventBus` as a **Singleton** provider named `event_bus`. It is
automatically wired into `DecisionService` and `PlanLifecycleService`:
```python
from cleveragents.application.container import Container
container = Container()
bus = container.event_bus() # ReactiveEventBus singleton
```
To substitute a different implementation (e.g. in tests):
```python
from cleveragents.application.container import Container
from cleveragents.infrastructure.events import LoggingEventBus
from dependency_injector import providers
container = Container()
container.event_bus.override(providers.Singleton(LoggingEventBus))
```
---
## Service Event Emission
### DecisionService
`cleveragents.application.services.decision_service.DecisionService.record_decision`
emits **`DECISION_CREATED`** after a decision is persisted.
### PlanLifecycleService
| Method | Event emitted |
|--------|---------------|
| `use_action(...)` | `PLAN_CREATED` |
| `execute_plan(...)` | `PLAN_PHASE_CHANGED` |
Both services accept an optional `event_bus` constructor parameter; when
`None`, event emission is silently skipped (backward-compatible).
---
## Example: Subscribing to Plan Events
```python
from cleveragents.application.container import Container
from cleveragents.infrastructure.events import EventType, DomainEvent
container = Container()
bus = container.event_bus()
def on_plan_created(event: DomainEvent) -> None:
print(f"New plan: {event.plan_id} | details: {event.details}")
bus.subscribe(EventType.PLAN_CREATED, on_plan_created)
# All subsequent plan.create operations will call on_plan_created
svc = container.plan_lifecycle_service()
```