omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. omnibase_infra/constants_topic_patterns.py +26 -0
  2. omnibase_infra/enums/__init__.py +3 -0
  3. omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
  4. omnibase_infra/enums/enum_handler_source_mode.py +16 -2
  5. omnibase_infra/errors/__init__.py +4 -0
  6. omnibase_infra/errors/error_binding_resolution.py +128 -0
  7. omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
  8. omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
  9. omnibase_infra/event_bus/event_bus_kafka.py +105 -47
  10. omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
  11. omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
  12. omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
  13. omnibase_infra/event_bus/testing/__init__.py +26 -0
  14. omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
  15. omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
  16. omnibase_infra/handlers/handler_consul.py +2 -0
  17. omnibase_infra/handlers/mixins/__init__.py +5 -0
  18. omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
  19. omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
  20. omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
  21. omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
  22. omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
  23. omnibase_infra/mixins/mixin_node_introspection.py +189 -19
  24. omnibase_infra/models/__init__.py +8 -0
  25. omnibase_infra/models/bindings/__init__.py +59 -0
  26. omnibase_infra/models/bindings/constants.py +144 -0
  27. omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
  28. omnibase_infra/models/bindings/model_operation_binding.py +44 -0
  29. omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
  30. omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
  31. omnibase_infra/models/discovery/model_introspection_config.py +25 -17
  32. omnibase_infra/models/dispatch/__init__.py +8 -0
  33. omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
  34. omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
  35. omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
  36. omnibase_infra/models/model_node_identity.py +126 -0
  37. omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
  38. omnibase_infra/models/registration/__init__.py +9 -0
  39. omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
  40. omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
  41. omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
  42. omnibase_infra/models/runtime/__init__.py +9 -0
  43. omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
  44. omnibase_infra/nodes/__init__.py +9 -0
  45. omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
  46. omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
  47. omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
  48. omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
  49. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
  50. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
  51. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
  52. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
  53. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
  54. omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
  55. omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
  56. omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
  57. omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
  58. omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
  59. omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
  60. omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
  61. omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
  62. omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
  63. omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
  64. omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
  65. omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
  66. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
  67. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
  68. omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
  69. omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
  70. omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
  71. omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
  72. omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
  73. omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
  74. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
  75. omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
  76. omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
  77. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
  78. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
  79. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
  80. omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
  81. omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
  82. omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
  83. omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
  84. omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
  85. omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
  86. omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
  87. omnibase_infra/nodes/reducers/models/__init__.py +7 -2
  88. omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
  89. omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
  90. omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
  91. omnibase_infra/protocols/__init__.py +3 -0
  92. omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
  93. omnibase_infra/runtime/__init__.py +60 -0
  94. omnibase_infra/runtime/binding_resolver.py +753 -0
  95. omnibase_infra/runtime/constants_security.py +70 -0
  96. omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
  97. omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
  98. omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
  99. omnibase_infra/runtime/emit_daemon/cli.py +844 -0
  100. omnibase_infra/runtime/emit_daemon/client.py +811 -0
  101. omnibase_infra/runtime/emit_daemon/config.py +535 -0
  102. omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
  103. omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
  104. omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
  105. omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
  106. omnibase_infra/runtime/emit_daemon/queue.py +618 -0
  107. omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
  108. omnibase_infra/runtime/handler_source_resolver.py +43 -2
  109. omnibase_infra/runtime/kafka_contract_source.py +984 -0
  110. omnibase_infra/runtime/models/__init__.py +13 -0
  111. omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
  112. omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
  113. omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
  114. omnibase_infra/runtime/models/model_security_config.py +109 -0
  115. omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
  116. omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
  117. omnibase_infra/runtime/service_kernel.py +76 -6
  118. omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
  119. omnibase_infra/runtime/service_runtime_host_process.py +770 -20
  120. omnibase_infra/runtime/transition_notification_publisher.py +3 -2
  121. omnibase_infra/runtime/util_wiring.py +206 -62
  122. omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
  123. omnibase_infra/services/session/config_consumer.py +25 -8
  124. omnibase_infra/services/session/config_store.py +2 -2
  125. omnibase_infra/services/session/consumer.py +1 -1
  126. omnibase_infra/topics/__init__.py +45 -0
  127. omnibase_infra/topics/platform_topic_suffixes.py +140 -0
  128. omnibase_infra/topics/util_topic_composition.py +95 -0
  129. omnibase_infra/types/typed_dict/__init__.py +9 -1
  130. omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
  131. omnibase_infra/utils/__init__.py +9 -0
  132. omnibase_infra/utils/util_consumer_group.py +232 -0
  133. omnibase_infra/validation/infra_validators.py +18 -1
  134. omnibase_infra/validation/validation_exemptions.yaml +192 -0
  135. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
  136. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
  137. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
  138. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
  139. {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,811 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Emit Daemon Client - Python client for emitting events via the daemon.
4
+
5
+ This module provides the EmitClient class for communicating with the EmitDaemon
6
+ via Unix socket. It offers fire-and-forget semantics where the client returns
7
+ as soon as the daemon acknowledges the event has been queued.
8
+
9
+ Features:
10
+ - Async and sync interfaces for flexibility
11
+ - Automatic connection management
12
+ - Timeout handling with configurable limits
13
+ - Health check (ping) support
14
+ - Graceful degradation with fallback callback
15
+
16
+ Protocol:
17
+ Request: {"event_type": "...", "payload": {...}}\\n
18
+ Response: {"status": "queued", "event_id": "..."}\\n or {"status": "error", "reason": "..."}\\n
19
+ Ping: {"command": "ping"}\\n -> {"status": "ok", "queue_size": N, "spool_size": M}\\n
20
+
21
+ Example:
22
+ ```python
23
+ from omnibase_infra.runtime.emit_daemon.client import EmitClient
24
+
25
+ # Async usage
26
+ async with EmitClient() as client:
27
+ event_id = await client.emit("prompt.submitted", {"prompt_id": "abc123"})
28
+
29
+ # Sync usage (for scripts)
30
+ client = EmitClient()
31
+ event_id = client.emit_sync("prompt.submitted", {"prompt_id": "abc123"})
32
+
33
+ # Convenience function
34
+ event_id = await emit_event("prompt.submitted", {"prompt_id": "abc123"})
35
+ ```
36
+
37
+ Related Tickets:
38
+ - OMN-1610: Hook Event Daemon MVP
39
+
40
+ .. versionadded:: 0.2.6
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import asyncio
46
+ import json
47
+ import logging
48
+ from collections.abc import Awaitable, Callable, Coroutine
49
+ from pathlib import Path
50
+ from types import TracebackType
51
+ from typing import TypeVar
52
+
53
+ from pydantic import ValidationError
54
+
55
+ from omnibase_core.enums import EnumCoreErrorCode
56
+ from omnibase_core.errors import OnexError
57
+ from omnibase_core.types import JsonType
58
+ from omnibase_infra.runtime.emit_daemon.model_daemon_request import (
59
+ ModelDaemonEmitRequest,
60
+ ModelDaemonPingRequest,
61
+ )
62
+ from omnibase_infra.runtime.emit_daemon.model_daemon_response import (
63
+ ModelDaemonErrorResponse,
64
+ ModelDaemonPingResponse,
65
+ ModelDaemonQueuedResponse,
66
+ parse_daemon_response,
67
+ )
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+ # Type variable for generic async runner
72
+ _T = TypeVar("_T")
73
+
74
+
75
+ class EmitClientError(OnexError):
76
+ """Error communicating with emit daemon.
77
+
78
+ Raised when the client cannot connect to the daemon, the daemon rejects
79
+ the event, or a timeout occurs during communication.
80
+
81
+ Inherits from OnexError for consistent error handling across the ONEX platform.
82
+
83
+ Attributes:
84
+ reason: Optional detailed reason for the error (from daemon response).
85
+
86
+ Example:
87
+ ```python
88
+ try:
89
+ await client.emit("event.type", {"data": "value"})
90
+ except EmitClientError as e:
91
+ print(f"Failed to emit: {e}")
92
+ if e.reason:
93
+ print(f"Reason: {e.reason}")
94
+ ```
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ message: str,
100
+ reason: str | None = None,
101
+ error_code: EnumCoreErrorCode = EnumCoreErrorCode.OPERATION_FAILED,
102
+ ) -> None:
103
+ """Initialize the error with a message and optional reason.
104
+
105
+ Args:
106
+ message: Human-readable error message
107
+ reason: Optional detailed reason from daemon response
108
+ error_code: Error code from EnumCoreErrorCode, defaults to OPERATION_FAILED
109
+ """
110
+ super().__init__(message=message, error_code=error_code)
111
+ self.reason = reason
112
+
113
+
114
+ class EmitClient:
115
+ """Client for emitting events via the emit daemon.
116
+
117
+ Connects to the daemon's Unix socket and sends events with fire-and-forget
118
+ semantics. The client returns as soon as the daemon acknowledges the event
119
+ has been queued for Kafka publishing.
120
+
121
+ The client supports both async context manager usage for connection pooling
122
+ and standalone usage where each operation creates a new connection.
123
+
124
+ Attributes:
125
+ socket_path: Path to the daemon's Unix socket
126
+ timeout: Timeout in seconds for socket operations
127
+
128
+ Example:
129
+ ```python
130
+ # Context manager usage (recommended for multiple operations)
131
+ async with EmitClient() as client:
132
+ await client.emit("event.one", {"data": "1"})
133
+ await client.emit("event.two", {"data": "2"})
134
+
135
+ # Standalone usage (creates new connection per operation)
136
+ client = EmitClient()
137
+ await client.emit("event.single", {"data": "value"})
138
+
139
+ # Custom socket path and timeout
140
+ client = EmitClient(
141
+ socket_path=Path("/custom/emit.sock"),
142
+ timeout=10.0,
143
+ )
144
+ ```
145
+ """
146
+
147
+ # Default socket path matching daemon config default
148
+ # NOTE: /tmp is standard for Unix domain sockets - not a security issue
149
+ DEFAULT_SOCKET_PATH: Path = Path("/tmp/omniclaude-emit.sock") # noqa: S108
150
+
151
+ # Default timeout in seconds
152
+ DEFAULT_TIMEOUT: float = 5.0
153
+
154
+ def __init__(
155
+ self,
156
+ socket_path: Path | str = DEFAULT_SOCKET_PATH,
157
+ timeout: float = DEFAULT_TIMEOUT,
158
+ ) -> None:
159
+ """Initialize client with socket path and timeout.
160
+
161
+ Args:
162
+ socket_path: Path to the daemon's Unix socket. Defaults to
163
+ /tmp/omniclaude-emit.sock to match daemon default.
164
+ timeout: Timeout in seconds for socket operations. Defaults to 5.0.
165
+
166
+ Example:
167
+ ```python
168
+ # Use defaults
169
+ client = EmitClient()
170
+
171
+ # Custom configuration
172
+ client = EmitClient(
173
+ socket_path="/var/run/emit.sock",
174
+ timeout=10.0,
175
+ )
176
+ ```
177
+ """
178
+ self._socket_path = (
179
+ Path(socket_path) if isinstance(socket_path, str) else socket_path
180
+ )
181
+ self._timeout = timeout
182
+ self._reader: asyncio.StreamReader | None = None
183
+ self._writer: asyncio.StreamWriter | None = None
184
+ self._lock = asyncio.Lock()
185
+
186
+ logger.debug(
187
+ "EmitClient initialized",
188
+ extra={
189
+ "socket_path": str(self._socket_path),
190
+ "timeout": self._timeout,
191
+ },
192
+ )
193
+
194
+ @property
195
+ def socket_path(self) -> Path:
196
+ """Get the socket path.
197
+
198
+ Returns:
199
+ Path to the daemon's Unix socket.
200
+ """
201
+ return self._socket_path
202
+
203
+ @property
204
+ def timeout(self) -> float:
205
+ """Get the timeout value.
206
+
207
+ Returns:
208
+ Timeout in seconds for socket operations.
209
+ """
210
+ return self._timeout
211
+
212
+ async def __aenter__(self) -> EmitClient:
213
+ """Enter async context manager, establishing connection.
214
+
215
+ Returns:
216
+ Self for use in async with statement.
217
+
218
+ Raises:
219
+ EmitClientError: If connection to daemon fails.
220
+ """
221
+ await self._connect()
222
+ return self
223
+
224
+ async def __aexit__(
225
+ self,
226
+ exc_type: type[BaseException] | None,
227
+ exc_val: BaseException | None,
228
+ exc_tb: TracebackType | None,
229
+ ) -> None:
230
+ """Exit async context manager, closing connection.
231
+
232
+ Args:
233
+ exc_type: Exception type if an exception was raised
234
+ exc_val: Exception value if an exception was raised
235
+ exc_tb: Exception traceback if an exception was raised
236
+ """
237
+ await self._disconnect()
238
+
239
+ async def _connect_unlocked(self) -> None:
240
+ """Establish connection to daemon socket (without lock).
241
+
242
+ Internal method - caller must hold self._lock.
243
+
244
+ Raises:
245
+ EmitClientError: If connection fails (daemon not running, permission denied, etc.)
246
+ """
247
+ if self._writer is not None:
248
+ return # Already connected
249
+
250
+ try:
251
+ self._reader, self._writer = await asyncio.wait_for(
252
+ asyncio.open_unix_connection(str(self._socket_path)),
253
+ timeout=self._timeout,
254
+ )
255
+ logger.debug(f"Connected to emit daemon at {self._socket_path}")
256
+ except FileNotFoundError as e:
257
+ raise EmitClientError(
258
+ f"Emit daemon socket not found at {self._socket_path}. "
259
+ "Is the daemon running?",
260
+ reason="socket_not_found",
261
+ error_code=EnumCoreErrorCode.RESOURCE_NOT_FOUND,
262
+ ) from e
263
+ except PermissionError as e:
264
+ raise EmitClientError(
265
+ f"Permission denied accessing emit daemon socket at {self._socket_path}",
266
+ reason="permission_denied",
267
+ error_code=EnumCoreErrorCode.PERMISSION_DENIED,
268
+ ) from e
269
+ except ConnectionRefusedError as e:
270
+ raise EmitClientError(
271
+ f"Connection refused to emit daemon at {self._socket_path}. "
272
+ "Is the daemon running?",
273
+ reason="connection_refused",
274
+ error_code=EnumCoreErrorCode.SERVICE_UNAVAILABLE,
275
+ ) from e
276
+ except TimeoutError as e:
277
+ raise EmitClientError(
278
+ f"Timeout connecting to emit daemon at {self._socket_path}",
279
+ reason="connection_timeout",
280
+ error_code=EnumCoreErrorCode.TIMEOUT_ERROR,
281
+ ) from e
282
+ except OSError as e:
283
+ raise EmitClientError(
284
+ f"Failed to connect to emit daemon: {e}",
285
+ reason="os_error",
286
+ error_code=EnumCoreErrorCode.NETWORK_ERROR,
287
+ ) from e
288
+
289
+ async def _connect(self) -> None:
290
+ """Establish connection to daemon socket.
291
+
292
+ Internal method called by context manager or lazily on first operation.
293
+
294
+ Raises:
295
+ EmitClientError: If connection fails (daemon not running, permission denied, etc.)
296
+ """
297
+ async with self._lock:
298
+ await self._connect_unlocked()
299
+
300
+ async def _disconnect_unlocked(self) -> None:
301
+ """Close connection to daemon socket (without lock).
302
+
303
+ Internal method - caller must hold self._lock.
304
+ Safe to call multiple times.
305
+ """
306
+ if self._writer is not None:
307
+ try:
308
+ self._writer.close()
309
+ await self._writer.wait_closed()
310
+ except Exception as e:
311
+ logger.debug(f"Error during disconnect cleanup: {e}")
312
+ finally:
313
+ self._writer = None
314
+ self._reader = None
315
+ logger.debug("Disconnected from emit daemon")
316
+
317
+ async def _disconnect(self) -> None:
318
+ """Close connection to daemon socket.
319
+
320
+ Internal method called by context manager or cleanup operations.
321
+ Safe to call multiple times.
322
+ """
323
+ async with self._lock:
324
+ await self._disconnect_unlocked()
325
+
326
+ async def _send_request(self, request: JsonType) -> dict[str, object]:
327
+ """Send a request and receive response.
328
+
329
+ Internal method for protocol communication. Acquires lock to ensure
330
+ the write-then-read sequence is atomic, preventing response mixing
331
+ when multiple coroutines call emit() concurrently.
332
+
333
+ Args:
334
+ request: Request dict to send (will be JSON-encoded)
335
+
336
+ Returns:
337
+ Response dict from daemon
338
+
339
+ Raises:
340
+ EmitClientError: If communication fails
341
+ """
342
+ async with self._lock:
343
+ # Ensure we're connected (use unlocked variant since we hold the lock)
344
+ if self._writer is None or self._reader is None:
345
+ await self._connect_unlocked()
346
+
347
+ # Type guard - we just connected, so these should be set
348
+ if self._writer is None or self._reader is None:
349
+ raise EmitClientError(
350
+ "Failed to establish connection", reason="no_connection"
351
+ )
352
+
353
+ try:
354
+ # Send request
355
+ request_json = json.dumps(request) + "\n"
356
+ self._writer.write(request_json.encode("utf-8"))
357
+ await self._writer.drain()
358
+
359
+ # Receive response with timeout
360
+ response_line = await asyncio.wait_for(
361
+ self._reader.readline(),
362
+ timeout=self._timeout,
363
+ )
364
+
365
+ if not response_line:
366
+ # Connection closed by daemon
367
+ await self._disconnect_unlocked()
368
+ raise EmitClientError(
369
+ "Connection closed by emit daemon",
370
+ reason="connection_closed",
371
+ )
372
+
373
+ # Parse response
374
+ response = json.loads(response_line.decode("utf-8").strip())
375
+ if not isinstance(response, dict):
376
+ raise EmitClientError(
377
+ "Invalid response from daemon: expected JSON object",
378
+ reason="invalid_response",
379
+ )
380
+ return response
381
+
382
+ except TimeoutError as e:
383
+ await self._disconnect_unlocked()
384
+ raise EmitClientError(
385
+ f"Timeout waiting for daemon response (timeout={self._timeout}s)",
386
+ reason="response_timeout",
387
+ error_code=EnumCoreErrorCode.TIMEOUT_ERROR,
388
+ ) from e
389
+ except json.JSONDecodeError as e:
390
+ await self._disconnect_unlocked()
391
+ raise EmitClientError(
392
+ f"Invalid JSON response from daemon: {e}",
393
+ reason="invalid_json",
394
+ error_code=EnumCoreErrorCode.VALIDATION_ERROR,
395
+ ) from e
396
+ except ConnectionResetError as e:
397
+ await self._disconnect_unlocked()
398
+ raise EmitClientError(
399
+ "Connection reset by emit daemon",
400
+ reason="connection_reset",
401
+ error_code=EnumCoreErrorCode.NETWORK_ERROR,
402
+ ) from e
403
+ except BrokenPipeError as e:
404
+ await self._disconnect_unlocked()
405
+ raise EmitClientError(
406
+ "Broken pipe to emit daemon",
407
+ reason="broken_pipe",
408
+ error_code=EnumCoreErrorCode.NETWORK_ERROR,
409
+ ) from e
410
+
411
+ async def emit(
412
+ self,
413
+ event_type: str,
414
+ payload: JsonType,
415
+ ) -> str:
416
+ """Emit an event via the daemon.
417
+
418
+ Sends an event to the daemon for asynchronous publishing to Kafka.
419
+ Returns as soon as the daemon acknowledges the event has been queued.
420
+
421
+ Args:
422
+ event_type: Semantic event type (e.g., "prompt.submitted",
423
+ "tool.invoked"). Must be registered with the daemon's
424
+ EventRegistry.
425
+ payload: Event payload dict. Must contain required fields for
426
+ the event type as defined in EventRegistry.
427
+
428
+ Returns:
429
+ Event ID assigned by the daemon (UUID string). This can be used
430
+ for tracking/debugging but is not needed for normal operation.
431
+
432
+ Raises:
433
+ EmitClientError: If daemon is unavailable, rejects the event,
434
+ or a timeout occurs.
435
+
436
+ Example:
437
+ ```python
438
+ event_id = await client.emit(
439
+ "prompt.submitted",
440
+ {
441
+ "prompt_id": "abc123",
442
+ "session_id": "sess-456",
443
+ "prompt_text": "Hello, Claude!",
444
+ },
445
+ )
446
+ print(f"Event queued with ID: {event_id}")
447
+ ```
448
+ """
449
+ # Build typed request
450
+ request = ModelDaemonEmitRequest(event_type=event_type, payload=payload)
451
+ raw_response = await self._send_request(request.model_dump())
452
+
453
+ # Parse typed response
454
+ try:
455
+ response = parse_daemon_response(raw_response)
456
+ except (ValueError, ValidationError) as e:
457
+ raise EmitClientError(
458
+ f"Invalid daemon response: {e}",
459
+ reason="invalid_response",
460
+ ) from e
461
+
462
+ # Handle response by type
463
+ if isinstance(response, ModelDaemonQueuedResponse):
464
+ logger.debug(
465
+ f"Event emitted: {response.event_id}",
466
+ extra={
467
+ "event_type": event_type,
468
+ "event_id": response.event_id,
469
+ },
470
+ )
471
+ return response.event_id
472
+ elif isinstance(response, ModelDaemonErrorResponse):
473
+ raise EmitClientError(
474
+ f"Daemon rejected event: {response.reason}",
475
+ reason=response.reason,
476
+ )
477
+ elif isinstance(response, ModelDaemonPingResponse):
478
+ # Unexpected ping response to emit request
479
+ raise EmitClientError(
480
+ "Unexpected ping response to emit request",
481
+ reason="unexpected_response_type",
482
+ )
483
+ else:
484
+ # Should be unreachable
485
+ raise EmitClientError(
486
+ "Unexpected daemon response type",
487
+ reason="unexpected_status",
488
+ )
489
+
490
+ async def ping(self) -> ModelDaemonPingResponse:
491
+ """Health check the daemon.
492
+
493
+ Sends a ping command to the daemon to verify it is running and
494
+ get current queue status.
495
+
496
+ Returns:
497
+ ModelDaemonPingResponse with:
498
+ - status: "ok" (always for successful ping)
499
+ - queue_size: Number of events in memory queue
500
+ - spool_size: Number of events spooled to disk
501
+
502
+ Raises:
503
+ EmitClientError: If daemon is unavailable or returns error.
504
+
505
+ Example:
506
+ ```python
507
+ status = await client.ping()
508
+ print(f"Queue size: {status.queue_size}")
509
+ print(f"Spool size: {status.spool_size}")
510
+ ```
511
+ """
512
+ # Build typed request
513
+ request = ModelDaemonPingRequest()
514
+ raw_response = await self._send_request(request.model_dump())
515
+
516
+ # Parse typed response
517
+ try:
518
+ response = parse_daemon_response(raw_response)
519
+ except (ValueError, ValidationError) as e:
520
+ raise EmitClientError(
521
+ f"Invalid daemon response: {e}",
522
+ reason="invalid_response",
523
+ ) from e
524
+
525
+ # Handle response by type
526
+ if isinstance(response, ModelDaemonPingResponse):
527
+ logger.debug(
528
+ "Daemon ping successful",
529
+ extra={
530
+ "queue_size": response.queue_size,
531
+ "spool_size": response.spool_size,
532
+ },
533
+ )
534
+ return response
535
+ elif isinstance(response, ModelDaemonErrorResponse):
536
+ raise EmitClientError(
537
+ f"Daemon ping error: {response.reason}",
538
+ reason=response.reason,
539
+ )
540
+ elif isinstance(response, ModelDaemonQueuedResponse):
541
+ # Unexpected queued response to ping request
542
+ raise EmitClientError(
543
+ "Unexpected queued response to ping request",
544
+ reason="unexpected_response_type",
545
+ )
546
+ else:
547
+ # Should be unreachable
548
+ raise EmitClientError(
549
+ "Unexpected daemon ping response type",
550
+ reason="unexpected_status",
551
+ )
552
+
553
+ async def is_daemon_running(self) -> bool:
554
+ """Check if daemon is running and responsive.
555
+
556
+ Attempts to ping the daemon and returns True if successful.
557
+ Unlike ping(), this method does not raise exceptions - it simply
558
+ returns False if the daemon is unavailable.
559
+
560
+ Returns:
561
+ True if daemon responds to ping, False otherwise.
562
+
563
+ Example:
564
+ ```python
565
+ if await client.is_daemon_running():
566
+ await client.emit("event.type", {"data": "value"})
567
+ else:
568
+ # Fall back to direct Kafka publish
569
+ await direct_publish("event.type", {"data": "value"})
570
+ ```
571
+ """
572
+ try:
573
+ await self.ping()
574
+ return True
575
+ except EmitClientError:
576
+ return False
577
+ except Exception:
578
+ return False
579
+
580
+ def emit_sync(
581
+ self,
582
+ event_type: str,
583
+ payload: JsonType,
584
+ ) -> str:
585
+ """Synchronous wrapper for emit().
586
+
587
+ Creates an event loop if needed, calls emit(), and returns the result.
588
+ Useful for shell scripts and non-async code.
589
+
590
+ Note: This method creates a new connection for each call. For multiple
591
+ operations, consider using async code with the context manager.
592
+
593
+ Args:
594
+ event_type: Semantic event type (e.g., "prompt.submitted")
595
+ payload: Event payload dict
596
+
597
+ Returns:
598
+ Event ID assigned by the daemon
599
+
600
+ Raises:
601
+ EmitClientError: If daemon is unavailable or rejects the event
602
+
603
+ Example:
604
+ ```python
605
+ # In a non-async context (shell script, simple script)
606
+ client = EmitClient()
607
+ event_id = client.emit_sync("prompt.submitted", {"prompt_id": "abc"})
608
+ ```
609
+ """
610
+ return self._run_async(self._emit_and_disconnect(event_type, payload))
611
+
612
+ def ping_sync(self) -> ModelDaemonPingResponse:
613
+ """Synchronous wrapper for ping().
614
+
615
+ Creates an event loop if needed, calls ping(), and returns the result.
616
+ Useful for shell scripts and health checks.
617
+
618
+ Returns:
619
+ ModelDaemonPingResponse with status, queue_size, spool_size
620
+
621
+ Raises:
622
+ EmitClientError: If daemon is unavailable
623
+
624
+ Example:
625
+ ```python
626
+ status = EmitClient().ping_sync()
627
+ print(f"Daemon healthy, queue size: {status.queue_size}")
628
+ ```
629
+ """
630
+ return self._run_async(self._ping_and_disconnect())
631
+
632
+ def is_daemon_running_sync(self) -> bool:
633
+ """Synchronous wrapper for is_daemon_running().
634
+
635
+ Returns:
636
+ True if daemon responds to ping, False otherwise.
637
+ """
638
+ return self._run_async(self.is_daemon_running())
639
+
640
+ async def _emit_and_disconnect(
641
+ self,
642
+ event_type: str,
643
+ payload: JsonType,
644
+ ) -> str:
645
+ """Emit event and disconnect (for sync wrapper).
646
+
647
+ Args:
648
+ event_type: Event type to emit
649
+ payload: Event payload
650
+
651
+ Returns:
652
+ Event ID from daemon
653
+ """
654
+ try:
655
+ return await self.emit(event_type, payload)
656
+ finally:
657
+ await self._disconnect()
658
+
659
+ async def _ping_and_disconnect(self) -> ModelDaemonPingResponse:
660
+ """Ping daemon and disconnect (for sync wrapper).
661
+
662
+ Returns:
663
+ Typed ping response from daemon
664
+ """
665
+ try:
666
+ return await self.ping()
667
+ finally:
668
+ await self._disconnect()
669
+
670
+ def _run_async(self, coro: Coroutine[object, object, _T]) -> _T:
671
+ """Run an async coroutine in a sync context.
672
+
673
+ Handles event loop creation for sync wrappers.
674
+
675
+ Args:
676
+ coro: Coroutine to run
677
+
678
+ Returns:
679
+ Result from the coroutine
680
+ """
681
+ try:
682
+ # Check if we're in an existing event loop
683
+ loop = asyncio.get_running_loop()
684
+ except RuntimeError:
685
+ # No running loop - create a new one
686
+ loop = None
687
+
688
+ if loop is not None:
689
+ # We're in an async context - use run_until_complete is not allowed
690
+ # Create a new event loop in a thread would be complex
691
+ # Instead, raise an error suggesting async usage
692
+ raise RuntimeError(
693
+ "Cannot use sync methods from an async context. "
694
+ "Use the async emit() or ping() methods instead."
695
+ )
696
+
697
+ # No running loop - safe to use asyncio.run()
698
+ return asyncio.run(coro)
699
+
700
+
701
+ # Module-level convenience functions
702
+
703
+
704
+ async def emit_event(
705
+ event_type: str,
706
+ payload: JsonType,
707
+ socket_path: Path | str = EmitClient.DEFAULT_SOCKET_PATH,
708
+ timeout: float = EmitClient.DEFAULT_TIMEOUT,
709
+ ) -> str:
710
+ """Convenience function to emit a single event.
711
+
712
+ Creates a client, emits the event, and closes the connection.
713
+ For multiple events, use EmitClient as a context manager instead.
714
+
715
+ Args:
716
+ event_type: Semantic event type (e.g., "prompt.submitted")
717
+ payload: Event payload dict
718
+ socket_path: Path to daemon socket (defaults to /tmp/omniclaude-emit.sock)
719
+ timeout: Timeout in seconds for socket operations (defaults to 5.0)
720
+
721
+ Returns:
722
+ Event ID assigned by the daemon
723
+
724
+ Raises:
725
+ EmitClientError: If daemon is unavailable or rejects the event
726
+
727
+ Example:
728
+ ```python
729
+ event_id = await emit_event(
730
+ "prompt.submitted",
731
+ {"prompt_id": "abc123", "prompt_text": "Hello!"},
732
+ )
733
+ ```
734
+ """
735
+ async with EmitClient(socket_path=socket_path, timeout=timeout) as client:
736
+ return await client.emit(event_type, payload)
737
+
738
+
739
+ async def emit_event_with_fallback(
740
+ event_type: str,
741
+ payload: JsonType,
742
+ socket_path: Path | str = EmitClient.DEFAULT_SOCKET_PATH,
743
+ timeout: float = EmitClient.DEFAULT_TIMEOUT,
744
+ fallback: Callable[[str, JsonType], Awaitable[str]] | None = None,
745
+ ) -> str:
746
+ """Emit event via daemon, falling back to callback if daemon unavailable.
747
+
748
+ Use this when you want graceful degradation to direct Kafka publish
749
+ (or other fallback mechanism) when the daemon is not running.
750
+
751
+ Args:
752
+ event_type: Semantic event type (e.g., "prompt.submitted")
753
+ payload: Event payload dict
754
+ socket_path: Path to daemon socket (defaults to /tmp/omniclaude-emit.sock)
755
+ timeout: Timeout in seconds for socket operations (defaults to 5.0)
756
+ fallback: Optional async callback to invoke if daemon is unavailable.
757
+ Receives (event_type, payload) and should return event_id string.
758
+ If None and daemon is unavailable, raises EmitClientError.
759
+
760
+ Returns:
761
+ Event ID from daemon or fallback
762
+
763
+ Raises:
764
+ EmitClientError: If daemon unavailable and no fallback provided,
765
+ or if fallback raises an exception.
766
+
767
+ Example:
768
+ ```python
769
+ async def direct_kafka_publish(event_type: str, payload: dict) -> str:
770
+ # Direct Kafka publish implementation
771
+ return str(uuid4())
772
+
773
+ event_id = await emit_event_with_fallback(
774
+ "prompt.submitted",
775
+ {"prompt_id": "abc123"},
776
+ fallback=direct_kafka_publish,
777
+ )
778
+ ```
779
+ """
780
+ client = EmitClient(socket_path=socket_path, timeout=timeout)
781
+
782
+ try:
783
+ async with client:
784
+ return await client.emit(event_type, payload)
785
+ except EmitClientError as e:
786
+ # Check if this is a connection error (daemon not running)
787
+ connection_errors = {
788
+ "socket_not_found",
789
+ "connection_refused",
790
+ "connection_timeout",
791
+ "permission_denied",
792
+ "os_error",
793
+ }
794
+
795
+ if e.reason in connection_errors and fallback is not None:
796
+ logger.info(
797
+ f"Daemon unavailable ({e.reason}), using fallback",
798
+ extra={"event_type": event_type},
799
+ )
800
+ return await fallback(event_type, payload)
801
+
802
+ # Re-raise if not a connection error or no fallback
803
+ raise
804
+
805
+
806
+ __all__: list[str] = [
807
+ "EmitClient",
808
+ "EmitClientError",
809
+ "emit_event",
810
+ "emit_event_with_fallback",
811
+ ]