omnibase_infra 0.2.1__py3-none-any.whl → 0.2.3__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/__init__.py +1 -1
- omnibase_infra/adapters/adapter_onex_tool_execution.py +451 -0
- omnibase_infra/capabilities/__init__.py +15 -0
- omnibase_infra/capabilities/capability_inference_rules.py +211 -0
- omnibase_infra/capabilities/contract_capability_extractor.py +221 -0
- omnibase_infra/capabilities/intent_type_extractor.py +160 -0
- omnibase_infra/cli/commands.py +1 -1
- omnibase_infra/configs/widget_mapping.yaml +176 -0
- omnibase_infra/contracts/handlers/filesystem/handler_contract.yaml +5 -2
- omnibase_infra/contracts/handlers/mcp/handler_contract.yaml +5 -2
- omnibase_infra/enums/__init__.py +6 -0
- omnibase_infra/enums/enum_handler_error_type.py +10 -0
- omnibase_infra/enums/enum_handler_source_mode.py +72 -0
- omnibase_infra/enums/enum_kafka_acks.py +99 -0
- omnibase_infra/errors/error_compute_registry.py +4 -1
- omnibase_infra/errors/error_event_bus_registry.py +4 -1
- omnibase_infra/errors/error_infra.py +3 -1
- omnibase_infra/errors/error_policy_registry.py +4 -1
- omnibase_infra/event_bus/event_bus_kafka.py +1 -1
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +59 -10
- omnibase_infra/handlers/__init__.py +8 -1
- omnibase_infra/handlers/handler_consul.py +7 -1
- omnibase_infra/handlers/handler_db.py +10 -3
- omnibase_infra/handlers/handler_graph.py +10 -5
- omnibase_infra/handlers/handler_http.py +8 -2
- omnibase_infra/handlers/handler_intent.py +387 -0
- omnibase_infra/handlers/handler_mcp.py +745 -63
- omnibase_infra/handlers/handler_vault.py +11 -5
- omnibase_infra/handlers/mixins/mixin_consul_kv.py +4 -3
- omnibase_infra/handlers/mixins/mixin_consul_service.py +2 -1
- omnibase_infra/handlers/registration_storage/handler_registration_storage_postgres.py +7 -0
- omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +308 -4
- omnibase_infra/handlers/service_discovery/models/model_service_info.py +10 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +3 -2
- omnibase_infra/mixins/mixin_node_introspection.py +42 -7
- omnibase_infra/mixins/mixin_retry_execution.py +1 -1
- omnibase_infra/models/discovery/model_introspection_config.py +11 -0
- omnibase_infra/models/handlers/__init__.py +48 -5
- omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py +162 -0
- omnibase_infra/models/handlers/model_contract_discovery_result.py +6 -4
- omnibase_infra/models/handlers/model_handler_descriptor.py +15 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +220 -0
- omnibase_infra/models/mcp/__init__.py +15 -0
- omnibase_infra/models/mcp/model_mcp_contract_config.py +80 -0
- omnibase_infra/models/mcp/model_mcp_server_config.py +67 -0
- omnibase_infra/models/mcp/model_mcp_tool_definition.py +73 -0
- omnibase_infra/models/mcp/model_mcp_tool_parameter.py +35 -0
- omnibase_infra/models/registration/model_node_capabilities.py +11 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +9 -0
- omnibase_infra/models/runtime/model_handler_contract.py +25 -9
- omnibase_infra/models/runtime/model_loaded_handler.py +9 -0
- omnibase_infra/nodes/architecture_validator/contract_architecture_validator.yaml +0 -5
- omnibase_infra/nodes/architecture_validator/registry/registry_infra_architecture_validator.py +17 -10
- omnibase_infra/nodes/effects/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_orchestrator/contract.yaml +7 -0
- omnibase_infra/nodes/node_registration_orchestrator/handlers/handler_node_introspected.py +86 -1
- omnibase_infra/nodes/node_registration_orchestrator/introspection_event_router.py +3 -3
- omnibase_infra/nodes/node_registration_orchestrator/plugin.py +1 -1
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +9 -8
- omnibase_infra/nodes/node_registration_orchestrator/timeout_coordinator.py +4 -3
- omnibase_infra/nodes/node_registration_orchestrator/wiring.py +14 -13
- omnibase_infra/nodes/node_registration_storage_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registration_storage_effect/node.py +4 -1
- omnibase_infra/nodes/node_registration_storage_effect/registry/registry_infra_registration_storage.py +47 -26
- omnibase_infra/nodes/node_registry_effect/contract.yaml +0 -5
- omnibase_infra/nodes/node_registry_effect/handlers/handler_partial_retry.py +2 -1
- omnibase_infra/nodes/node_service_discovery_effect/registry/registry_infra_service_discovery.py +28 -20
- omnibase_infra/plugins/examples/plugin_json_normalizer.py +2 -2
- omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +2 -2
- omnibase_infra/plugins/plugin_compute_base.py +16 -2
- omnibase_infra/protocols/__init__.py +2 -0
- omnibase_infra/protocols/protocol_container_aware.py +200 -0
- omnibase_infra/protocols/protocol_event_projector.py +1 -1
- omnibase_infra/runtime/__init__.py +90 -1
- omnibase_infra/runtime/binding_config_resolver.py +102 -37
- omnibase_infra/runtime/constants_notification.py +75 -0
- omnibase_infra/runtime/contract_handler_discovery.py +6 -1
- omnibase_infra/runtime/handler_bootstrap_source.py +507 -0
- omnibase_infra/runtime/handler_contract_config_loader.py +603 -0
- omnibase_infra/runtime/handler_contract_source.py +267 -186
- omnibase_infra/runtime/handler_identity.py +81 -0
- omnibase_infra/runtime/handler_plugin_loader.py +19 -2
- omnibase_infra/runtime/handler_registry.py +11 -3
- omnibase_infra/runtime/handler_source_resolver.py +326 -0
- omnibase_infra/runtime/mixin_semver_cache.py +25 -1
- omnibase_infra/runtime/mixins/__init__.py +7 -0
- omnibase_infra/runtime/mixins/mixin_projector_notification_publishing.py +566 -0
- omnibase_infra/runtime/mixins/mixin_projector_sql_operations.py +31 -10
- omnibase_infra/runtime/models/__init__.py +24 -0
- omnibase_infra/runtime/models/model_health_check_result.py +2 -1
- omnibase_infra/runtime/models/model_projector_notification_config.py +171 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_config.py +112 -0
- omnibase_infra/runtime/models/model_transition_notification_outbox_metrics.py +140 -0
- omnibase_infra/runtime/models/model_transition_notification_publisher_metrics.py +357 -0
- omnibase_infra/runtime/projector_plugin_loader.py +1 -1
- omnibase_infra/runtime/projector_shell.py +229 -1
- omnibase_infra/runtime/protocol_lifecycle_executor.py +6 -6
- omnibase_infra/runtime/protocols/__init__.py +10 -0
- omnibase_infra/runtime/registry/registry_protocol_binding.py +16 -15
- omnibase_infra/runtime/registry_contract_source.py +693 -0
- omnibase_infra/runtime/registry_policy.py +9 -326
- omnibase_infra/runtime/secret_resolver.py +4 -2
- omnibase_infra/runtime/service_kernel.py +11 -3
- omnibase_infra/runtime/service_message_dispatch_engine.py +4 -2
- omnibase_infra/runtime/service_runtime_host_process.py +589 -106
- omnibase_infra/runtime/transition_notification_outbox.py +1190 -0
- omnibase_infra/runtime/transition_notification_publisher.py +764 -0
- omnibase_infra/runtime/util_container_wiring.py +6 -5
- omnibase_infra/runtime/util_wiring.py +17 -4
- omnibase_infra/schemas/schema_transition_notification_outbox.sql +245 -0
- omnibase_infra/services/__init__.py +21 -0
- omnibase_infra/services/corpus_capture.py +7 -1
- omnibase_infra/services/mcp/__init__.py +31 -0
- omnibase_infra/services/mcp/mcp_server_lifecycle.py +449 -0
- omnibase_infra/services/mcp/service_mcp_tool_discovery.py +411 -0
- omnibase_infra/services/mcp/service_mcp_tool_registry.py +329 -0
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +547 -0
- omnibase_infra/services/registry_api/__init__.py +40 -0
- omnibase_infra/services/registry_api/main.py +261 -0
- omnibase_infra/services/registry_api/models/__init__.py +66 -0
- omnibase_infra/services/registry_api/models/model_capability_widget_mapping.py +38 -0
- omnibase_infra/services/registry_api/models/model_pagination_info.py +48 -0
- omnibase_infra/services/registry_api/models/model_registry_discovery_response.py +73 -0
- omnibase_infra/services/registry_api/models/model_registry_health_response.py +49 -0
- omnibase_infra/services/registry_api/models/model_registry_instance_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_node_view.py +88 -0
- omnibase_infra/services/registry_api/models/model_registry_summary.py +60 -0
- omnibase_infra/services/registry_api/models/model_response_list_instances.py +43 -0
- omnibase_infra/services/registry_api/models/model_response_list_nodes.py +51 -0
- omnibase_infra/services/registry_api/models/model_warning.py +49 -0
- omnibase_infra/services/registry_api/models/model_widget_defaults.py +28 -0
- omnibase_infra/services/registry_api/models/model_widget_mapping.py +51 -0
- omnibase_infra/services/registry_api/routes.py +371 -0
- omnibase_infra/services/registry_api/service.py +837 -0
- omnibase_infra/services/service_capability_query.py +4 -4
- omnibase_infra/services/service_health.py +3 -2
- omnibase_infra/services/service_timeout_emitter.py +20 -3
- omnibase_infra/services/service_timeout_scanner.py +7 -3
- omnibase_infra/services/session/__init__.py +56 -0
- omnibase_infra/services/session/config_consumer.py +120 -0
- omnibase_infra/services/session/config_store.py +139 -0
- omnibase_infra/services/session/consumer.py +1007 -0
- omnibase_infra/services/session/protocol_session_aggregator.py +117 -0
- omnibase_infra/services/session/store.py +997 -0
- omnibase_infra/utils/__init__.py +19 -0
- omnibase_infra/utils/util_atomic_file.py +261 -0
- omnibase_infra/utils/util_db_transaction.py +239 -0
- omnibase_infra/utils/util_dsn_validation.py +1 -1
- omnibase_infra/utils/util_retry_optimistic.py +281 -0
- omnibase_infra/validation/__init__.py +3 -19
- omnibase_infra/validation/contracts/security.validation.yaml +114 -0
- omnibase_infra/validation/infra_validators.py +35 -24
- omnibase_infra/validation/validation_exemptions.yaml +140 -9
- omnibase_infra/validation/validator_chain_propagation.py +2 -2
- omnibase_infra/validation/validator_runtime_shape.py +1 -1
- omnibase_infra/validation/validator_security.py +473 -370
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/RECORD +161 -98
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.1.dist-info → omnibase_infra-0.2.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Registry Discovery Service.
|
|
4
|
+
|
|
5
|
+
Combines ProjectionReaderRegistration and HandlerServiceDiscoveryConsul
|
|
6
|
+
to provide a unified discovery interface for the Registry API.
|
|
7
|
+
|
|
8
|
+
Design Principles:
|
|
9
|
+
- Partial success: Returns data even if one backend fails
|
|
10
|
+
- Warnings array: Communicates backend failures without crashing
|
|
11
|
+
- Async-first: All methods are async for non-blocking I/O
|
|
12
|
+
- Correlation IDs: Full traceability across all operations
|
|
13
|
+
- Container DI: Accepts ModelONEXContainer for dependency injection
|
|
14
|
+
|
|
15
|
+
Related Tickets:
|
|
16
|
+
- OMN-1278: Contract-Driven Dashboard - Registry Discovery
|
|
17
|
+
- OMN-1282: MCP Handler Contract-Driven Config
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
from uuid import UUID, uuid4
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from omnibase_core.container import ModelONEXContainer
|
|
31
|
+
from omnibase_core.types import JsonType
|
|
32
|
+
from omnibase_infra.enums import EnumRegistrationState
|
|
33
|
+
from omnibase_infra.nodes.node_service_discovery_effect.models.enum_health_status import (
|
|
34
|
+
EnumHealthStatus,
|
|
35
|
+
)
|
|
36
|
+
from omnibase_infra.services.registry_api.models import (
|
|
37
|
+
ModelCapabilityWidgetMapping,
|
|
38
|
+
ModelPaginationInfo,
|
|
39
|
+
ModelRegistryDiscoveryResponse,
|
|
40
|
+
ModelRegistryHealthResponse,
|
|
41
|
+
ModelRegistryInstanceView,
|
|
42
|
+
ModelRegistryNodeView,
|
|
43
|
+
ModelRegistrySummary,
|
|
44
|
+
ModelWarning,
|
|
45
|
+
ModelWidgetDefaults,
|
|
46
|
+
ModelWidgetMapping,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from omnibase_infra.handlers.service_discovery import HandlerServiceDiscoveryConsul
|
|
51
|
+
from omnibase_infra.models.projection import ModelRegistrationProjection
|
|
52
|
+
from omnibase_infra.projectors import ProjectionReaderRegistration
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
# Maximum records to fetch when node_type filtering requires in-memory pagination.
|
|
57
|
+
# The projection reader API doesn't support node_type filtering, so we fetch all
|
|
58
|
+
# records matching the state filter and apply node_type filter in-memory.
|
|
59
|
+
MAX_NODE_TYPE_FILTER_FETCH = 10000
|
|
60
|
+
|
|
61
|
+
# Default config path relative to this module
|
|
62
|
+
DEFAULT_WIDGET_MAPPING_PATH = (
|
|
63
|
+
Path(__file__).parent.parent.parent / "configs" / "widget_mapping.yaml"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ServiceRegistryDiscovery:
|
|
68
|
+
"""Registry discovery service combining projection and Consul data.
|
|
69
|
+
|
|
70
|
+
Provides a unified interface for querying both registered nodes
|
|
71
|
+
(from PostgreSQL projections) and live service instances (from Consul).
|
|
72
|
+
|
|
73
|
+
Partial Success Pattern:
|
|
74
|
+
If one backend fails, the service still returns data from the
|
|
75
|
+
successful backend along with warnings indicating the failure.
|
|
76
|
+
This allows dashboards to display partial data rather than
|
|
77
|
+
showing complete errors.
|
|
78
|
+
|
|
79
|
+
Dependency Injection:
|
|
80
|
+
This service requires a ModelONEXContainer for ONEX-style dependency
|
|
81
|
+
injection. Dependencies can also be provided directly via constructor
|
|
82
|
+
parameters for testing flexibility.
|
|
83
|
+
|
|
84
|
+
Thread Safety:
|
|
85
|
+
This service is coroutine-safe. All methods are async and
|
|
86
|
+
delegate to underlying services that handle their own
|
|
87
|
+
concurrency requirements.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> # Using container for DI (container is required)
|
|
91
|
+
>>> service = ServiceRegistryDiscovery(container=container)
|
|
92
|
+
>>> response = await service.get_discovery()
|
|
93
|
+
>>>
|
|
94
|
+
>>> # With explicit dependencies (for testing)
|
|
95
|
+
>>> service = ServiceRegistryDiscovery(
|
|
96
|
+
... container=container,
|
|
97
|
+
... projection_reader=reader,
|
|
98
|
+
... consul_handler=handler,
|
|
99
|
+
... )
|
|
100
|
+
>>> response = await service.get_discovery()
|
|
101
|
+
>>> if response.warnings:
|
|
102
|
+
... logger.warning("Partial data: %s", response.warnings)
|
|
103
|
+
|
|
104
|
+
Attributes:
|
|
105
|
+
projection_reader: Reader for node registration projections.
|
|
106
|
+
consul_handler: Handler for Consul service discovery.
|
|
107
|
+
widget_mapping_path: Path to widget mapping YAML configuration.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
container: ModelONEXContainer,
|
|
113
|
+
projection_reader: ProjectionReaderRegistration | None = None,
|
|
114
|
+
consul_handler: HandlerServiceDiscoveryConsul | None = None,
|
|
115
|
+
widget_mapping_path: Path | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Initialize the registry discovery service.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
container: ONEX container for dependency injection. Required for
|
|
121
|
+
ONEX DI pattern compliance.
|
|
122
|
+
projection_reader: Optional projection reader for node registrations.
|
|
123
|
+
If not provided, node queries will return empty results with warnings.
|
|
124
|
+
consul_handler: Optional Consul handler for live instances.
|
|
125
|
+
If not provided, instance queries will return empty results with warnings.
|
|
126
|
+
widget_mapping_path: Path to widget mapping YAML file.
|
|
127
|
+
Defaults to configs/widget_mapping.yaml relative to package.
|
|
128
|
+
"""
|
|
129
|
+
self._container = container
|
|
130
|
+
|
|
131
|
+
# Resolve projection_reader: direct param > None
|
|
132
|
+
# NOTE: Container-based resolution removed in omnibase_core ^0.9.0.
|
|
133
|
+
# The new ServiceRegistry uses async interface-based resolution which
|
|
134
|
+
# doesn't fit the sync __init__ pattern. Use explicit dependency injection
|
|
135
|
+
# via the projection_reader parameter instead.
|
|
136
|
+
self._projection_reader = projection_reader
|
|
137
|
+
|
|
138
|
+
# Resolve consul_handler: direct param > None
|
|
139
|
+
# NOTE: Container-based resolution removed in omnibase_core ^0.9.0.
|
|
140
|
+
# The new ServiceRegistry uses async interface-based resolution which
|
|
141
|
+
# doesn't fit the sync __init__ pattern. Use explicit dependency injection
|
|
142
|
+
# via the consul_handler parameter instead.
|
|
143
|
+
self._consul_handler = consul_handler
|
|
144
|
+
|
|
145
|
+
self._widget_mapping_path = widget_mapping_path or DEFAULT_WIDGET_MAPPING_PATH
|
|
146
|
+
self._widget_mapping_cache: ModelWidgetMapping | None = None
|
|
147
|
+
self._widget_mapping_mtime: float | None = None
|
|
148
|
+
|
|
149
|
+
logger.info(
|
|
150
|
+
"ServiceRegistryDiscovery initialized",
|
|
151
|
+
extra={
|
|
152
|
+
"has_projection_reader": self._projection_reader is not None,
|
|
153
|
+
"has_consul_handler": self._consul_handler is not None,
|
|
154
|
+
"widget_mapping_path": str(self._widget_mapping_path),
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def has_projection_reader(self) -> bool:
|
|
160
|
+
"""Check if projection reader is configured."""
|
|
161
|
+
return self._projection_reader is not None
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def has_consul_handler(self) -> bool:
|
|
165
|
+
"""Check if Consul handler is configured."""
|
|
166
|
+
return self._consul_handler is not None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def consul_handler(self) -> HandlerServiceDiscoveryConsul | None:
|
|
170
|
+
"""Get the Consul handler for lifecycle management."""
|
|
171
|
+
return self._consul_handler
|
|
172
|
+
|
|
173
|
+
def invalidate_widget_mapping_cache(self) -> None:
|
|
174
|
+
"""Clear widget mapping cache, forcing reload on next access.
|
|
175
|
+
|
|
176
|
+
Use this method when you know the widget mapping file has changed
|
|
177
|
+
and want to force an immediate reload, rather than waiting for
|
|
178
|
+
file modification time detection.
|
|
179
|
+
|
|
180
|
+
Example:
|
|
181
|
+
>>> service.invalidate_widget_mapping_cache()
|
|
182
|
+
>>> mapping, warnings = service.get_widget_mapping() # Fresh load
|
|
183
|
+
"""
|
|
184
|
+
self._widget_mapping_cache = None
|
|
185
|
+
self._widget_mapping_mtime = None
|
|
186
|
+
logger.debug(
|
|
187
|
+
"Widget mapping cache invalidated",
|
|
188
|
+
extra={"widget_mapping_path": str(self._widget_mapping_path)},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def list_nodes(
|
|
192
|
+
self,
|
|
193
|
+
limit: int = 100,
|
|
194
|
+
offset: int = 0,
|
|
195
|
+
state: EnumRegistrationState | None = None,
|
|
196
|
+
node_type: str | None = None,
|
|
197
|
+
correlation_id: UUID | None = None,
|
|
198
|
+
) -> tuple[list[ModelRegistryNodeView], ModelPaginationInfo, list[ModelWarning]]:
|
|
199
|
+
"""List registered nodes with pagination.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
limit: Maximum number of nodes to return (1-1000).
|
|
203
|
+
offset: Number of nodes to skip for pagination.
|
|
204
|
+
state: Optional filter by registration state. When None, queries
|
|
205
|
+
all active states (ACTIVE, ACCEPTED, AWAITING_ACK, ACK_RECEIVED).
|
|
206
|
+
node_type: Optional filter by node type (effect, compute, reducer,
|
|
207
|
+
orchestrator). Case-insensitive.
|
|
208
|
+
correlation_id: Optional correlation ID for tracing.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Tuple of (nodes, pagination_info, warnings).
|
|
212
|
+
|
|
213
|
+
Note:
|
|
214
|
+
When node_type filter is specified, all matching records are fetched
|
|
215
|
+
to provide accurate pagination totals. For large datasets, consider
|
|
216
|
+
using state filters to reduce the query scope.
|
|
217
|
+
"""
|
|
218
|
+
correlation_id = correlation_id or uuid4()
|
|
219
|
+
warnings: list[ModelWarning] = []
|
|
220
|
+
nodes: list[ModelRegistryNodeView] = []
|
|
221
|
+
total = 0
|
|
222
|
+
|
|
223
|
+
if self._projection_reader is None:
|
|
224
|
+
warnings.append(
|
|
225
|
+
ModelWarning(
|
|
226
|
+
source="postgres",
|
|
227
|
+
message="Projection reader not configured",
|
|
228
|
+
code="NO_PROJECTION_READER",
|
|
229
|
+
timestamp=datetime.now(UTC),
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
try:
|
|
234
|
+
# Determine fetch limit based on whether node_type filter is applied
|
|
235
|
+
# When node_type is specified, we need all records for accurate totals
|
|
236
|
+
# since the projection reader doesn't support node_type filtering
|
|
237
|
+
if node_type:
|
|
238
|
+
# Fetch all matching records to get accurate count after filtering
|
|
239
|
+
fetch_limit = MAX_NODE_TYPE_FILTER_FETCH
|
|
240
|
+
else:
|
|
241
|
+
# No node_type filter - can use normal pagination
|
|
242
|
+
fetch_limit = limit + offset + 1 # +1 to detect has_more
|
|
243
|
+
|
|
244
|
+
# Query projections based on state filter
|
|
245
|
+
projections: list[ModelRegistrationProjection] = []
|
|
246
|
+
|
|
247
|
+
if state is not None:
|
|
248
|
+
# Single state filter
|
|
249
|
+
projections = await self._projection_reader.get_by_state(
|
|
250
|
+
state=state,
|
|
251
|
+
limit=fetch_limit,
|
|
252
|
+
correlation_id=correlation_id,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
# No state filter - query all active states and combine
|
|
256
|
+
# This provides results across all relevant states, not just ACTIVE
|
|
257
|
+
active_states = [
|
|
258
|
+
EnumRegistrationState.ACTIVE,
|
|
259
|
+
EnumRegistrationState.ACCEPTED,
|
|
260
|
+
EnumRegistrationState.AWAITING_ACK,
|
|
261
|
+
EnumRegistrationState.ACK_RECEIVED,
|
|
262
|
+
EnumRegistrationState.PENDING_REGISTRATION,
|
|
263
|
+
]
|
|
264
|
+
all_projections: list[ModelRegistrationProjection] = []
|
|
265
|
+
for query_state in active_states:
|
|
266
|
+
state_projections = await self._projection_reader.get_by_state(
|
|
267
|
+
state=query_state,
|
|
268
|
+
limit=fetch_limit,
|
|
269
|
+
correlation_id=correlation_id,
|
|
270
|
+
)
|
|
271
|
+
all_projections.extend(state_projections)
|
|
272
|
+
|
|
273
|
+
# Sort combined results by updated_at descending
|
|
274
|
+
projections = sorted(
|
|
275
|
+
all_projections,
|
|
276
|
+
key=lambda p: p.updated_at,
|
|
277
|
+
reverse=True,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Apply node_type filter in-memory if specified
|
|
281
|
+
# The projection reader API doesn't support node_type filtering
|
|
282
|
+
node_type_filter = node_type.upper() if node_type else None
|
|
283
|
+
if node_type_filter:
|
|
284
|
+
projections = [
|
|
285
|
+
p
|
|
286
|
+
for p in projections
|
|
287
|
+
if p.node_type.value.upper() == node_type_filter
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
# Calculate total from ALL filtered records (accurate count)
|
|
291
|
+
total = len(projections)
|
|
292
|
+
|
|
293
|
+
# Apply offset and limit for pagination
|
|
294
|
+
projections_slice = projections[offset : offset + limit]
|
|
295
|
+
|
|
296
|
+
# Convert to view models
|
|
297
|
+
for proj in projections_slice:
|
|
298
|
+
# Map EnumNodeKind to API node_type string
|
|
299
|
+
node_type_str = proj.node_type.value.upper()
|
|
300
|
+
if node_type_str not in (
|
|
301
|
+
"EFFECT",
|
|
302
|
+
"COMPUTE",
|
|
303
|
+
"REDUCER",
|
|
304
|
+
"ORCHESTRATOR",
|
|
305
|
+
):
|
|
306
|
+
node_type_str = "EFFECT" # Fallback
|
|
307
|
+
|
|
308
|
+
nodes.append(
|
|
309
|
+
ModelRegistryNodeView(
|
|
310
|
+
node_id=proj.entity_id,
|
|
311
|
+
name=f"onex-{proj.node_type.value}",
|
|
312
|
+
service_name=f"onex-{proj.node_type.value}-{str(proj.entity_id)[:8]}",
|
|
313
|
+
namespace=proj.domain
|
|
314
|
+
if proj.domain != "registration"
|
|
315
|
+
else None,
|
|
316
|
+
display_name=None,
|
|
317
|
+
node_type=node_type_str, # type: ignore[arg-type]
|
|
318
|
+
version=proj.node_version,
|
|
319
|
+
state=proj.current_state.value,
|
|
320
|
+
capabilities=proj.capability_tags,
|
|
321
|
+
registered_at=proj.registered_at,
|
|
322
|
+
last_heartbeat_at=proj.last_heartbeat_at,
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.exception(
|
|
328
|
+
"Failed to query projections",
|
|
329
|
+
extra={"correlation_id": str(correlation_id)},
|
|
330
|
+
)
|
|
331
|
+
warnings.append(
|
|
332
|
+
ModelWarning(
|
|
333
|
+
source="postgres",
|
|
334
|
+
message=f"Failed to query projections: {type(e).__name__}",
|
|
335
|
+
code="PROJECTION_QUERY_FAILED",
|
|
336
|
+
timestamp=datetime.now(UTC),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
pagination = ModelPaginationInfo(
|
|
341
|
+
total=total,
|
|
342
|
+
limit=limit,
|
|
343
|
+
offset=offset,
|
|
344
|
+
has_more=offset + len(nodes) < total,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return nodes, pagination, warnings
|
|
348
|
+
|
|
349
|
+
async def get_node(
|
|
350
|
+
self,
|
|
351
|
+
node_id: UUID,
|
|
352
|
+
correlation_id: UUID | None = None,
|
|
353
|
+
) -> tuple[ModelRegistryNodeView | None, list[ModelWarning]]:
|
|
354
|
+
"""Get a single node by ID.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
node_id: Node UUID to retrieve.
|
|
358
|
+
correlation_id: Optional correlation ID for tracing.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
Tuple of (node or None, warnings).
|
|
362
|
+
"""
|
|
363
|
+
correlation_id = correlation_id or uuid4()
|
|
364
|
+
warnings: list[ModelWarning] = []
|
|
365
|
+
|
|
366
|
+
if self._projection_reader is None:
|
|
367
|
+
warnings.append(
|
|
368
|
+
ModelWarning(
|
|
369
|
+
source="postgres",
|
|
370
|
+
message="Projection reader not configured",
|
|
371
|
+
code="NO_PROJECTION_READER",
|
|
372
|
+
timestamp=datetime.now(UTC),
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return None, warnings
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
proj = await self._projection_reader.get_entity_state(
|
|
379
|
+
entity_id=node_id,
|
|
380
|
+
correlation_id=correlation_id,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if proj is None:
|
|
384
|
+
return None, warnings
|
|
385
|
+
|
|
386
|
+
node_type_str = proj.node_type.value.upper()
|
|
387
|
+
if node_type_str not in ("EFFECT", "COMPUTE", "REDUCER", "ORCHESTRATOR"):
|
|
388
|
+
node_type_str = "EFFECT"
|
|
389
|
+
|
|
390
|
+
node = ModelRegistryNodeView(
|
|
391
|
+
node_id=proj.entity_id,
|
|
392
|
+
name=f"onex-{proj.node_type.value}",
|
|
393
|
+
service_name=f"onex-{proj.node_type.value}-{str(proj.entity_id)[:8]}",
|
|
394
|
+
namespace=proj.domain if proj.domain != "registration" else None,
|
|
395
|
+
display_name=None,
|
|
396
|
+
node_type=node_type_str, # type: ignore[arg-type]
|
|
397
|
+
version=proj.node_version,
|
|
398
|
+
state=proj.current_state.value,
|
|
399
|
+
capabilities=proj.capability_tags,
|
|
400
|
+
registered_at=proj.registered_at,
|
|
401
|
+
last_heartbeat_at=proj.last_heartbeat_at,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return node, warnings
|
|
405
|
+
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.exception(
|
|
408
|
+
"Failed to get node",
|
|
409
|
+
extra={"node_id": str(node_id), "correlation_id": str(correlation_id)},
|
|
410
|
+
)
|
|
411
|
+
warnings.append(
|
|
412
|
+
ModelWarning(
|
|
413
|
+
source="postgres",
|
|
414
|
+
message=f"Failed to get node: {type(e).__name__}",
|
|
415
|
+
code="NODE_QUERY_FAILED",
|
|
416
|
+
timestamp=datetime.now(UTC),
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
return None, warnings
|
|
420
|
+
|
|
421
|
+
async def list_instances(
|
|
422
|
+
self,
|
|
423
|
+
service_name: str | None = None,
|
|
424
|
+
include_unhealthy: bool = False,
|
|
425
|
+
correlation_id: UUID | None = None,
|
|
426
|
+
) -> tuple[list[ModelRegistryInstanceView], list[ModelWarning]]:
|
|
427
|
+
"""List live Consul service instances.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
service_name: Optional service name filter. If not provided,
|
|
431
|
+
queries all services from the Consul catalog.
|
|
432
|
+
include_unhealthy: Whether to include unhealthy instances.
|
|
433
|
+
correlation_id: Optional correlation ID for tracing.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Tuple of (instances, warnings).
|
|
437
|
+
"""
|
|
438
|
+
correlation_id = correlation_id or uuid4()
|
|
439
|
+
warnings: list[ModelWarning] = []
|
|
440
|
+
instances: list[ModelRegistryInstanceView] = []
|
|
441
|
+
|
|
442
|
+
if self._consul_handler is None:
|
|
443
|
+
warnings.append(
|
|
444
|
+
ModelWarning(
|
|
445
|
+
source="consul",
|
|
446
|
+
message="Consul handler not configured",
|
|
447
|
+
code="NO_CONSUL_HANDLER",
|
|
448
|
+
timestamp=datetime.now(UTC),
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
return instances, warnings
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
# Determine which services to query
|
|
455
|
+
service_names_to_query: list[str] = []
|
|
456
|
+
|
|
457
|
+
if service_name:
|
|
458
|
+
# Single service specified
|
|
459
|
+
service_names_to_query = [service_name]
|
|
460
|
+
else:
|
|
461
|
+
# Get all service names from Consul catalog
|
|
462
|
+
try:
|
|
463
|
+
all_services = await self._consul_handler.list_all_services(
|
|
464
|
+
correlation_id=correlation_id,
|
|
465
|
+
)
|
|
466
|
+
service_names_to_query = list(all_services.keys())
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.warning(
|
|
469
|
+
"Failed to list all services, falling back to empty discovery",
|
|
470
|
+
extra={
|
|
471
|
+
"error": str(e),
|
|
472
|
+
"correlation_id": str(correlation_id),
|
|
473
|
+
},
|
|
474
|
+
)
|
|
475
|
+
warnings.append(
|
|
476
|
+
ModelWarning(
|
|
477
|
+
source="consul",
|
|
478
|
+
message=f"Failed to list all services: {type(e).__name__}",
|
|
479
|
+
code="CONSUL_CATALOG_FAILED",
|
|
480
|
+
timestamp=datetime.now(UTC),
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
return instances, warnings
|
|
484
|
+
|
|
485
|
+
# Query each service for its instances
|
|
486
|
+
for svc_name in service_names_to_query:
|
|
487
|
+
try:
|
|
488
|
+
service_instances = (
|
|
489
|
+
await self._consul_handler.get_all_service_instances(
|
|
490
|
+
service_name=svc_name,
|
|
491
|
+
include_unhealthy=include_unhealthy,
|
|
492
|
+
correlation_id=correlation_id,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
for svc in service_instances:
|
|
497
|
+
# Map EnumHealthStatus to API health_status string
|
|
498
|
+
health_status: str
|
|
499
|
+
if svc.health_status == EnumHealthStatus.HEALTHY:
|
|
500
|
+
health_status = "passing"
|
|
501
|
+
elif svc.health_status == EnumHealthStatus.UNHEALTHY:
|
|
502
|
+
health_status = "critical"
|
|
503
|
+
else:
|
|
504
|
+
health_status = "unknown"
|
|
505
|
+
|
|
506
|
+
instances.append(
|
|
507
|
+
ModelRegistryInstanceView(
|
|
508
|
+
node_id=svc.service_id,
|
|
509
|
+
service_name=svc.service_name,
|
|
510
|
+
service_id=svc.service_id,
|
|
511
|
+
instance_id=svc.service_id,
|
|
512
|
+
address=svc.address or "unknown",
|
|
513
|
+
port=svc.port or 0,
|
|
514
|
+
health_status=health_status, # type: ignore[arg-type]
|
|
515
|
+
health_output=svc.health_output,
|
|
516
|
+
last_check_at=svc.last_check_at or svc.registered_at,
|
|
517
|
+
tags=list(svc.tags),
|
|
518
|
+
meta=svc.metadata,
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
# Log but continue with other services (partial success)
|
|
524
|
+
logger.warning(
|
|
525
|
+
"Failed to query service instances",
|
|
526
|
+
extra={
|
|
527
|
+
"service_name": svc_name,
|
|
528
|
+
"error": str(e),
|
|
529
|
+
"correlation_id": str(correlation_id),
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
warnings.append(
|
|
533
|
+
ModelWarning(
|
|
534
|
+
source="consul",
|
|
535
|
+
message=f"Failed to query service '{svc_name}': {type(e).__name__}",
|
|
536
|
+
code="CONSUL_SERVICE_QUERY_FAILED",
|
|
537
|
+
timestamp=datetime.now(UTC),
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
except Exception as e:
|
|
542
|
+
logger.exception(
|
|
543
|
+
"Failed to discover services",
|
|
544
|
+
extra={"correlation_id": str(correlation_id)},
|
|
545
|
+
)
|
|
546
|
+
warnings.append(
|
|
547
|
+
ModelWarning(
|
|
548
|
+
source="consul",
|
|
549
|
+
message=f"Failed to discover services: {type(e).__name__}",
|
|
550
|
+
code="CONSUL_QUERY_FAILED",
|
|
551
|
+
timestamp=datetime.now(UTC),
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
return instances, warnings
|
|
556
|
+
|
|
557
|
+
async def get_discovery(
|
|
558
|
+
self,
|
|
559
|
+
limit: int = 100,
|
|
560
|
+
offset: int = 0,
|
|
561
|
+
correlation_id: UUID | None = None,
|
|
562
|
+
) -> ModelRegistryDiscoveryResponse:
|
|
563
|
+
"""Get full dashboard payload with nodes, instances, and summary.
|
|
564
|
+
|
|
565
|
+
This is the primary endpoint for dashboard consumption, providing
|
|
566
|
+
all needed data in a single request.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
limit: Maximum number of nodes to return.
|
|
570
|
+
offset: Number of nodes to skip for pagination.
|
|
571
|
+
correlation_id: Optional correlation ID for tracing.
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
Complete discovery response with all data and any warnings.
|
|
575
|
+
"""
|
|
576
|
+
correlation_id = correlation_id or uuid4()
|
|
577
|
+
all_warnings: list[ModelWarning] = []
|
|
578
|
+
|
|
579
|
+
# Fetch nodes
|
|
580
|
+
nodes, pagination, node_warnings = await self.list_nodes(
|
|
581
|
+
limit=limit,
|
|
582
|
+
offset=offset,
|
|
583
|
+
correlation_id=correlation_id,
|
|
584
|
+
)
|
|
585
|
+
all_warnings.extend(node_warnings)
|
|
586
|
+
|
|
587
|
+
# Fetch instances
|
|
588
|
+
instances, instance_warnings = await self.list_instances(
|
|
589
|
+
include_unhealthy=True,
|
|
590
|
+
correlation_id=correlation_id,
|
|
591
|
+
)
|
|
592
|
+
all_warnings.extend(instance_warnings)
|
|
593
|
+
|
|
594
|
+
# Build summary
|
|
595
|
+
by_node_type: dict[str, int] = {}
|
|
596
|
+
by_state: dict[str, int] = {}
|
|
597
|
+
active_count = 0
|
|
598
|
+
|
|
599
|
+
for node in nodes:
|
|
600
|
+
by_node_type[node.node_type] = by_node_type.get(node.node_type, 0) + 1
|
|
601
|
+
by_state[node.state] = by_state.get(node.state, 0) + 1
|
|
602
|
+
if node.state == "active":
|
|
603
|
+
active_count += 1
|
|
604
|
+
|
|
605
|
+
healthy_count = sum(1 for i in instances if i.health_status == "passing")
|
|
606
|
+
unhealthy_count = len(instances) - healthy_count
|
|
607
|
+
|
|
608
|
+
summary = ModelRegistrySummary(
|
|
609
|
+
total_nodes=pagination.total,
|
|
610
|
+
active_nodes=active_count,
|
|
611
|
+
healthy_instances=healthy_count,
|
|
612
|
+
unhealthy_instances=unhealthy_count,
|
|
613
|
+
by_node_type=by_node_type,
|
|
614
|
+
by_state=by_state,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
return ModelRegistryDiscoveryResponse(
|
|
618
|
+
timestamp=datetime.now(UTC),
|
|
619
|
+
warnings=all_warnings,
|
|
620
|
+
summary=summary,
|
|
621
|
+
nodes=nodes,
|
|
622
|
+
live_instances=instances,
|
|
623
|
+
pagination=pagination,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def get_widget_mapping(
|
|
627
|
+
self,
|
|
628
|
+
) -> tuple[ModelWidgetMapping | None, list[ModelWarning]]:
|
|
629
|
+
"""Load and return widget mapping configuration.
|
|
630
|
+
|
|
631
|
+
Returns cached mapping if available and file unchanged, otherwise
|
|
632
|
+
loads from YAML file.
|
|
633
|
+
|
|
634
|
+
The cache is automatically invalidated when the file's modification
|
|
635
|
+
time changes, enabling hot-reload of widget mappings without restart.
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Tuple of (widget_mapping or None, warnings).
|
|
639
|
+
"""
|
|
640
|
+
warnings: list[ModelWarning] = []
|
|
641
|
+
|
|
642
|
+
# Check if file has been modified since last cache
|
|
643
|
+
current_mtime: float | None = None
|
|
644
|
+
try:
|
|
645
|
+
current_mtime = self._widget_mapping_path.stat().st_mtime
|
|
646
|
+
if (
|
|
647
|
+
self._widget_mapping_cache is not None
|
|
648
|
+
and self._widget_mapping_mtime == current_mtime
|
|
649
|
+
):
|
|
650
|
+
return self._widget_mapping_cache, warnings
|
|
651
|
+
except OSError:
|
|
652
|
+
# File doesn't exist or can't be accessed - will be handled below
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
# Log cache invalidation due to file change (only when cache existed)
|
|
656
|
+
if self._widget_mapping_cache is not None and current_mtime is not None:
|
|
657
|
+
logger.info(
|
|
658
|
+
"Widget mapping cache invalidated, reloading from file",
|
|
659
|
+
extra={
|
|
660
|
+
"widget_mapping_path": str(self._widget_mapping_path),
|
|
661
|
+
"old_mtime": self._widget_mapping_mtime,
|
|
662
|
+
"new_mtime": current_mtime,
|
|
663
|
+
},
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if not self._widget_mapping_path.exists():
|
|
667
|
+
warnings.append(
|
|
668
|
+
ModelWarning(
|
|
669
|
+
source="config",
|
|
670
|
+
message=f"Widget mapping file not found: {self._widget_mapping_path}",
|
|
671
|
+
code="CONFIG_NOT_FOUND",
|
|
672
|
+
timestamp=datetime.now(UTC),
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
return None, warnings
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
with open(self._widget_mapping_path) as f:
|
|
679
|
+
data = yaml.safe_load(f)
|
|
680
|
+
|
|
681
|
+
# Parse capability mappings
|
|
682
|
+
capability_mappings: dict[str, ModelCapabilityWidgetMapping] = {}
|
|
683
|
+
for key, value in data.get("capability_mappings", {}).items():
|
|
684
|
+
capability_mappings[key] = ModelCapabilityWidgetMapping(
|
|
685
|
+
widget_type=value.get("widget_type", "info_card"),
|
|
686
|
+
defaults=ModelWidgetDefaults(**value.get("defaults", {})),
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Parse semantic mappings
|
|
690
|
+
semantic_mappings: dict[str, ModelCapabilityWidgetMapping] = {}
|
|
691
|
+
for key, value in data.get("semantic_mappings", {}).items():
|
|
692
|
+
semantic_mappings[key] = ModelCapabilityWidgetMapping(
|
|
693
|
+
widget_type=value.get("widget_type", "info_card"),
|
|
694
|
+
defaults=ModelWidgetDefaults(**value.get("defaults", {})),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
# Parse fallback
|
|
698
|
+
fallback_data = data.get("fallback", {})
|
|
699
|
+
fallback = ModelCapabilityWidgetMapping(
|
|
700
|
+
widget_type=fallback_data.get("widget_type", "info_card"),
|
|
701
|
+
defaults=ModelWidgetDefaults(**fallback_data.get("defaults", {})),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
self._widget_mapping_cache = ModelWidgetMapping(
|
|
705
|
+
version=data.get("version", "1.0.0"),
|
|
706
|
+
capability_mappings=capability_mappings,
|
|
707
|
+
semantic_mappings=semantic_mappings,
|
|
708
|
+
fallback=fallback,
|
|
709
|
+
)
|
|
710
|
+
self._widget_mapping_mtime = current_mtime
|
|
711
|
+
|
|
712
|
+
logger.debug(
|
|
713
|
+
"Widget mapping loaded",
|
|
714
|
+
extra={
|
|
715
|
+
"widget_mapping_path": str(self._widget_mapping_path),
|
|
716
|
+
"mtime": current_mtime,
|
|
717
|
+
"version": data.get("version", "1.0.0"),
|
|
718
|
+
},
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
return self._widget_mapping_cache, warnings
|
|
722
|
+
|
|
723
|
+
except Exception as e:
|
|
724
|
+
logger.exception(
|
|
725
|
+
"Failed to load widget mapping",
|
|
726
|
+
extra={"path": str(self._widget_mapping_path)},
|
|
727
|
+
)
|
|
728
|
+
warnings.append(
|
|
729
|
+
ModelWarning(
|
|
730
|
+
source="config",
|
|
731
|
+
message=f"Failed to load widget mapping: {type(e).__name__}",
|
|
732
|
+
code="CONFIG_LOAD_FAILED",
|
|
733
|
+
timestamp=datetime.now(UTC),
|
|
734
|
+
)
|
|
735
|
+
)
|
|
736
|
+
return None, warnings
|
|
737
|
+
|
|
738
|
+
async def health_check(
|
|
739
|
+
self,
|
|
740
|
+
correlation_id: UUID | None = None,
|
|
741
|
+
) -> ModelRegistryHealthResponse:
|
|
742
|
+
"""Perform health check on all backend components.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
correlation_id: Optional correlation ID for tracing.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
Health check response with component statuses.
|
|
749
|
+
"""
|
|
750
|
+
correlation_id = correlation_id or uuid4()
|
|
751
|
+
components: dict[str, JsonType] = {}
|
|
752
|
+
overall_healthy = True
|
|
753
|
+
|
|
754
|
+
# Check projection reader
|
|
755
|
+
if self._projection_reader is None:
|
|
756
|
+
components["postgres"] = {
|
|
757
|
+
"healthy": False,
|
|
758
|
+
"message": "Not configured",
|
|
759
|
+
}
|
|
760
|
+
overall_healthy = False
|
|
761
|
+
else:
|
|
762
|
+
try:
|
|
763
|
+
# Simple query to verify connection
|
|
764
|
+
await self._projection_reader.count_by_state(
|
|
765
|
+
correlation_id=correlation_id,
|
|
766
|
+
)
|
|
767
|
+
components["postgres"] = {
|
|
768
|
+
"healthy": True,
|
|
769
|
+
"message": "Connected",
|
|
770
|
+
}
|
|
771
|
+
except Exception as e:
|
|
772
|
+
components["postgres"] = {
|
|
773
|
+
"healthy": False,
|
|
774
|
+
"message": f"Error: {type(e).__name__}",
|
|
775
|
+
}
|
|
776
|
+
overall_healthy = False
|
|
777
|
+
|
|
778
|
+
# Check Consul handler
|
|
779
|
+
if self._consul_handler is None:
|
|
780
|
+
components["consul"] = {
|
|
781
|
+
"healthy": False,
|
|
782
|
+
"message": "Not configured",
|
|
783
|
+
}
|
|
784
|
+
overall_healthy = False
|
|
785
|
+
else:
|
|
786
|
+
try:
|
|
787
|
+
result = await self._consul_handler.health_check(
|
|
788
|
+
correlation_id=correlation_id,
|
|
789
|
+
)
|
|
790
|
+
components["consul"] = {
|
|
791
|
+
"healthy": result.healthy,
|
|
792
|
+
"message": result.reason,
|
|
793
|
+
}
|
|
794
|
+
if not result.healthy:
|
|
795
|
+
overall_healthy = False
|
|
796
|
+
except Exception as e:
|
|
797
|
+
components["consul"] = {
|
|
798
|
+
"healthy": False,
|
|
799
|
+
"message": f"Error: {type(e).__name__}",
|
|
800
|
+
}
|
|
801
|
+
overall_healthy = False
|
|
802
|
+
|
|
803
|
+
# Check widget mapping
|
|
804
|
+
_, mapping_warnings = self.get_widget_mapping()
|
|
805
|
+
if mapping_warnings:
|
|
806
|
+
components["config"] = {
|
|
807
|
+
"healthy": False,
|
|
808
|
+
"message": mapping_warnings[0].message,
|
|
809
|
+
}
|
|
810
|
+
else:
|
|
811
|
+
components["config"] = {
|
|
812
|
+
"healthy": True,
|
|
813
|
+
"message": "Loaded",
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
# Determine overall status
|
|
817
|
+
unhealthy_count = sum(
|
|
818
|
+
1
|
|
819
|
+
for c in components.values()
|
|
820
|
+
if isinstance(c, dict) and not c.get("healthy", False)
|
|
821
|
+
)
|
|
822
|
+
if unhealthy_count == 0:
|
|
823
|
+
status = "healthy"
|
|
824
|
+
elif unhealthy_count < len(components):
|
|
825
|
+
status = "degraded"
|
|
826
|
+
else:
|
|
827
|
+
status = "unhealthy"
|
|
828
|
+
|
|
829
|
+
return ModelRegistryHealthResponse(
|
|
830
|
+
status=status, # type: ignore[arg-type]
|
|
831
|
+
timestamp=datetime.now(UTC),
|
|
832
|
+
components=components,
|
|
833
|
+
version="1.0.0",
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
__all__ = ["ServiceRegistryDiscovery"]
|