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,844 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """Command-Line Interface for Hook Event Emit Daemon.
4
+
5
+ This module provides the CLI for managing and testing the emit daemon.
6
+ The daemon provides a Unix socket interface for Claude Code hooks to emit
7
+ events to Kafka without blocking hook execution.
8
+
9
+ Commands:
10
+ start - Start the daemon (foreground or daemonized)
11
+ stop - Stop the running daemon
12
+ status - Check daemon status and queue metrics
13
+ emit - Emit a test event
14
+ config - Show resolved configuration
15
+
16
+ Usage Examples:
17
+ # Start daemon in foreground
18
+ emit-daemon start --kafka-servers localhost:9092
19
+
20
+ # Start daemon in background
21
+ emit-daemon start --kafka-servers localhost:9092 --daemonize
22
+
23
+ # Check status
24
+ emit-daemon status
25
+
26
+ # Emit a test event
27
+ emit-daemon emit --event-type prompt.submitted --payload '{"session_id": "test"}'
28
+
29
+ # Stop daemon
30
+ emit-daemon stop
31
+
32
+ Related Tickets:
33
+ - OMN-1610: Hook Event Daemon MVP
34
+
35
+ .. versionadded:: 0.2.6
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import asyncio
42
+ import json
43
+ import logging
44
+ import os
45
+ import signal
46
+ import socket
47
+ import sys
48
+ import time
49
+ from pathlib import Path
50
+
51
+ import yaml
52
+
53
+ from omnibase_core.errors import OnexError
54
+ from omnibase_infra import __version__
55
+ from omnibase_infra.runtime.emit_daemon.config import ModelEmitDaemonConfig
56
+ from omnibase_infra.runtime.emit_daemon.daemon import EmitDaemon
57
+
58
+ # Default timeout for socket operations (seconds)
59
+ DEFAULT_SOCKET_TIMEOUT: float = 5.0
60
+
61
+ # Default graceful shutdown wait time (seconds)
62
+ DEFAULT_SHUTDOWN_WAIT: float = 30.0
63
+
64
+
65
+ def _setup_logging(verbose: bool = False) -> None:
66
+ """Configure logging for CLI operations.
67
+
68
+ Args:
69
+ verbose: If True, set DEBUG level; otherwise INFO level.
70
+ """
71
+ level = logging.DEBUG if verbose else logging.INFO
72
+ logging.basicConfig(
73
+ level=level,
74
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
75
+ datefmt="%Y-%m-%d %H:%M:%S",
76
+ )
77
+
78
+
79
+ def _load_config_file(config_path: Path) -> dict[str, object]:
80
+ """Load configuration from YAML file.
81
+
82
+ Args:
83
+ config_path: Path to the YAML configuration file.
84
+
85
+ Returns:
86
+ Dictionary of configuration values. Values are str, int, float, or bool
87
+ as parsed from YAML. Pydantic handles final type validation.
88
+
89
+ Raises:
90
+ SystemExit: If file cannot be read or parsed.
91
+ """
92
+ if not config_path.exists():
93
+ print(f"Error: Config file not found: {config_path}", file=sys.stderr)
94
+ sys.exit(1)
95
+
96
+ try:
97
+ with config_path.open("r", encoding="utf-8") as f:
98
+ data = yaml.safe_load(f)
99
+ if not isinstance(data, dict):
100
+ print("Error: Config file must be a YAML mapping", file=sys.stderr)
101
+ sys.exit(1)
102
+ # Filter out None values, keep all YAML-parsed types (str, int, float, bool)
103
+ # Pydantic handles final type validation and conversion
104
+ result: dict[str, object] = {}
105
+ for key, value in data.items():
106
+ if value is not None:
107
+ result[key] = value
108
+ return result
109
+ except yaml.YAMLError as e:
110
+ print(f"Error: Invalid YAML in config file: {e}", file=sys.stderr)
111
+ sys.exit(1)
112
+ except OSError as e:
113
+ print(f"Error: Cannot read config file: {e}", file=sys.stderr)
114
+ sys.exit(1)
115
+
116
+
117
+ def _build_config(args: argparse.Namespace) -> ModelEmitDaemonConfig:
118
+ """Build configuration from CLI arguments and config file.
119
+
120
+ Args:
121
+ args: Parsed CLI arguments.
122
+
123
+ Returns:
124
+ Validated configuration model.
125
+
126
+ Raises:
127
+ SystemExit: If configuration is invalid.
128
+ """
129
+ # Start with empty config dict (object allows all config value types)
130
+ config_dict: dict[str, object] = {}
131
+
132
+ # Load from config file if specified
133
+ config_file = getattr(args, "config", None)
134
+ if config_file:
135
+ config_dict.update(_load_config_file(Path(config_file)))
136
+
137
+ # Apply CLI overrides (these take precedence over config file)
138
+ if hasattr(args, "kafka_servers") and args.kafka_servers:
139
+ config_dict["kafka_bootstrap_servers"] = args.kafka_servers
140
+
141
+ if hasattr(args, "socket_path") and args.socket_path:
142
+ config_dict["socket_path"] = Path(args.socket_path)
143
+
144
+ if hasattr(args, "pid_path") and args.pid_path:
145
+ config_dict["pid_path"] = Path(args.pid_path)
146
+
147
+ if hasattr(args, "spool_dir") and args.spool_dir:
148
+ config_dict["spool_dir"] = Path(args.spool_dir)
149
+
150
+ try:
151
+ # Use with_env_overrides to also pick up environment variables
152
+ return ModelEmitDaemonConfig.with_env_overrides(**config_dict)
153
+ except ValueError as e:
154
+ print(f"Error: Invalid configuration: {e}", file=sys.stderr)
155
+ sys.exit(1)
156
+
157
+
158
+ def _send_socket_message(
159
+ socket_path: Path,
160
+ message: dict[str, object],
161
+ timeout: float = DEFAULT_SOCKET_TIMEOUT,
162
+ ) -> dict[str, object]:
163
+ """Send a message to the daemon via Unix socket.
164
+
165
+ Args:
166
+ socket_path: Path to the Unix domain socket.
167
+ message: JSON-serializable message to send.
168
+ timeout: Socket timeout in seconds.
169
+
170
+ Returns:
171
+ Parsed JSON response from daemon.
172
+
173
+ Raises:
174
+ ConnectionError: If cannot connect to daemon.
175
+ TimeoutError: If operation times out.
176
+ OnexError: If response is invalid JSON.
177
+ """
178
+ if not socket_path.exists():
179
+ raise ConnectionError(f"Socket not found: {socket_path}")
180
+
181
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
182
+ sock.settimeout(timeout)
183
+
184
+ try:
185
+ sock.connect(str(socket_path))
186
+
187
+ # Send message (newline-delimited JSON)
188
+ request = json.dumps(message) + "\n"
189
+ sock.sendall(request.encode("utf-8"))
190
+
191
+ # Read response
192
+ response_data = b""
193
+ while True:
194
+ chunk = sock.recv(4096)
195
+ if not chunk:
196
+ break
197
+ response_data += chunk
198
+ if b"\n" in response_data:
199
+ break
200
+
201
+ if not response_data:
202
+ raise ConnectionError("Empty response from daemon")
203
+
204
+ # Parse response
205
+ response_str = response_data.decode("utf-8").strip()
206
+ result = json.loads(response_str)
207
+ if not isinstance(result, dict):
208
+ raise OnexError("Response must be a JSON object")
209
+ return result
210
+
211
+ except TimeoutError as e:
212
+ raise TimeoutError(f"Socket operation timed out: {e}") from e
213
+ except json.JSONDecodeError as e:
214
+ raise OnexError(f"Invalid JSON response: {e}") from e
215
+ except OSError as e:
216
+ raise ConnectionError(f"Socket error: {e}") from e
217
+ finally:
218
+ sock.close()
219
+
220
+
221
+ def _read_pid_file(pid_path: Path) -> int | None:
222
+ """Read PID from daemon PID file.
223
+
224
+ Args:
225
+ pid_path: Path to the PID file.
226
+
227
+ Returns:
228
+ The PID if file exists and is valid, None otherwise.
229
+ """
230
+ if not pid_path.exists():
231
+ return None
232
+
233
+ try:
234
+ pid_str = pid_path.read_text().strip()
235
+ return int(pid_str)
236
+ except (OSError, ValueError):
237
+ return None
238
+
239
+
240
+ def _is_process_running(pid: int) -> bool:
241
+ """Check if a process with the given PID is running.
242
+
243
+ Args:
244
+ pid: Process ID to check.
245
+
246
+ Returns:
247
+ True if process is running, False otherwise.
248
+ """
249
+ try:
250
+ os.kill(pid, 0)
251
+ return True
252
+ except ProcessLookupError:
253
+ return False
254
+ except PermissionError:
255
+ # Process exists but we can't signal it
256
+ return True
257
+
258
+
259
+ def _daemonize() -> None:
260
+ """Fork the current process into a daemon.
261
+
262
+ Double-fork to prevent zombie processes and detach from terminal.
263
+ After this call, the parent process should exit.
264
+
265
+ Raises:
266
+ SystemExit: On Windows (os.fork() is not available).
267
+ """
268
+ # Check platform - fork() is Unix-only
269
+ if sys.platform == "win32":
270
+ print("Error: --daemonize is not supported on Windows", file=sys.stderr)
271
+ sys.exit(1)
272
+
273
+ # First fork
274
+ pid = os.fork()
275
+ if pid > 0:
276
+ # Parent process - exit
277
+ sys.exit(0)
278
+
279
+ # Become session leader
280
+ os.setsid()
281
+
282
+ # Second fork (prevent acquiring a controlling terminal)
283
+ pid = os.fork()
284
+ if pid > 0:
285
+ # First child - exit
286
+ sys.exit(0)
287
+
288
+ # We're now in the grandchild (daemon) process
289
+
290
+ # Change working directory to root
291
+ os.chdir("/")
292
+
293
+ # Reset file creation mask to secure default (rw-r--r-- for files, rwxr-xr-x for dirs)
294
+ os.umask(0o022)
295
+
296
+ # Redirect standard file descriptors to /dev/null
297
+ devnull = os.open(os.devnull, os.O_RDWR)
298
+ os.dup2(devnull, sys.stdin.fileno())
299
+ os.dup2(devnull, sys.stdout.fileno())
300
+ os.dup2(devnull, sys.stderr.fileno())
301
+ os.close(devnull)
302
+
303
+
304
+ def cmd_start(args: argparse.Namespace) -> None:
305
+ """Handle the 'start' command.
306
+
307
+ Start the emit daemon in foreground or background mode.
308
+
309
+ Args:
310
+ args: Parsed CLI arguments.
311
+ """
312
+ _setup_logging(verbose=args.verbose)
313
+
314
+ # Build configuration
315
+ config = _build_config(args)
316
+
317
+ # Check if daemon is already running
318
+ pid = _read_pid_file(config.pid_path)
319
+ if pid is not None and _is_process_running(pid):
320
+ print(f"Error: Daemon already running with PID {pid}", file=sys.stderr)
321
+ sys.exit(1)
322
+
323
+ # Daemonize if requested
324
+ if args.daemonize:
325
+ log_dir = config.spool_dir.parent
326
+ log_file = log_dir / "emit-daemon.log"
327
+ # Create log directory BEFORE daemonizing so errors are visible to user
328
+ try:
329
+ log_dir.mkdir(parents=True, exist_ok=True)
330
+ except OSError as e:
331
+ print(f"Error: Cannot create log directory: {e}", file=sys.stderr)
332
+ sys.exit(1)
333
+ print("Starting emit-daemon in background...")
334
+ print(f" Socket: {config.socket_path}")
335
+ print(f" PID file: {config.pid_path}")
336
+ print(f" Kafka: {config.kafka_bootstrap_servers}")
337
+ print(f" Log file: {log_file}")
338
+ _daemonize()
339
+ # NOTE: After daemonize, stdio is redirected to /dev/null.
340
+ # Startup errors will appear in the log file above.
341
+ # Clear any existing handlers to ensure reconfiguration works
342
+ # (logging.basicConfig() is a no-op if handlers already exist)
343
+ root_logger = logging.getLogger()
344
+ for handler in root_logger.handlers[:]:
345
+ root_logger.removeHandler(handler)
346
+ # Set up file-based logging (directory already exists from above)
347
+ logging.basicConfig(
348
+ level=logging.INFO,
349
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
350
+ filename=str(log_file),
351
+ filemode="a",
352
+ )
353
+
354
+ # Run the daemon
355
+ async def run_daemon() -> None:
356
+ daemon = EmitDaemon(config)
357
+ try:
358
+ await daemon.start()
359
+ if not args.daemonize:
360
+ print(f"Emit daemon started (PID: {os.getpid()})")
361
+ print(f" Socket: {config.socket_path}")
362
+ print(f" Kafka: {config.kafka_bootstrap_servers}")
363
+ print("Press Ctrl+C to stop...")
364
+ await daemon.run_until_shutdown()
365
+ except OnexError as e:
366
+ if not args.daemonize:
367
+ print(f"Error: {e}", file=sys.stderr)
368
+ logging.getLogger(__name__).exception("Daemon error")
369
+ sys.exit(1)
370
+
371
+ try:
372
+ asyncio.run(run_daemon())
373
+ except KeyboardInterrupt:
374
+ if not args.daemonize:
375
+ print("\nShutting down...")
376
+ sys.exit(0)
377
+
378
+
379
+ def cmd_stop(args: argparse.Namespace) -> None:
380
+ """Handle the 'stop' command.
381
+
382
+ Stop the running emit daemon gracefully.
383
+
384
+ Args:
385
+ args: Parsed CLI arguments.
386
+ """
387
+ # Get PID file path (intentional /tmp default for daemon PID tracking)
388
+ pid_path = Path(getattr(args, "pid_path", None) or "/tmp/omniclaude-emit.pid") # noqa: S108
389
+
390
+ # If config file provided, use its pid_path
391
+ if args.config:
392
+ config_data = _load_config_file(Path(args.config))
393
+ if "pid_path" in config_data:
394
+ pid_path = Path(str(config_data["pid_path"]))
395
+
396
+ # Read PID
397
+ pid = _read_pid_file(pid_path)
398
+ if pid is None:
399
+ print("Daemon not running (no PID file)", file=sys.stderr)
400
+ sys.exit(1)
401
+
402
+ if not _is_process_running(pid):
403
+ print(f"Daemon not running (stale PID file for PID {pid})")
404
+ # Clean up stale PID file
405
+ try:
406
+ pid_path.unlink()
407
+ print(f"Removed stale PID file: {pid_path}")
408
+ except OSError:
409
+ pass
410
+ sys.exit(0)
411
+
412
+ # Send SIGTERM for graceful shutdown
413
+ print(f"Stopping emit-daemon (PID: {pid})...")
414
+ try:
415
+ os.kill(pid, signal.SIGTERM)
416
+ except ProcessLookupError:
417
+ print("Daemon already stopped")
418
+ sys.exit(0)
419
+ except PermissionError:
420
+ print(f"Error: Permission denied to stop daemon (PID: {pid})", file=sys.stderr)
421
+ sys.exit(1)
422
+
423
+ # Wait for graceful shutdown
424
+ wait_time = DEFAULT_SHUTDOWN_WAIT
425
+ for _ in range(int(wait_time * 10)): # Check every 0.1s
426
+ if not _is_process_running(pid):
427
+ print("Daemon stopped successfully")
428
+ # Clean up PID file if daemon didn't
429
+ if pid_path.exists():
430
+ try:
431
+ pid_path.unlink()
432
+ except OSError:
433
+ pass
434
+ sys.exit(0)
435
+ time.sleep(0.1)
436
+
437
+ # Process didn't stop gracefully - warn user
438
+ print(
439
+ f"Warning: Daemon (PID: {pid}) did not stop within {wait_time}s",
440
+ file=sys.stderr,
441
+ )
442
+ print(f"You may need to force kill with: kill -9 {pid}", file=sys.stderr)
443
+ sys.exit(1)
444
+
445
+
446
+ def cmd_status(args: argparse.Namespace) -> None:
447
+ """Handle the 'status' command.
448
+
449
+ Check daemon status via ping command.
450
+
451
+ Args:
452
+ args: Parsed CLI arguments.
453
+ """
454
+ # Get socket path (intentional /tmp default for daemon socket)
455
+ socket_path = Path(
456
+ getattr(args, "socket_path", None) or "/tmp/omniclaude-emit.sock" # noqa: S108
457
+ )
458
+
459
+ # If config file provided, use its socket_path
460
+ if args.config:
461
+ config_data = _load_config_file(Path(args.config))
462
+ if "socket_path" in config_data:
463
+ socket_path = Path(str(config_data["socket_path"]))
464
+
465
+ try:
466
+ response = _send_socket_message(socket_path, {"command": "ping"})
467
+
468
+ if response.get("status") == "ok":
469
+ print("Daemon is running")
470
+ print(f" Queue size (memory): {response.get('queue_size', 'unknown')}")
471
+ print(f" Queue size (spool): {response.get('spool_size', 'unknown')}")
472
+
473
+ # Print as JSON if requested
474
+ if args.json:
475
+ print(json.dumps(response, indent=2))
476
+
477
+ sys.exit(0)
478
+ else:
479
+ print(f"Daemon returned unexpected status: {response}", file=sys.stderr)
480
+ sys.exit(1)
481
+
482
+ except ConnectionError as e:
483
+ print(f"Daemon not running: {e}", file=sys.stderr)
484
+ sys.exit(1)
485
+ except TimeoutError as e:
486
+ print(f"Daemon not responding: {e}", file=sys.stderr)
487
+ sys.exit(1)
488
+ except OnexError as e:
489
+ print(f"Invalid response from daemon: {e}", file=sys.stderr)
490
+ sys.exit(1)
491
+
492
+
493
+ def cmd_emit(args: argparse.Namespace) -> None:
494
+ """Handle the 'emit' command.
495
+
496
+ Emit a test event to the daemon.
497
+
498
+ Args:
499
+ args: Parsed CLI arguments.
500
+ """
501
+ # Get socket path (intentional /tmp default for daemon socket)
502
+ socket_path = Path(
503
+ getattr(args, "socket_path", None) or "/tmp/omniclaude-emit.sock" # noqa: S108
504
+ )
505
+
506
+ # If config file provided, use its socket_path
507
+ if args.config:
508
+ config_data = _load_config_file(Path(args.config))
509
+ if "socket_path" in config_data:
510
+ socket_path = Path(str(config_data["socket_path"]))
511
+
512
+ # Parse payload JSON
513
+ try:
514
+ payload = json.loads(args.payload)
515
+ if not isinstance(payload, dict):
516
+ print("Error: Payload must be a JSON object", file=sys.stderr)
517
+ sys.exit(1)
518
+ except json.JSONDecodeError as e:
519
+ print(f"Error: Invalid JSON payload: {e}", file=sys.stderr)
520
+ sys.exit(1)
521
+
522
+ # Build event message
523
+ message: dict[str, object] = {
524
+ "event_type": args.event_type,
525
+ "payload": payload,
526
+ }
527
+
528
+ try:
529
+ response = _send_socket_message(socket_path, message)
530
+
531
+ if response.get("status") == "queued":
532
+ event_id = response.get("event_id", "unknown")
533
+ print("Event queued successfully")
534
+ print(f" Event ID: {event_id}")
535
+ print(f" Type: {args.event_type}")
536
+
537
+ if args.json:
538
+ print(json.dumps(response, indent=2))
539
+
540
+ sys.exit(0)
541
+ elif response.get("status") == "error":
542
+ reason = response.get("reason", "Unknown error")
543
+ print(f"Error: {reason}", file=sys.stderr)
544
+ sys.exit(1)
545
+ else:
546
+ print(f"Unexpected response: {response}", file=sys.stderr)
547
+ sys.exit(1)
548
+
549
+ except ConnectionError as e:
550
+ print(f"Cannot connect to daemon: {e}", file=sys.stderr)
551
+ sys.exit(1)
552
+ except TimeoutError as e:
553
+ print(f"Daemon not responding: {e}", file=sys.stderr)
554
+ sys.exit(1)
555
+ except OnexError as e:
556
+ print(f"Invalid response from daemon: {e}", file=sys.stderr)
557
+ sys.exit(1)
558
+
559
+
560
+ def cmd_config(args: argparse.Namespace) -> None:
561
+ """Handle the 'config' command.
562
+
563
+ Show resolved configuration.
564
+
565
+ Args:
566
+ args: Parsed CLI arguments.
567
+ """
568
+ try:
569
+ config = _build_config(args)
570
+ except SystemExit:
571
+ # If config building fails, show what we can
572
+ if args.config:
573
+ config_data = _load_config_file(Path(args.config))
574
+ print("# Configuration from file (not validated):")
575
+ print(yaml.dump(config_data, default_flow_style=False))
576
+ else:
577
+ print("# Default configuration:")
578
+ print("# (Requires kafka_bootstrap_servers to be set)")
579
+ print()
580
+ print("socket_path: /tmp/omniclaude-emit.sock")
581
+ print("pid_path: /tmp/omniclaude-emit.pid")
582
+ print("spool_dir: ~/.omniclaude/emit-spool")
583
+ print("kafka_bootstrap_servers: <REQUIRED>")
584
+ print("kafka_client_id: emit-daemon")
585
+ print("max_payload_bytes: 1048576")
586
+ print("max_memory_queue: 100")
587
+ print("max_spool_messages: 1000")
588
+ print("max_spool_bytes: 10485760")
589
+ print("socket_timeout_seconds: 5.0")
590
+ print("kafka_timeout_seconds: 30.0")
591
+ print("shutdown_drain_seconds: 10.0")
592
+ return
593
+
594
+ # Convert config to dict for YAML output
595
+ config_dict = {
596
+ "socket_path": str(config.socket_path),
597
+ "pid_path": str(config.pid_path),
598
+ "spool_dir": str(config.spool_dir),
599
+ "kafka_bootstrap_servers": config.kafka_bootstrap_servers,
600
+ "kafka_client_id": config.kafka_client_id,
601
+ "max_payload_bytes": config.max_payload_bytes,
602
+ "max_memory_queue": config.max_memory_queue,
603
+ "max_spool_messages": config.max_spool_messages,
604
+ "max_spool_bytes": config.max_spool_bytes,
605
+ "socket_timeout_seconds": config.socket_timeout_seconds,
606
+ "kafka_timeout_seconds": config.kafka_timeout_seconds,
607
+ "shutdown_drain_seconds": config.shutdown_drain_seconds,
608
+ "spooling_enabled": config.spooling_enabled,
609
+ }
610
+
611
+ print("# Resolved configuration:")
612
+ print(yaml.dump(config_dict, default_flow_style=False))
613
+
614
+
615
+ def create_parser() -> argparse.ArgumentParser:
616
+ """Create the argument parser with all subcommands.
617
+
618
+ Returns:
619
+ Configured argument parser.
620
+ """
621
+ parser = argparse.ArgumentParser(
622
+ prog="emit-daemon",
623
+ description="Hook Event Emit Daemon - Persistent Kafka event emission for Claude Code hooks",
624
+ formatter_class=argparse.RawDescriptionHelpFormatter,
625
+ epilog="""
626
+ Examples:
627
+ # Show version
628
+ emit-daemon --version
629
+
630
+ # Start daemon in foreground
631
+ emit-daemon start --kafka-servers localhost:9092
632
+
633
+ # Start daemon in background
634
+ emit-daemon start --kafka-servers localhost:9092 --daemonize
635
+
636
+ # Start with config file
637
+ emit-daemon start --config /path/to/config.yaml
638
+
639
+ # Check daemon status
640
+ emit-daemon status
641
+
642
+ # Emit a test event
643
+ emit-daemon emit --event-type prompt.submitted --payload '{"session_id": "test"}'
644
+
645
+ # Stop the daemon
646
+ emit-daemon stop
647
+
648
+ # Show resolved configuration
649
+ emit-daemon config --config /path/to/config.yaml
650
+ """,
651
+ )
652
+
653
+ parser.add_argument(
654
+ "--version",
655
+ action="version",
656
+ version=f"%(prog)s {__version__}",
657
+ )
658
+
659
+ subparsers = parser.add_subparsers(
660
+ dest="command",
661
+ title="commands",
662
+ description="Available commands",
663
+ required=True,
664
+ )
665
+
666
+ # -------------------------------------------------------------------------
667
+ # start command
668
+ # -------------------------------------------------------------------------
669
+ start_parser = subparsers.add_parser(
670
+ "start",
671
+ help="Start the emit daemon",
672
+ description="Start the emit daemon in foreground or background mode.",
673
+ )
674
+ start_parser.add_argument(
675
+ "--config",
676
+ "-c",
677
+ metavar="FILE",
678
+ help="Path to YAML configuration file",
679
+ )
680
+ start_parser.add_argument(
681
+ "--kafka-servers",
682
+ "-k",
683
+ metavar="SERVERS",
684
+ help="Kafka bootstrap servers (e.g., localhost:9092). Overrides config file.",
685
+ )
686
+ start_parser.add_argument(
687
+ "--socket-path",
688
+ "-s",
689
+ metavar="PATH",
690
+ help="Unix socket path. Overrides config file.",
691
+ )
692
+ start_parser.add_argument(
693
+ "--pid-path",
694
+ "-p",
695
+ metavar="PATH",
696
+ help="PID file path. Overrides config file.",
697
+ )
698
+ start_parser.add_argument(
699
+ "--spool-dir",
700
+ metavar="DIR",
701
+ help="Spool directory path. Overrides config file.",
702
+ )
703
+ start_parser.add_argument(
704
+ "--daemonize",
705
+ "-d",
706
+ action="store_true",
707
+ help="Run daemon in background (fork and exit)",
708
+ )
709
+ start_parser.add_argument(
710
+ "--verbose",
711
+ "-v",
712
+ action="store_true",
713
+ help="Enable verbose (DEBUG) logging",
714
+ )
715
+ start_parser.set_defaults(func=cmd_start)
716
+
717
+ # -------------------------------------------------------------------------
718
+ # stop command
719
+ # -------------------------------------------------------------------------
720
+ stop_parser = subparsers.add_parser(
721
+ "stop",
722
+ help="Stop the running daemon",
723
+ description="Stop the running emit daemon gracefully.",
724
+ )
725
+ stop_parser.add_argument(
726
+ "--config",
727
+ "-c",
728
+ metavar="FILE",
729
+ help="Path to YAML configuration file (to find PID file)",
730
+ )
731
+ stop_parser.add_argument(
732
+ "--pid-path",
733
+ "-p",
734
+ metavar="PATH",
735
+ help="PID file path (default: /tmp/omniclaude-emit.pid)",
736
+ )
737
+ stop_parser.set_defaults(func=cmd_stop)
738
+
739
+ # -------------------------------------------------------------------------
740
+ # status command
741
+ # -------------------------------------------------------------------------
742
+ status_parser = subparsers.add_parser(
743
+ "status",
744
+ help="Check daemon status",
745
+ description="Check if the daemon is running and show queue metrics.",
746
+ )
747
+ status_parser.add_argument(
748
+ "--config",
749
+ "-c",
750
+ metavar="FILE",
751
+ help="Path to YAML configuration file (to find socket path)",
752
+ )
753
+ status_parser.add_argument(
754
+ "--socket-path",
755
+ "-s",
756
+ metavar="PATH",
757
+ help="Unix socket path (default: /tmp/omniclaude-emit.sock)",
758
+ )
759
+ status_parser.add_argument(
760
+ "--json",
761
+ "-j",
762
+ action="store_true",
763
+ help="Output full status as JSON",
764
+ )
765
+ status_parser.set_defaults(func=cmd_status)
766
+
767
+ # -------------------------------------------------------------------------
768
+ # emit command
769
+ # -------------------------------------------------------------------------
770
+ emit_parser = subparsers.add_parser(
771
+ "emit",
772
+ help="Emit a test event",
773
+ description="Emit an event to the daemon for publishing to Kafka.",
774
+ )
775
+ emit_parser.add_argument(
776
+ "--event-type",
777
+ "-t",
778
+ required=True,
779
+ metavar="TYPE",
780
+ help="Event type (e.g., prompt.submitted)",
781
+ )
782
+ emit_parser.add_argument(
783
+ "--payload",
784
+ "-p",
785
+ required=True,
786
+ metavar="JSON",
787
+ help='Event payload as JSON string (e.g., \'{"key": "value"}\')',
788
+ )
789
+ emit_parser.add_argument(
790
+ "--config",
791
+ "-c",
792
+ metavar="FILE",
793
+ help="Path to YAML configuration file (to find socket path)",
794
+ )
795
+ emit_parser.add_argument(
796
+ "--socket-path",
797
+ "-s",
798
+ metavar="PATH",
799
+ help="Unix socket path (default: /tmp/omniclaude-emit.sock)",
800
+ )
801
+ emit_parser.add_argument(
802
+ "--json",
803
+ "-j",
804
+ action="store_true",
805
+ help="Output full response as JSON",
806
+ )
807
+ emit_parser.set_defaults(func=cmd_emit)
808
+
809
+ # -------------------------------------------------------------------------
810
+ # config command
811
+ # -------------------------------------------------------------------------
812
+ config_parser = subparsers.add_parser(
813
+ "config",
814
+ help="Show configuration",
815
+ description="Show the resolved configuration as YAML.",
816
+ )
817
+ config_parser.add_argument(
818
+ "--config",
819
+ "-c",
820
+ metavar="FILE",
821
+ help="Path to YAML configuration file",
822
+ )
823
+ config_parser.add_argument(
824
+ "--kafka-servers",
825
+ "-k",
826
+ metavar="SERVERS",
827
+ help="Kafka bootstrap servers (required if no config file)",
828
+ )
829
+ config_parser.set_defaults(func=cmd_config)
830
+
831
+ return parser
832
+
833
+
834
+ def main() -> None:
835
+ """CLI entry point."""
836
+ parser = create_parser()
837
+ args = parser.parse_args()
838
+
839
+ # Call the appropriate command handler
840
+ args.func(args)
841
+
842
+
843
+ if __name__ == "__main__":
844
+ main()