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.
- omnibase_infra/constants_topic_patterns.py +26 -0
- omnibase_infra/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
- omnibase_infra/enums/enum_handler_source_mode.py +16 -2
- omnibase_infra/errors/__init__.py +4 -0
- omnibase_infra/errors/error_binding_resolution.py +128 -0
- omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
- omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
- omnibase_infra/event_bus/event_bus_kafka.py +105 -47
- omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
- omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
- omnibase_infra/event_bus/testing/__init__.py +26 -0
- omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
- omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
- omnibase_infra/handlers/handler_consul.py +2 -0
- omnibase_infra/handlers/mixins/__init__.py +5 -0
- omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
- omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
- omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
- omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
- omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
- omnibase_infra/mixins/mixin_node_introspection.py +189 -19
- omnibase_infra/models/__init__.py +8 -0
- omnibase_infra/models/bindings/__init__.py +59 -0
- omnibase_infra/models/bindings/constants.py +144 -0
- omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
- omnibase_infra/models/bindings/model_operation_binding.py +44 -0
- omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
- omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
- omnibase_infra/models/discovery/model_introspection_config.py +25 -17
- omnibase_infra/models/dispatch/__init__.py +8 -0
- omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
- omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
- omnibase_infra/models/model_node_identity.py +126 -0
- omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
- omnibase_infra/models/registration/__init__.py +9 -0
- omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
- omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
- omnibase_infra/models/runtime/__init__.py +9 -0
- omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
- omnibase_infra/nodes/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
- omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
- omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
- omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
- omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
- omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
- omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
- omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
- omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
- omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
- omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
- omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
- omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
- omnibase_infra/nodes/reducers/models/__init__.py +7 -2
- omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
- omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
- omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
- omnibase_infra/protocols/__init__.py +3 -0
- omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
- omnibase_infra/runtime/__init__.py +60 -0
- omnibase_infra/runtime/binding_resolver.py +753 -0
- omnibase_infra/runtime/constants_security.py +70 -0
- omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
- omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
- omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
- omnibase_infra/runtime/emit_daemon/cli.py +844 -0
- omnibase_infra/runtime/emit_daemon/client.py +811 -0
- omnibase_infra/runtime/emit_daemon/config.py +535 -0
- omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
- omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
- omnibase_infra/runtime/emit_daemon/queue.py +618 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
- omnibase_infra/runtime/handler_source_resolver.py +43 -2
- omnibase_infra/runtime/kafka_contract_source.py +984 -0
- omnibase_infra/runtime/models/__init__.py +13 -0
- omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
- omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
- omnibase_infra/runtime/models/model_security_config.py +109 -0
- omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
- omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
- omnibase_infra/runtime/service_kernel.py +76 -6
- omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
- omnibase_infra/runtime/service_runtime_host_process.py +770 -20
- omnibase_infra/runtime/transition_notification_publisher.py +3 -2
- omnibase_infra/runtime/util_wiring.py +206 -62
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
- omnibase_infra/services/session/config_consumer.py +25 -8
- omnibase_infra/services/session/config_store.py +2 -2
- omnibase_infra/services/session/consumer.py +1 -1
- omnibase_infra/topics/__init__.py +45 -0
- omnibase_infra/topics/platform_topic_suffixes.py +140 -0
- omnibase_infra/topics/util_topic_composition.py +95 -0
- omnibase_infra/types/typed_dict/__init__.py +9 -1
- omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
- omnibase_infra/utils/__init__.py +9 -0
- omnibase_infra/utils/util_consumer_group.py +232 -0
- omnibase_infra/validation/infra_validators.py +18 -1
- omnibase_infra/validation/validation_exemptions.yaml +192 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Emit Daemon Configuration Model.
|
|
4
|
+
|
|
5
|
+
This module provides the Pydantic configuration model for the Hook Event Emit Daemon.
|
|
6
|
+
The daemon provides a Unix socket interface for Claude Code hooks to emit events
|
|
7
|
+
to Kafka without blocking hook execution.
|
|
8
|
+
|
|
9
|
+
Configuration includes:
|
|
10
|
+
- Socket and PID file paths
|
|
11
|
+
- Spool directory for message persistence during Kafka unavailability
|
|
12
|
+
- Memory and disk limits for backpressure management
|
|
13
|
+
- Kafka connection settings
|
|
14
|
+
- Timeout configurations for graceful operations
|
|
15
|
+
|
|
16
|
+
Environment Variable Overrides:
|
|
17
|
+
EMIT_DAEMON_SOCKET_PATH: Override socket_path
|
|
18
|
+
EMIT_DAEMON_PID_PATH: Override pid_path
|
|
19
|
+
EMIT_DAEMON_SPOOL_DIR: Override spool_dir
|
|
20
|
+
EMIT_DAEMON_SOCKET_PERMISSIONS: Override socket_permissions (octal string, e.g., "660")
|
|
21
|
+
EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS: Override kafka_bootstrap_servers
|
|
22
|
+
EMIT_DAEMON_KAFKA_CLIENT_ID: Override kafka_client_id
|
|
23
|
+
EMIT_DAEMON_ENVIRONMENT: Override environment
|
|
24
|
+
EMIT_DAEMON_MAX_RETRY_ATTEMPTS: Override max_retry_attempts
|
|
25
|
+
EMIT_DAEMON_BACKOFF_BASE_SECONDS: Override backoff_base_seconds
|
|
26
|
+
EMIT_DAEMON_MAX_BACKOFF_SECONDS: Override max_backoff_seconds
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
35
|
+
|
|
36
|
+
from omnibase_core.errors import OnexError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ModelEmitDaemonConfigInput(BaseModel):
|
|
40
|
+
"""Intermediate input model for environment variable override parsing.
|
|
41
|
+
|
|
42
|
+
This model captures configuration values from kwargs and environment variables
|
|
43
|
+
before final validation. All fields are optional since we only populate what's
|
|
44
|
+
actually provided, allowing the final ModelEmitDaemonConfig to apply defaults.
|
|
45
|
+
|
|
46
|
+
Purpose:
|
|
47
|
+
- Eliminates union types in with_env_overrides() method
|
|
48
|
+
- Provides early validation at parse time with ConfigDict(extra="forbid")
|
|
49
|
+
- Allows type-safe accumulation of config values from multiple sources
|
|
50
|
+
|
|
51
|
+
Usage:
|
|
52
|
+
This model is used internally by ModelEmitDaemonConfig.with_env_overrides().
|
|
53
|
+
It should not be used directly - use ModelEmitDaemonConfig instead.
|
|
54
|
+
|
|
55
|
+
Note:
|
|
56
|
+
Validation happens in the final ModelEmitDaemonConfig model, not here.
|
|
57
|
+
This model only ensures type safety and catches typos in field names.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
# Path configurations
|
|
63
|
+
socket_path: Path | None = None
|
|
64
|
+
pid_path: Path | None = None
|
|
65
|
+
spool_dir: Path | None = None
|
|
66
|
+
|
|
67
|
+
# Limit configurations
|
|
68
|
+
max_payload_bytes: int | None = None
|
|
69
|
+
max_memory_queue: int | None = None
|
|
70
|
+
max_spool_messages: int | None = None
|
|
71
|
+
max_spool_bytes: int | None = None
|
|
72
|
+
|
|
73
|
+
# Kafka configurations
|
|
74
|
+
kafka_bootstrap_servers: str | None = None
|
|
75
|
+
kafka_client_id: str | None = None
|
|
76
|
+
environment: str | None = None
|
|
77
|
+
|
|
78
|
+
# Socket permissions
|
|
79
|
+
socket_permissions: int | None = None
|
|
80
|
+
|
|
81
|
+
# Timeout configurations
|
|
82
|
+
socket_timeout_seconds: float | None = None
|
|
83
|
+
kafka_timeout_seconds: float | None = None
|
|
84
|
+
shutdown_drain_seconds: float | None = None
|
|
85
|
+
|
|
86
|
+
# Retry configurations
|
|
87
|
+
max_retry_attempts: int | None = None
|
|
88
|
+
backoff_base_seconds: float | None = None
|
|
89
|
+
max_backoff_seconds: float | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ModelEmitDaemonConfig(BaseModel):
|
|
93
|
+
"""Configuration model for the Hook Event Emit Daemon.
|
|
94
|
+
|
|
95
|
+
The emit daemon provides a non-blocking interface for Claude Code hooks
|
|
96
|
+
to emit events to Kafka. This configuration controls all operational
|
|
97
|
+
parameters including paths, limits, and timeouts.
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
socket_path: Unix domain socket path for client connections
|
|
101
|
+
pid_path: PID file path for daemon process management
|
|
102
|
+
spool_dir: Directory for spooling messages when Kafka is unavailable
|
|
103
|
+
max_payload_bytes: Maximum allowed payload size per message
|
|
104
|
+
max_memory_queue: Maximum messages to hold in memory queue
|
|
105
|
+
max_spool_messages: Maximum messages to persist in spool directory
|
|
106
|
+
max_spool_bytes: Maximum total bytes in spool directory
|
|
107
|
+
kafka_bootstrap_servers: Kafka broker addresses (host:port format)
|
|
108
|
+
kafka_client_id: Client identifier for Kafka producer
|
|
109
|
+
environment: Deployment environment for topic naming
|
|
110
|
+
socket_timeout_seconds: Timeout for socket read/write operations
|
|
111
|
+
kafka_timeout_seconds: Timeout for Kafka produce operations
|
|
112
|
+
shutdown_drain_seconds: Time to drain queues during graceful shutdown
|
|
113
|
+
max_retry_attempts: Maximum retry attempts before dropping an event
|
|
114
|
+
backoff_base_seconds: Base backoff delay in seconds for exponential backoff
|
|
115
|
+
max_backoff_seconds: Maximum backoff delay in seconds (caps exponential growth)
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> config = ModelEmitDaemonConfig(
|
|
119
|
+
... kafka_bootstrap_servers="kafka.example.com:9092",
|
|
120
|
+
... socket_path=Path("/tmp/my-emit.sock"),
|
|
121
|
+
... )
|
|
122
|
+
>>> print(config.max_payload_bytes)
|
|
123
|
+
1048576
|
|
124
|
+
|
|
125
|
+
>>> # Load with environment overrides
|
|
126
|
+
>>> config = ModelEmitDaemonConfig.with_env_overrides(
|
|
127
|
+
... kafka_bootstrap_servers="localhost:9092"
|
|
128
|
+
... )
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(
|
|
132
|
+
strict=True,
|
|
133
|
+
frozen=True,
|
|
134
|
+
extra="forbid",
|
|
135
|
+
from_attributes=True,
|
|
136
|
+
validate_default=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Path configurations
|
|
140
|
+
# NOTE: /tmp is standard for Unix domain sockets - not a security issue
|
|
141
|
+
socket_path: Path = Field(
|
|
142
|
+
default=Path("/tmp/omniclaude-emit.sock"), # noqa: S108
|
|
143
|
+
description="Unix domain socket path for client connections",
|
|
144
|
+
)
|
|
145
|
+
pid_path: Path = Field(
|
|
146
|
+
default=Path("/tmp/omniclaude-emit.pid"), # noqa: S108
|
|
147
|
+
description="PID file path for daemon process management",
|
|
148
|
+
)
|
|
149
|
+
spool_dir: Path = Field(
|
|
150
|
+
default_factory=lambda: Path.home() / ".omniclaude" / "emit-spool",
|
|
151
|
+
description="Directory for spooling messages when Kafka is unavailable",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Limit configurations
|
|
155
|
+
max_payload_bytes: int = Field(
|
|
156
|
+
default=1_048_576, # 1MB
|
|
157
|
+
ge=1024, # Minimum 1KB
|
|
158
|
+
le=10_485_760, # Maximum 10MB
|
|
159
|
+
description="Maximum allowed payload size per message in bytes",
|
|
160
|
+
)
|
|
161
|
+
max_memory_queue: int = Field(
|
|
162
|
+
default=100,
|
|
163
|
+
ge=1,
|
|
164
|
+
le=10_000,
|
|
165
|
+
description="Maximum messages to hold in memory queue",
|
|
166
|
+
)
|
|
167
|
+
max_spool_messages: int = Field(
|
|
168
|
+
default=1000,
|
|
169
|
+
ge=0, # 0 disables spooling
|
|
170
|
+
le=100_000,
|
|
171
|
+
description="Maximum messages to persist in spool directory",
|
|
172
|
+
)
|
|
173
|
+
max_spool_bytes: int = Field(
|
|
174
|
+
default=10_485_760, # 10MB
|
|
175
|
+
ge=0, # 0 disables spooling
|
|
176
|
+
le=1_073_741_824, # Maximum 1GB
|
|
177
|
+
description="Maximum total bytes in spool directory",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Kafka configurations
|
|
181
|
+
kafka_bootstrap_servers: str = Field(
|
|
182
|
+
..., # Required, no default
|
|
183
|
+
min_length=1,
|
|
184
|
+
description="Kafka broker addresses (host:port format, comma-separated for multiple)",
|
|
185
|
+
)
|
|
186
|
+
# ONEX_EXCLUDE: string_id - kafka_client_id is Kafka identifier, not UUID
|
|
187
|
+
kafka_client_id: str = Field(
|
|
188
|
+
default="emit-daemon",
|
|
189
|
+
min_length=1,
|
|
190
|
+
max_length=255,
|
|
191
|
+
description="Client identifier for Kafka producer",
|
|
192
|
+
)
|
|
193
|
+
environment: str = Field(
|
|
194
|
+
default="dev",
|
|
195
|
+
pattern=r"^[a-z][a-z0-9-]*$",
|
|
196
|
+
description="Deployment environment (e.g., 'dev', 'staging', 'prod'). Used in topic names.",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Socket permissions
|
|
200
|
+
socket_permissions: int = Field(
|
|
201
|
+
default=0o660, # Owner and group read/write
|
|
202
|
+
ge=0,
|
|
203
|
+
le=0o777, # Maximum valid permission mode
|
|
204
|
+
description=(
|
|
205
|
+
"Unix permission mode for the socket file. "
|
|
206
|
+
"Default 0o660 allows owner and group read/write access. "
|
|
207
|
+
"Use 0o600 for single-user, 0o666 for multi-user development."
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Timeout configurations
|
|
212
|
+
socket_timeout_seconds: float = Field(
|
|
213
|
+
default=5.0,
|
|
214
|
+
ge=0.1,
|
|
215
|
+
le=60.0,
|
|
216
|
+
description="Timeout for socket read/write operations in seconds",
|
|
217
|
+
)
|
|
218
|
+
kafka_timeout_seconds: float = Field(
|
|
219
|
+
default=30.0,
|
|
220
|
+
ge=1.0,
|
|
221
|
+
le=300.0,
|
|
222
|
+
description="Timeout for Kafka produce operations in seconds",
|
|
223
|
+
)
|
|
224
|
+
shutdown_drain_seconds: float = Field(
|
|
225
|
+
default=10.0,
|
|
226
|
+
ge=0.0,
|
|
227
|
+
le=300.0,
|
|
228
|
+
description="Time to drain queues during graceful shutdown in seconds",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Retry configurations
|
|
232
|
+
max_retry_attempts: int = Field(
|
|
233
|
+
default=3,
|
|
234
|
+
ge=1,
|
|
235
|
+
le=10,
|
|
236
|
+
description="Maximum retry attempts before dropping an event",
|
|
237
|
+
)
|
|
238
|
+
backoff_base_seconds: float = Field(
|
|
239
|
+
default=1.0,
|
|
240
|
+
ge=0.1,
|
|
241
|
+
le=30.0,
|
|
242
|
+
description="Base backoff delay in seconds for exponential backoff",
|
|
243
|
+
)
|
|
244
|
+
max_backoff_seconds: float = Field(
|
|
245
|
+
default=60.0,
|
|
246
|
+
ge=1.0,
|
|
247
|
+
le=300.0,
|
|
248
|
+
description="Maximum backoff delay in seconds (caps exponential growth)",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@field_validator("socket_path", "pid_path", mode="after")
|
|
252
|
+
@classmethod
|
|
253
|
+
def validate_file_path_parent_exists_or_creatable(cls, v: Path) -> Path:
|
|
254
|
+
"""Validate that file path's parent directory exists or can be created.
|
|
255
|
+
|
|
256
|
+
For socket and PID files, we validate that the parent directory either
|
|
257
|
+
exists or can be created (i.e., its parent exists).
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
v: The path to validate
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The validated path
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
OnexError: If the parent directory path is invalid
|
|
267
|
+
"""
|
|
268
|
+
parent = v.parent
|
|
269
|
+
if parent.exists():
|
|
270
|
+
if not parent.is_dir():
|
|
271
|
+
raise OnexError(f"Parent path exists but is not a directory: {parent}")
|
|
272
|
+
return v
|
|
273
|
+
|
|
274
|
+
# Check if grandparent exists (parent can be created)
|
|
275
|
+
grandparent = parent.parent
|
|
276
|
+
if grandparent.exists() and grandparent.is_dir():
|
|
277
|
+
return v
|
|
278
|
+
|
|
279
|
+
raise OnexError(
|
|
280
|
+
f"Parent directory does not exist and cannot be created: {parent}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
@field_validator("spool_dir", mode="after")
|
|
284
|
+
@classmethod
|
|
285
|
+
def validate_spool_dir_creatable(cls, v: Path) -> Path:
|
|
286
|
+
"""Validate that spool directory exists or can be created.
|
|
287
|
+
|
|
288
|
+
The spool directory may be nested (e.g., ~/.omniclaude/emit-spool),
|
|
289
|
+
so we validate that at least one ancestor exists.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
v: The spool directory path to validate
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The validated path
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
OnexError: If no valid ancestor exists for directory creation
|
|
299
|
+
"""
|
|
300
|
+
if v.exists():
|
|
301
|
+
if not v.is_dir():
|
|
302
|
+
raise OnexError(f"Spool path exists but is not a directory: {v}")
|
|
303
|
+
return v
|
|
304
|
+
|
|
305
|
+
# Walk up the path to find an existing ancestor
|
|
306
|
+
current = v
|
|
307
|
+
while current != current.parent: # Stop at filesystem root
|
|
308
|
+
current = current.parent
|
|
309
|
+
if current.exists():
|
|
310
|
+
if current.is_dir():
|
|
311
|
+
return v
|
|
312
|
+
raise OnexError(
|
|
313
|
+
f"Ancestor path exists but is not a directory: {current}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
raise OnexError(f"No valid ancestor directory found for spool path: {v}")
|
|
317
|
+
|
|
318
|
+
@field_validator("socket_permissions", mode="after")
|
|
319
|
+
@classmethod
|
|
320
|
+
def validate_socket_permissions(cls, v: int) -> int:
|
|
321
|
+
"""Validate that socket permissions is a valid Unix permission mode.
|
|
322
|
+
|
|
323
|
+
Unix permissions are represented as octal values from 0o000 to 0o777.
|
|
324
|
+
Each digit represents permissions for owner, group, and others respectively.
|
|
325
|
+
Values 0-7 encode read (4), write (2), and execute (1) bits.
|
|
326
|
+
|
|
327
|
+
Note:
|
|
328
|
+
The range is already enforced by Field(ge=0, le=0o777), so this
|
|
329
|
+
validator provides explicit error messages for edge cases.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
v: The permission mode to validate (integer)
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
The validated permission mode
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
OnexError: If the permission mode is invalid
|
|
339
|
+
"""
|
|
340
|
+
# Range is enforced by Field constraints (ge=0, le=0o777)
|
|
341
|
+
# This validator provides explicit error messaging
|
|
342
|
+
if v < 0 or v > 0o777:
|
|
343
|
+
raise OnexError(
|
|
344
|
+
f"Invalid socket permissions {oct(v)}. "
|
|
345
|
+
"Must be between 0o000 and 0o777 (0-511 in decimal)."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return v
|
|
349
|
+
|
|
350
|
+
@field_validator("kafka_bootstrap_servers", mode="after")
|
|
351
|
+
@classmethod
|
|
352
|
+
def validate_bootstrap_servers_format(cls, v: str) -> str:
|
|
353
|
+
"""Validate Kafka bootstrap servers format.
|
|
354
|
+
|
|
355
|
+
Each server must be in host:port format with a valid port number.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
v: Bootstrap servers string
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The validated bootstrap servers string
|
|
362
|
+
|
|
363
|
+
Raises:
|
|
364
|
+
OnexError: If the format is invalid
|
|
365
|
+
"""
|
|
366
|
+
servers = v.strip().split(",")
|
|
367
|
+
for server in servers:
|
|
368
|
+
server = server.strip()
|
|
369
|
+
if not server:
|
|
370
|
+
raise OnexError("Bootstrap servers cannot contain empty entries")
|
|
371
|
+
if ":" not in server:
|
|
372
|
+
raise OnexError(
|
|
373
|
+
f"Invalid bootstrap server format '{server}'. "
|
|
374
|
+
"Expected 'host:port' (e.g., 'localhost:9092')"
|
|
375
|
+
)
|
|
376
|
+
host, port_str = server.rsplit(":", 1)
|
|
377
|
+
if not host:
|
|
378
|
+
raise OnexError(
|
|
379
|
+
f"Invalid bootstrap server format '{server}'. Host cannot be empty"
|
|
380
|
+
)
|
|
381
|
+
try:
|
|
382
|
+
port = int(port_str)
|
|
383
|
+
if port < 1 or port > 65535:
|
|
384
|
+
raise OnexError(
|
|
385
|
+
f"Invalid port {port} in '{server}'. "
|
|
386
|
+
"Port must be between 1 and 65535"
|
|
387
|
+
)
|
|
388
|
+
except ValueError as e:
|
|
389
|
+
raise OnexError(
|
|
390
|
+
f"Invalid port '{port_str}' in '{server}'. "
|
|
391
|
+
"Port must be a valid integer"
|
|
392
|
+
) from e
|
|
393
|
+
|
|
394
|
+
return v.strip()
|
|
395
|
+
|
|
396
|
+
@model_validator(mode="after")
|
|
397
|
+
def validate_spool_limits_consistency(self) -> ModelEmitDaemonConfig:
|
|
398
|
+
"""Validate that spool limits are consistent.
|
|
399
|
+
|
|
400
|
+
If max_spool_messages is 0 (spooling disabled), max_spool_bytes
|
|
401
|
+
should also be 0, and vice versa.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
The validated model instance
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
OnexError: If spool limits are inconsistent
|
|
408
|
+
"""
|
|
409
|
+
if self.max_spool_messages == 0 and self.max_spool_bytes > 0:
|
|
410
|
+
raise OnexError(
|
|
411
|
+
"Inconsistent spool limits: max_spool_messages is 0 (disabled) "
|
|
412
|
+
"but max_spool_bytes is non-zero. Set both to 0 to disable spooling."
|
|
413
|
+
)
|
|
414
|
+
if self.max_spool_bytes == 0 and self.max_spool_messages > 0:
|
|
415
|
+
raise OnexError(
|
|
416
|
+
"Inconsistent spool limits: max_spool_bytes is 0 (disabled) "
|
|
417
|
+
"but max_spool_messages is non-zero. Set both to 0 to disable spooling."
|
|
418
|
+
)
|
|
419
|
+
return self
|
|
420
|
+
|
|
421
|
+
@classmethod
|
|
422
|
+
def with_env_overrides(cls, **kwargs: object) -> ModelEmitDaemonConfig:
|
|
423
|
+
"""Create configuration with environment variable overrides.
|
|
424
|
+
|
|
425
|
+
Environment variables take precedence over provided kwargs.
|
|
426
|
+
If an environment variable is set, it overrides the corresponding
|
|
427
|
+
kwarg value.
|
|
428
|
+
|
|
429
|
+
Environment Variable Mapping:
|
|
430
|
+
EMIT_DAEMON_SOCKET_PATH -> socket_path
|
|
431
|
+
EMIT_DAEMON_PID_PATH -> pid_path
|
|
432
|
+
EMIT_DAEMON_SPOOL_DIR -> spool_dir
|
|
433
|
+
EMIT_DAEMON_SOCKET_PERMISSIONS -> socket_permissions (parsed as octal string)
|
|
434
|
+
EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS -> kafka_bootstrap_servers
|
|
435
|
+
EMIT_DAEMON_KAFKA_CLIENT_ID -> kafka_client_id
|
|
436
|
+
EMIT_DAEMON_MAX_PAYLOAD_BYTES -> max_payload_bytes
|
|
437
|
+
EMIT_DAEMON_MAX_MEMORY_QUEUE -> max_memory_queue
|
|
438
|
+
EMIT_DAEMON_MAX_SPOOL_MESSAGES -> max_spool_messages
|
|
439
|
+
EMIT_DAEMON_MAX_SPOOL_BYTES -> max_spool_bytes
|
|
440
|
+
EMIT_DAEMON_SOCKET_TIMEOUT_SECONDS -> socket_timeout_seconds
|
|
441
|
+
EMIT_DAEMON_KAFKA_TIMEOUT_SECONDS -> kafka_timeout_seconds
|
|
442
|
+
EMIT_DAEMON_SHUTDOWN_DRAIN_SECONDS -> shutdown_drain_seconds
|
|
443
|
+
EMIT_DAEMON_MAX_RETRY_ATTEMPTS -> max_retry_attempts
|
|
444
|
+
EMIT_DAEMON_BACKOFF_BASE_SECONDS -> backoff_base_seconds
|
|
445
|
+
EMIT_DAEMON_MAX_BACKOFF_SECONDS -> max_backoff_seconds
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
**kwargs: Base configuration values to use if env vars not set
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Configuration instance with environment overrides applied
|
|
452
|
+
|
|
453
|
+
Example:
|
|
454
|
+
>>> import os
|
|
455
|
+
>>> os.environ["EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS"] = "kafka:9092"
|
|
456
|
+
>>> config = ModelEmitDaemonConfig.with_env_overrides(
|
|
457
|
+
... kafka_bootstrap_servers="localhost:9092" # Overridden by env
|
|
458
|
+
... )
|
|
459
|
+
>>> config.kafka_bootstrap_servers
|
|
460
|
+
'kafka:9092'
|
|
461
|
+
"""
|
|
462
|
+
# Marker for fields that should be parsed as octal integers
|
|
463
|
+
OCTAL_INT = "octal_int"
|
|
464
|
+
|
|
465
|
+
# Environment variable to field mapping with type converters
|
|
466
|
+
# Uses object type for converter since it can be type, str marker, or callable
|
|
467
|
+
env_mappings: dict[str, tuple[str, object]] = {
|
|
468
|
+
"EMIT_DAEMON_SOCKET_PATH": ("socket_path", Path),
|
|
469
|
+
"EMIT_DAEMON_PID_PATH": ("pid_path", Path),
|
|
470
|
+
"EMIT_DAEMON_SPOOL_DIR": ("spool_dir", Path),
|
|
471
|
+
# NOTE: socket_permissions uses octal string parsing (e.g., "660" -> 0o660)
|
|
472
|
+
"EMIT_DAEMON_SOCKET_PERMISSIONS": ("socket_permissions", OCTAL_INT),
|
|
473
|
+
"EMIT_DAEMON_KAFKA_BOOTSTRAP_SERVERS": ("kafka_bootstrap_servers", str),
|
|
474
|
+
"EMIT_DAEMON_KAFKA_CLIENT_ID": ("kafka_client_id", str),
|
|
475
|
+
"EMIT_DAEMON_ENVIRONMENT": ("environment", str),
|
|
476
|
+
"EMIT_DAEMON_MAX_PAYLOAD_BYTES": ("max_payload_bytes", int),
|
|
477
|
+
"EMIT_DAEMON_MAX_MEMORY_QUEUE": ("max_memory_queue", int),
|
|
478
|
+
"EMIT_DAEMON_MAX_SPOOL_MESSAGES": ("max_spool_messages", int),
|
|
479
|
+
"EMIT_DAEMON_MAX_SPOOL_BYTES": ("max_spool_bytes", int),
|
|
480
|
+
"EMIT_DAEMON_SOCKET_TIMEOUT_SECONDS": ("socket_timeout_seconds", float),
|
|
481
|
+
"EMIT_DAEMON_KAFKA_TIMEOUT_SECONDS": ("kafka_timeout_seconds", float),
|
|
482
|
+
"EMIT_DAEMON_SHUTDOWN_DRAIN_SECONDS": ("shutdown_drain_seconds", float),
|
|
483
|
+
"EMIT_DAEMON_MAX_RETRY_ATTEMPTS": ("max_retry_attempts", int),
|
|
484
|
+
"EMIT_DAEMON_BACKOFF_BASE_SECONDS": ("backoff_base_seconds", float),
|
|
485
|
+
"EMIT_DAEMON_MAX_BACKOFF_SECONDS": ("max_backoff_seconds", float),
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
# Build intermediate config using strongly typed input model
|
|
489
|
+
# This provides early validation and eliminates union types
|
|
490
|
+
input_fields: dict[str, object] = {}
|
|
491
|
+
|
|
492
|
+
# First, apply provided kwargs (filter None values)
|
|
493
|
+
for key, value in kwargs.items():
|
|
494
|
+
if value is not None:
|
|
495
|
+
input_fields[key] = value
|
|
496
|
+
|
|
497
|
+
# Then, apply environment variable overrides (env takes precedence)
|
|
498
|
+
for env_var, (field_name, field_type) in env_mappings.items():
|
|
499
|
+
env_value = os.environ.get(env_var)
|
|
500
|
+
if env_value is not None:
|
|
501
|
+
try:
|
|
502
|
+
if field_type is Path:
|
|
503
|
+
input_fields[field_name] = Path(env_value)
|
|
504
|
+
elif field_type == OCTAL_INT:
|
|
505
|
+
# Parse as octal string (e.g., "660" -> 0o660 = 432)
|
|
506
|
+
# Handles both "660" and "0o660" formats
|
|
507
|
+
input_fields[field_name] = int(env_value, 8)
|
|
508
|
+
elif field_type is int:
|
|
509
|
+
input_fields[field_name] = int(env_value)
|
|
510
|
+
elif field_type is float:
|
|
511
|
+
input_fields[field_name] = float(env_value)
|
|
512
|
+
else:
|
|
513
|
+
input_fields[field_name] = env_value
|
|
514
|
+
except ValueError:
|
|
515
|
+
# Skip invalid env values, let Pydantic validation handle
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
# Validate through input model first (catches typos, provides type safety)
|
|
519
|
+
# Then extract only set values for final model construction
|
|
520
|
+
input_model = ModelEmitDaemonConfigInput.model_validate(input_fields)
|
|
521
|
+
final_config = input_model.model_dump(exclude_none=True)
|
|
522
|
+
|
|
523
|
+
return cls.model_validate(final_config)
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def spooling_enabled(self) -> bool:
|
|
527
|
+
"""Check if message spooling is enabled.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
True if both max_spool_messages and max_spool_bytes are non-zero
|
|
531
|
+
"""
|
|
532
|
+
return self.max_spool_messages > 0 and self.max_spool_bytes > 0
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
__all__: list[str] = ["ModelEmitDaemonConfig", "ModelEmitDaemonConfigInput"]
|