diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index b5e8cfd4dc..0000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' - plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index d300267f18..0000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' - diff --git a/CLAUDE.md b/CLAUDE.md index 3842782767..361a2bf891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,20 +36,31 @@ **ALL changes are breaking changes. NO backwards compatibility is maintained.** -- Breaking changes are **always** acceptable and encouraged -- Remove old patterns **immediately** - do not leave deprecated code +**Rationale**: There are no external users of this system yet. This is the optimal time to make breaking changes freely, clean up technical debt aggressively, and evolve the architecture without friction. Once external users exist, this policy will be revisited. + +- Breaking changes are **always** acceptable and **strongly encouraged** +- **ALWAYS** remove deprecated functionality immediately - never leave dead code +- Remove old patterns **immediately** - do not leave deprecated code lying around - **NO** backwards compatibility documentation required - **NO** migration guides needed - **NO** deprecation periods - old APIs are simply removed -- Downstream consumers are expected to update immediately +- **NO** `# TODO: remove in v2` comments - remove it NOW - Version bumps may contain any breaking change without warning +**When you see deprecated or unused code: DELETE IT.** Do not: +- Leave it "for reference" +- Comment it out +- Add deprecation warnings +- Create compatibility shims +- Keep old function signatures with forwarding + This policy applies to: - API changes (function signatures, class interfaces) - Model changes (Pydantic field additions/removals/renames) - Import path changes (module reorganization) - Type changes (type aliases, generics) - Configuration changes (environment variables, settings) +- Removing entire features or modules that are no longer needed ### No Versioned Directories - **NEVER** create `v1_0_0/`, `v2/` directories diff --git a/pyproject.toml b/pyproject.toml index 876f60ed77..abc7fb9806 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -257,7 +257,6 @@ ignore = [ "PLR1722", # Sys exit alias (technical debt) "ISC001", # Implicit string concatenation (conflicts with formatter) "PLC0415", # Import not at top-level (intentional lazy imports in tests) - "TC006", # Add quotes to type expression in cast (style preference) ] [tool.ruff.lint.isort] diff --git a/src/omnibase_infra/handlers/mixins/mixin_consul_kv.py b/src/omnibase_infra/handlers/mixins/mixin_consul_kv.py index 83b44a0069..0c6d181d48 100644 --- a/src/omnibase_infra/handlers/mixins/mixin_consul_kv.py +++ b/src/omnibase_infra/handlers/mixins/mixin_consul_kv.py @@ -176,7 +176,7 @@ def get_func() -> tuple[ get_func, correlation_id, ) - index, data = cast(KVGetResult, result) + index, data = cast("KVGetResult", result) # Handle response - data can be None if key doesn't exist if data is None: @@ -325,7 +325,7 @@ def put_func() -> bool: put_func, correlation_id, ) - success = cast(bool, result) + success = cast("bool", result) typed_payload = ModelConsulKVPutPayload( success=success, diff --git a/src/omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py b/src/omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py index 06a4ecc910..144cca806f 100644 --- a/src/omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py +++ b/src/omnibase_infra/handlers/service_discovery/handler_service_discovery_consul.py @@ -567,15 +567,15 @@ def _query_services() -> tuple[int, list[dict[str, object]]]: service_infos: list[ModelServiceInfo] = [] for svc in services: # Cast nested dicts for type safety - svc_data = cast(dict[str, object], svc.get("Service", {})) - node_data = cast(dict[str, object], svc.get("Node", {})) + svc_data = cast("dict[str, object]", svc.get("Service", {})) + node_data = cast("dict[str, object]", svc.get("Node", {})) svc_id_str = str(svc_data.get("ID", "")) svc_name = svc_data.get("Service", "") address = svc_data.get("Address", "") or node_data.get("Address", "") port_raw = svc_data.get("Port", 0) port = int(port_raw) if isinstance(port_raw, int | float | str) else 0 - svc_tags = cast(list[str], svc_data.get("Tags", [])) - svc_meta = cast(dict[str, str], svc_data.get("Meta", {})) + svc_tags = cast("list[str]", svc_data.get("Tags", [])) + svc_meta = cast("dict[str, str]", svc_data.get("Meta", {})) if svc_id_str and svc_name and address and port: # Convert Consul service ID string to UUID diff --git a/src/omnibase_infra/mixins/mixin_node_introspection.py b/src/omnibase_infra/mixins/mixin_node_introspection.py index 56941c2c8e..8164cfb9d1 100644 --- a/src/omnibase_infra/mixins/mixin_node_introspection.py +++ b/src/omnibase_infra/mixins/mixin_node_introspection.py @@ -1353,7 +1353,7 @@ async def get_introspection_data(self) -> ModelNodeIntrospectionEvent: # Update cache - cast the model_dump output to our typed dict since we know # the structure matches (model_dump returns dict[str, Any] by default) self._introspection_cache = cast( - IntrospectionCacheDict, event.model_dump(mode="json") + "IntrospectionCacheDict", event.model_dump(mode="json") ) self._introspection_cached_at = current_time diff --git a/src/omnibase_infra/mixins/mixin_retry_execution.py b/src/omnibase_infra/mixins/mixin_retry_execution.py index 258fff8054..653b2cccce 100644 --- a/src/omnibase_infra/mixins/mixin_retry_execution.py +++ b/src/omnibase_infra/mixins/mixin_retry_execution.py @@ -196,7 +196,7 @@ def _as_circuit_breaker(self) -> ProtocolCircuitBreakerAware: This should only be called when _circuit_breaker_initialized is True, which guarantees the circuit breaker methods are available. """ - return cast(ProtocolCircuitBreakerAware, self) + return cast("ProtocolCircuitBreakerAware", self) async def _record_circuit_failure_if_enabled( self, operation: str, correlation_id: UUID diff --git a/src/omnibase_infra/models/handlers/__init__.py b/src/omnibase_infra/models/handlers/__init__.py index b81a6333d8..a85f946062 100644 --- a/src/omnibase_infra/models/handlers/__init__.py +++ b/src/omnibase_infra/models/handlers/__init__.py @@ -12,6 +12,10 @@ Added ModelHandlerDescriptor and ModelContractDiscoveryResult for OMN-1097 filesystem handler discovery. +.. versionchanged:: 0.6.4 + Added ModelBootstrapHandlerDescriptor for OMN-1087 bootstrap handler + validation with required handler_class field. + Note: ModelContractDiscoveryResult uses a forward reference to ModelHandlerValidationError to avoid circular imports. The forward @@ -20,10 +24,14 @@ tests/unit/runtime/test_handler_contract_source.py. """ +from omnibase_infra.models.handlers.model_bootstrap_handler_descriptor import ( + ModelBootstrapHandlerDescriptor, +) from omnibase_infra.models.handlers.model_contract_discovery_result import ( ModelContractDiscoveryResult, ) from omnibase_infra.models.handlers.model_handler_descriptor import ( + LiteralHandlerKind, ModelHandlerDescriptor, ) from omnibase_infra.models.handlers.model_handler_identifier import ( @@ -31,6 +39,8 @@ ) __all__ = [ + "LiteralHandlerKind", + "ModelBootstrapHandlerDescriptor", "ModelContractDiscoveryResult", "ModelHandlerDescriptor", "ModelHandlerIdentifier", diff --git a/src/omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py b/src/omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py new file mode 100644 index 0000000000..d2a1217117 --- /dev/null +++ b/src/omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Bootstrap Handler Descriptor Model with Required handler_class. + +This module provides ModelBootstrapHandlerDescriptor, a specialized handler +descriptor for bootstrap handlers that REQUIRES the handler_class field to be set. + +Bootstrap handlers are hardcoded handlers that must always specify their +implementation class for dynamic import. Unlike contract-discovered handlers +where handler_class may be optional (inferred from convention), bootstrap +handlers have no contract file to derive the class from. + +Part of OMN-1087: Implement HandlerBootstrapSource descriptor-based validation. + +See Also: + - ModelHandlerDescriptor: Base descriptor with optional handler_class + - HandlerBootstrapSource: Source that uses this specialized descriptor + - BootstrapEffectDefinition: TypedDict for bootstrap handler definitions + +.. versionadded:: 0.6.4 + Created as part of OMN-1087 bootstrap handler validation. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from omnibase_infra.models.handlers.model_handler_descriptor import ( + ModelHandlerDescriptor, +) + + +class ModelBootstrapHandlerDescriptor(ModelHandlerDescriptor): + """Handler descriptor for bootstrap handlers with required handler_class. + + This specialized descriptor extends ModelHandlerDescriptor to enforce that + handler_class is always set. Bootstrap handlers are hardcoded and must + specify their implementation class since there is no contract file to + derive the class from. + + The key difference from ModelHandlerDescriptor: + - handler_class: Required (str) instead of optional (str | None) + + All other fields maintain the same constraints as the parent class. + + Attributes: + handler_id: Unique identifier for the handler (e.g., "bootstrap.consul"). + name: Human-readable name for the handler. + version: Semantic version (ModelSemVer). Accepts string, dict, or ModelSemVer. + handler_kind: Handler kind (compute, effect, reducer, orchestrator). + input_model: Fully qualified input model class path. + output_model: Fully qualified output model class path. + description: Optional description of the handler. + handler_class: REQUIRED fully qualified Python class path for dynamic import. + contract_path: Path to the source contract file (typically None for bootstrap). + + Example: + Create a bootstrap handler descriptor: + + >>> descriptor = ModelBootstrapHandlerDescriptor( + ... handler_id="bootstrap.consul", + ... name="Consul Handler", + ... version="1.0.0", + ... handler_kind="effect", + ... input_model="omnibase_infra.models.types.JsonDict", + ... output_model="omnibase_core.models.dispatch.ModelHandlerOutput", + ... handler_class="omnibase_infra.handlers.handler_consul.HandlerConsul", + ... ) + >>> descriptor.handler_class + 'omnibase_infra.handlers.handler_consul.HandlerConsul' + + Missing handler_class raises ValidationError: + + >>> from pydantic import ValidationError + >>> try: + ... ModelBootstrapHandlerDescriptor( + ... handler_id="bootstrap.consul", + ... name="Consul Handler", + ... version="1.0.0", + ... handler_kind="effect", + ... input_model="omnibase_infra.models.types.JsonDict", + ... output_model="omnibase_core.models.dispatch.ModelHandlerOutput", + ... # handler_class omitted - will fail + ... ) + ... except ValidationError as e: + ... print("Validation failed as expected") + Validation failed as expected + + Raises: + ValidationError: If handler_class is not provided or is None. + + .. versionadded:: 0.6.4 + Created as part of OMN-1087 bootstrap handler validation. + """ + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=True, + ) + + # Override handler_class to be required (no default, not optional) + # The Field() definition must include the pattern constraint from parent + handler_class: str = Field( + ..., + min_length=3, + pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)+$", + description=( + "REQUIRED: Fully qualified Python class path for dynamic handler import. " + "Bootstrap handlers must always specify this field since they have no " + "contract file to derive the class from. " + "Example: 'omnibase_infra.handlers.handler_consul.HandlerConsul'" + ), + ) + + def to_base_descriptor(self) -> ModelHandlerDescriptor: + """Convert to base ModelHandlerDescriptor for API compatibility. + + This method allows bootstrap descriptors to be used where the base + ModelHandlerDescriptor type is expected, while maintaining the + validation benefits of the bootstrap-specific model. + + Implementation Notes: + Uses ``model_dump()`` without ``exclude_unset=True`` because: + + 1. **Field parity**: This child class has NO extra fields beyond the + parent. The only difference is ``handler_class`` type constraint + (required ``str`` vs optional ``str | None``). + + 2. **Type compatibility**: A ``str`` value from child is valid where + parent expects ``str | None``. + + 3. **Complete copy**: ``model_dump()`` ensures all fields are copied, + including those set to their default values. + + Using ``exclude_unset=True`` would risk excluding fields that have + defaults but were explicitly set to those defaults during construction. + + If future versions add child-specific fields not in parent, this + method MUST be updated to use ``exclude={'new_field'}`` or refactored. + + Returns: + ModelHandlerDescriptor instance with all fields copied. + + Example: + >>> bootstrap_desc = ModelBootstrapHandlerDescriptor( + ... handler_id="bootstrap.consul", + ... name="Consul Handler", + ... version="1.0.0", + ... handler_kind="effect", + ... input_model="omnibase_infra.models.types.JsonDict", + ... output_model="omnibase_core.models.dispatch.ModelHandlerOutput", + ... handler_class="omnibase_infra.handlers.handler_consul.HandlerConsul", + ... ) + >>> base_desc = bootstrap_desc.to_base_descriptor() + >>> isinstance(base_desc, ModelHandlerDescriptor) + True + """ + return ModelHandlerDescriptor(**self.model_dump()) + + +__all__ = ["ModelBootstrapHandlerDescriptor"] diff --git a/src/omnibase_infra/models/handlers/model_handler_descriptor.py b/src/omnibase_infra/models/handlers/model_handler_descriptor.py index e60e364dbd..034554e800 100644 --- a/src/omnibase_infra/models/handlers/model_handler_descriptor.py +++ b/src/omnibase_infra/models/handlers/model_handler_descriptor.py @@ -99,6 +99,7 @@ class ModelHandlerDescriptor(BaseModel): input_model: Fully qualified input model class path. output_model: Fully qualified output model class path. description: Optional description of the handler. + handler_class: Fully qualified Python class path for dynamic handler import. contract_path: Path to the source contract file. Example: @@ -176,6 +177,12 @@ class ModelHandlerDescriptor(BaseModel): default=None, description="Optional description of the handler", ) + handler_class: str | None = Field( + default=None, + min_length=3, + pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)+$", + description="Fully qualified Python class path for dynamic handler import (e.g., 'omnibase_infra.handlers.handler_consul.HandlerConsul')", + ) contract_path: str | None = Field( default=None, description="Path to the source contract file", diff --git a/src/omnibase_infra/nodes/node_registration_orchestrator/wiring.py b/src/omnibase_infra/nodes/node_registration_orchestrator/wiring.py index bf65043bcc..fbd8290b5a 100644 --- a/src/omnibase_infra/nodes/node_registration_orchestrator/wiring.py +++ b/src/omnibase_infra/nodes/node_registration_orchestrator/wiring.py @@ -579,7 +579,7 @@ async def get_projection_reader_from_container( _validate_service_registry(container, "resolve ProjectionReaderRegistration") try: return cast( - ProjectionReaderRegistration, + "ProjectionReaderRegistration", await container.service_registry.resolve_service( ProjectionReaderRegistration ), @@ -622,7 +622,7 @@ async def get_handler_node_introspected_from_container( _validate_service_registry(container, "resolve HandlerNodeIntrospected") try: return cast( - HandlerNodeIntrospected, + "HandlerNodeIntrospected", await container.service_registry.resolve_service(HandlerNodeIntrospected), ) except Exception as e: @@ -663,7 +663,7 @@ async def get_handler_runtime_tick_from_container( _validate_service_registry(container, "resolve HandlerRuntimeTick") try: return cast( - HandlerRuntimeTick, + "HandlerRuntimeTick", await container.service_registry.resolve_service(HandlerRuntimeTick), ) except Exception as e: @@ -704,7 +704,7 @@ async def get_handler_node_registration_acked_from_container( _validate_service_registry(container, "resolve HandlerNodeRegistrationAcked") try: return cast( - HandlerNodeRegistrationAcked, + "HandlerNodeRegistrationAcked", await container.service_registry.resolve_service( HandlerNodeRegistrationAcked ), diff --git a/src/omnibase_infra/plugins/examples/plugin_json_normalizer.py b/src/omnibase_infra/plugins/examples/plugin_json_normalizer.py index a704206d1b..dc38d12e89 100644 --- a/src/omnibase_infra/plugins/examples/plugin_json_normalizer.py +++ b/src/omnibase_infra/plugins/examples/plugin_json_normalizer.py @@ -75,12 +75,12 @@ def execute( effective_max_depth = self.MAX_RECURSION_DEPTH try: - json_data = cast(object, input_data.get("json", {})) + json_data = cast("object", input_data.get("json", {})) normalized: object = self._sort_keys_recursively( json_data, _max_depth=effective_max_depth ) output: JsonNormalizerOutput = {"normalized": normalized} - return cast(ModelPluginOutputData, output) + return cast("ModelPluginOutputData", output) except RecursionError as e: raise OnexError( diff --git a/src/omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py b/src/omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py index fe200269f2..bb24322ed2 100644 --- a/src/omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py +++ b/src/omnibase_infra/plugins/examples/plugin_json_normalizer_error_handling.py @@ -80,14 +80,14 @@ def execute( try: # Retrieve JSON data with safe default - json_data = cast(object, input_data.get("json", {})) + json_data = cast("object", input_data.get("json", {})) # Perform pure deterministic computation normalized = self._sort_keys_recursively(json_data) # Return result with correlation_id for tracing return cast( - ModelPluginOutputData, + "ModelPluginOutputData", { "normalized": normalized, "correlation_id": correlation_id, diff --git a/src/omnibase_infra/runtime/__init__.py b/src/omnibase_infra/runtime/__init__.py index 9370a45d4e..8ad247a79a 100644 --- a/src/omnibase_infra/runtime/__init__.py +++ b/src/omnibase_infra/runtime/__init__.py @@ -145,6 +145,12 @@ MAX_CONTRACT_SIZE, ) +# Handler bootstrap source (OMN-1087) +from omnibase_infra.runtime.handler_bootstrap_source import ( + HandlerBootstrapSource, + SOURCE_TYPE_BOOTSTRAP, +) + # Binding config resolver (OMN-765) from omnibase_infra.runtime.binding_config_resolver import BindingConfigResolver from omnibase_infra.runtime.protocol_handler_plugin_loader import ( @@ -277,6 +283,9 @@ "HandlerPluginLoader", "MAX_CONTRACT_SIZE", "ProtocolHandlerPluginLoader", + # Handler bootstrap source (OMN-1087) + "HandlerBootstrapSource", + "SOURCE_TYPE_BOOTSTRAP", # Binding config resolver (OMN-765) "BindingConfigResolver", # Handler discovery protocol and implementation (OMN-1133) diff --git a/src/omnibase_infra/runtime/handler_bootstrap_source.py b/src/omnibase_infra/runtime/handler_bootstrap_source.py new file mode 100644 index 0000000000..d757ddc881 --- /dev/null +++ b/src/omnibase_infra/runtime/handler_bootstrap_source.py @@ -0,0 +1,416 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Handler Bootstrap Source for Hardcoded Handler Registration. + +This module provides HandlerBootstrapSource, which centralizes all hardcoded handler +wiring that was previously scattered in util_wiring.py. This source implements +ProtocolContractSource and provides handler descriptors for the core infrastructure +handlers (Consul, Database, HTTP, Vault) without requiring contract.yaml files. + +Part of OMN-1087: HandlerBootstrapSource for hardcoded handler registration. + +The bootstrap source provides handler descriptors for effect handlers that interact +with external infrastructure services. These handlers use envelope-based routing +and are registered as the foundation of the ONEX runtime handler ecosystem. + +Registered Handlers: + - consul: HandlerConsul for HashiCorp Consul service discovery + - db: HandlerDb for PostgreSQL database operations + - http: HandlerHttpRest for HTTP/REST protocol operations + - vault: HandlerVault for HashiCorp Vault secret management + +All handlers are registered with handler_kind="effect" as they perform external I/O +operations with infrastructure services. + +See Also: + - ProtocolContractSource: Protocol definition for handler sources + - HandlerContractSource: Filesystem-based contract discovery source + - util_wiring: Module that previously contained hardcoded handler wiring + - ModelHandlerDescriptor: Descriptor model for discovered handlers + +.. versionadded:: 0.6.4 + Created as part of OMN-1087 bootstrap handler registration. +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import TypedDict, final + +from omnibase_infra.models.handlers import ( + LiteralHandlerKind, + ModelBootstrapHandlerDescriptor, + ModelContractDiscoveryResult, + ModelHandlerDescriptor, +) +from omnibase_infra.runtime.protocol_contract_source import ProtocolContractSource + + +class BootstrapEffectDefinition(TypedDict): + """Type definition for bootstrap effect node configuration entries. + + This TypedDict provides compile-time type safety for the hardcoded effect + definitions, ensuring kind values are correctly typed as LiteralHandlerKind + rather than generic str. This eliminates the need for type: ignore comments + when constructing ModelBootstrapHandlerDescriptor instances. + + Note that handler_class is a required field here, matching the + ModelBootstrapHandlerDescriptor requirement that bootstrap handlers + must always specify their implementation class. + """ + + handler_id: str + name: str + description: str + handler_kind: LiteralHandlerKind + handler_class: str + input_model: str + output_model: str + + +# ============================================================================= +# Thread-Safe Model Rebuild Pattern (Deferred Execution) +# ============================================================================= +# +# This module uses a DEFERRED model_rebuild() pattern with thread-safe +# double-checked locking. This differs from HandlerContractSource which uses +# a simpler module-level model_rebuild() call. +# +# WHY DEFERRED (runtime) vs IMMEDIATE (module-load): +# - HandlerBootstrapSource is imported through runtime.__init__.py during +# application bootstrap, BEFORE all model dependencies are fully resolved +# - If we called model_rebuild() at module load time (like HandlerContractSource), +# it would fail with circular import errors because ModelHandlerValidationError +# may not be fully defined yet in the import chain +# - HandlerContractSource can use immediate module-level model_rebuild() because +# by the time that module is imported (via explicit user code, not runtime init), +# all dependencies are already resolved +# +# WHY THREAD-SAFE: +# - discover_handlers() may be called concurrently from multiple threads +# - Unlike module-level code (which Python imports once, thread-safely), +# runtime-invoked code needs explicit synchronization +# - The double-checked locking pattern minimizes lock contention: after the +# first successful rebuild, subsequent calls hit only the fast path check +# +# PATTERN COMPARISON: +# - HandlerBootstrapSource: Deferred + thread-safe (this file) +# - HandlerContractSource: Immediate + module-level (see that file for rationale) +# +# See Also: +# - handler_contract_source.py lines 49-68 for the immediate pattern rationale +# - OMN-1087 for the ticket tracking this design decision +# ============================================================================= + +# Lock ensures only one thread performs the rebuild +_model_rebuild_lock = threading.Lock() + +# Mutable container to track if model_rebuild() has been called +# Using a list avoids the need for global statement (PLW0603) +_model_rebuild_state: list[bool] = [False] + + +def _ensure_model_rebuilt() -> None: + """Ensure ModelContractDiscoveryResult has resolved forward references. + + This must be called before creating ModelContractDiscoveryResult instances. + It's deferred from module load time to avoid circular import issues when + this module is imported through the runtime.__init__.py chain. + + The rebuild resolves the forward reference to ModelHandlerValidationError + in the validation_errors field of ModelContractDiscoveryResult. + + Why Deferred (Not Module-Level): + Unlike HandlerContractSource which uses module-level model_rebuild(), + this module is imported early in the runtime bootstrap chain before all + model dependencies are resolved. Deferring the rebuild to first use + avoids circular import failures. + + Thread Safety: + Uses double-checked locking pattern to ensure thread-safe initialization + while minimizing lock contention after the first successful rebuild. + This is necessary because discover_handlers() may be called from multiple + threads, unlike module-level code which Python imports once. + + See Also: + handler_contract_source.py for the simpler immediate pattern used when + import order constraints don't apply. + """ + # Fast path - already rebuilt (no lock needed) + if _model_rebuild_state[0]: + return + + # Thread-safe initialization with double-checked locking + with _model_rebuild_lock: + # Re-check after acquiring lock (another thread may have completed rebuild) + if _model_rebuild_state[0]: + return + + # Import ModelHandlerValidationError here to avoid circular import at module load. + # This import MUST be in scope when model_rebuild() is called, as Pydantic uses + # the local namespace to resolve forward references in the validation_errors field. + from omnibase_infra.models.errors import ModelHandlerValidationError + + # Rebuild the model to resolve forward references. + # If this fails, provide a clear error message rather than obscure Pydantic errors. + try: + ModelContractDiscoveryResult.model_rebuild() + except Exception as e: + raise RuntimeError( + f"Failed to rebuild ModelContractDiscoveryResult during bootstrap " + f"initialization. This typically indicates a circular import or missing " + f"type definition: {e}" + ) from e + + # Keep import reference in scope - required for Pydantic forward reference resolution + _ = ModelHandlerValidationError + _model_rebuild_state[0] = True + + +logger = logging.getLogger(__name__) + +# Source type identifier for bootstrap handlers +SOURCE_TYPE_BOOTSTRAP = "BOOTSTRAP" + +# Handler type constants (matching handler_registry.py) +_HANDLER_TYPE_CONSUL = "consul" +_HANDLER_TYPE_DATABASE = "db" +_HANDLER_TYPE_HTTP = "http" +_HANDLER_TYPE_VAULT = "vault" + +# Bootstrap handler definitions. +# +# Each entry contains the metadata needed to create a ModelHandlerDescriptor: +# handler_id: Unique identifier with "bootstrap." prefix +# name: Human-readable display name +# description: Handler purpose description +# handler_kind: ONEX handler archetype (all are "effect" for I/O handlers) +# handler_class: Fully qualified Python class path for dynamic import +# input_model: Fully qualified path to input type (envelope-based handlers use JsonDict) +# output_model: Fully qualified path to output type (all handlers return ModelHandlerOutput) +# +# Design Note (handler_class vs handler_module): +# ModelHandlerDescriptor uses a single handler_class field with the fully qualified +# path (e.g., "module.path.ClassName") rather than separate handler_module and +# handler_class fields. This follows the standard Python import convention and +# avoids redundancy. The runtime extracts module/class via rsplit(".", 1): +# module_path, class_name = handler_class.rsplit(".", 1) +# See: handler_plugin_loader.py::_import_handler_class() for implementation. +# +# These handlers are the core infrastructure handlers that support envelope-based +# routing patterns for external service integration. +# +# The BootstrapEffectDefinition TypedDict ensures handler_kind is typed as LiteralHandlerKind, +# providing compile-time type safety for the hardcoded values. +_BOOTSTRAP_HANDLER_DEFINITIONS: list[BootstrapEffectDefinition] = [ + { + "handler_id": f"bootstrap.{_HANDLER_TYPE_CONSUL}", + "name": "Consul Handler", + "description": "HashiCorp Consul service discovery handler", + "handler_kind": "effect", + "handler_class": "omnibase_infra.handlers.handler_consul.HandlerConsul", + "input_model": "omnibase_infra.models.types.JsonDict", + "output_model": "omnibase_core.models.dispatch.ModelHandlerOutput", + }, + { + "handler_id": f"bootstrap.{_HANDLER_TYPE_DATABASE}", + "name": "Database Handler", + "description": "PostgreSQL database handler", + "handler_kind": "effect", + "handler_class": "omnibase_infra.handlers.handler_db.HandlerDb", + "input_model": "omnibase_infra.models.types.JsonDict", + "output_model": "omnibase_core.models.dispatch.ModelHandlerOutput", + }, + { + "handler_id": f"bootstrap.{_HANDLER_TYPE_HTTP}", + "name": "HTTP Handler", + "description": "HTTP REST protocol handler", + "handler_kind": "effect", + "handler_class": "omnibase_infra.handlers.handler_http.HandlerHttpRest", + "input_model": "omnibase_infra.models.types.JsonDict", + "output_model": "omnibase_core.models.dispatch.ModelHandlerOutput", + }, + { + "handler_id": f"bootstrap.{_HANDLER_TYPE_VAULT}", + "name": "Vault Handler", + "description": "HashiCorp Vault secret management handler", + "handler_kind": "effect", + "handler_class": "omnibase_infra.handlers.handler_vault.HandlerVault", + "input_model": "omnibase_infra.models.types.JsonDict", + "output_model": "omnibase_core.models.dispatch.ModelHandlerOutput", + }, +] + +# Version for all bootstrap handlers (hardcoded handlers use stable version) +_BOOTSTRAP_HANDLER_VERSION = "1.0.0" + + +@final +class HandlerBootstrapSource( + ProtocolContractSource +): # naming-ok: Handler prefix required by ProtocolHandlerSource convention + """Handler source that provides hardcoded bootstrap handler descriptors. + + This class implements ProtocolContractSource by returning predefined handler + descriptors for core infrastructure handlers. Unlike HandlerContractSource + which discovers handlers from filesystem contracts, this source provides + handlers that are essential for the ONEX runtime bootstrap process. + + Protocol Compliance: + This class explicitly inherits from ProtocolContractSource and implements + all required protocol methods: discover_handlers() async method and + source_type property. Protocol compliance is verified at runtime through + Python's structural subtyping and enforced by type checkers. + + Attributes: + source_type: Returns "BOOTSTRAP" as the source type identifier. + + Example: + >>> source = HandlerBootstrapSource() + >>> result = await source.discover_handlers() + >>> print(f"Found {len(result.descriptors)} bootstrap handlers") + Found 4 bootstrap handlers + >>> for desc in result.descriptors: + ... print(f" - {desc.handler_id}: {desc.description}") + - bootstrap.consul: HashiCorp Consul service discovery handler + - bootstrap.db: PostgreSQL database handler + - bootstrap.http: HTTP REST protocol handler + - bootstrap.vault: HashiCorp Vault secret management handler + + Performance Characteristics: + - No filesystem or network I/O required + - Constant time O(1) discovery (hardcoded definitions) + - Typical performance: <1ms for all handlers (local dev) + - Test threshold: 100ms (generous for CI runner variance) + - Memory: ~500 bytes per handler descriptor + + .. versionadded:: 0.6.4 + Created as part of OMN-1087 bootstrap handler registration. + """ + + @property + def source_type(self) -> str: + """Return the source type identifier. + + Returns: + "BOOTSTRAP" as the source type. + """ + return SOURCE_TYPE_BOOTSTRAP + + async def discover_handlers( + self, + ) -> ModelContractDiscoveryResult: + """Discover bootstrap handler descriptors. + + Returns predefined handler descriptors for core infrastructure handlers. + Unlike filesystem-based discovery, this method returns hardcoded + definitions that are essential for ONEX runtime bootstrap. + + Returns: + ModelContractDiscoveryResult containing bootstrap handler descriptors. + The validation_errors list will always be empty since bootstrap + handlers are hardcoded and validated at development time. + + Note: + This method is idempotent and can be called multiple times safely. + Each call returns the same set of handler descriptors. + + Implementation Note: + Uses ModelBootstrapHandlerDescriptor (which requires handler_class) + for construction validation, ensuring all bootstrap handlers have + the required handler_class field. The descriptors are instances + of ModelHandlerDescriptor due to inheritance. + """ + # Ensure forward references are resolved before creating result + _ensure_model_rebuilt() + + start_time = time.perf_counter() + descriptors: list[ModelHandlerDescriptor] = [] + + logger.debug( + "Starting bootstrap handler discovery", + extra={ + "source_type": SOURCE_TYPE_BOOTSTRAP, + "expected_handler_count": len(_BOOTSTRAP_HANDLER_DEFINITIONS), + }, + ) + + # Create descriptors from hardcoded definitions + # Uses ModelBootstrapHandlerDescriptor to enforce handler_class requirement + for handler_def in _BOOTSTRAP_HANDLER_DEFINITIONS: + descriptor = ModelBootstrapHandlerDescriptor( + handler_id=handler_def["handler_id"], + name=handler_def["name"], + version=_BOOTSTRAP_HANDLER_VERSION, + handler_kind=handler_def["handler_kind"], + input_model=handler_def["input_model"], + output_model=handler_def["output_model"], + description=handler_def["description"], + handler_class=handler_def["handler_class"], + contract_path=None, # No contract file for bootstrap handlers + ) + descriptors.append(descriptor) + + logger.debug( + "Created bootstrap handler descriptor", + extra={ + "handler_id": descriptor.handler_id, + "handler_name": descriptor.name, + "handler_kind": descriptor.handler_kind, + "source_type": SOURCE_TYPE_BOOTSTRAP, + }, + ) + + # Calculate duration and log results + duration_seconds = time.perf_counter() - start_time + self._log_discovery_results(len(descriptors), duration_seconds) + + return ModelContractDiscoveryResult( + descriptors=descriptors, + validation_errors=[], # Bootstrap handlers have no validation errors + ) + + def _log_discovery_results( + self, + discovered_count: int, + duration_seconds: float, + ) -> None: + """Log the discovery results with structured counts and timing. + + Args: + discovered_count: Number of successfully discovered handlers. + duration_seconds: Total discovery duration in seconds. + """ + # Cap handlers_per_sec at 1M to avoid float("inf") which can cause issues + # in downstream logging/monitoring systems expecting finite numbers. + # A value of 1M represents "effectively instant" discovery. + if duration_seconds > 0: + handlers_per_sec = discovered_count / duration_seconds + elif discovered_count > 0: + handlers_per_sec = 1_000_000.0 # Cap for instant discovery + else: + handlers_per_sec = 0.0 + + logger.info( + "Bootstrap handler discovery completed: " + "discovered_handler_count=%d, " + "duration_seconds=%.6f, handlers_per_second=%.1f", + discovered_count, + duration_seconds, + handlers_per_sec, + extra={ + "discovered_handler_count": discovered_count, + "validation_failure_count": 0, + "source_type": SOURCE_TYPE_BOOTSTRAP, + "duration_seconds": duration_seconds, + "handlers_per_second": handlers_per_sec, + }, + ) + + +__all__ = [ + "HandlerBootstrapSource", + "SOURCE_TYPE_BOOTSTRAP", +] diff --git a/src/omnibase_infra/runtime/handler_contract_source.py b/src/omnibase_infra/runtime/handler_contract_source.py index 187ebadd15..edf09b7055 100644 --- a/src/omnibase_infra/runtime/handler_contract_source.py +++ b/src/omnibase_infra/runtime/handler_contract_source.py @@ -46,11 +46,42 @@ ) from omnibase_infra.runtime.protocol_contract_source import ProtocolContractSource +# ============================================================================= +# Module-Level Model Rebuild Pattern (Immediate Execution) +# ============================================================================= +# +# This module uses a simple MODULE-LEVEL model_rebuild() call. This differs from +# HandlerBootstrapSource which uses a deferred, thread-safe pattern. +# +# WHY IMMEDIATE (module-load) IS SAFE HERE: +# - HandlerContractSource is NOT imported through runtime.__init__.py +# - This module is imported explicitly by user code AFTER the runtime is +# initialized and all model dependencies are resolved +# - Python's import mechanism is inherently thread-safe for module-level code: +# the import lock ensures module initialization runs exactly once, even if +# multiple threads import the same module simultaneously +# - Therefore, no explicit thread-safety mechanism (locks) is needed +# +# WHY HANDLERBOOTSTRAPSOURCE NEEDS DEFERRED PATTERN: +# - HandlerBootstrapSource is imported during runtime bootstrap (via __init__.py) +# - At that point, ModelHandlerValidationError may not be fully resolved +# - Additionally, discover_handlers() may be called from multiple threads, +# requiring explicit synchronization for the rebuild call +# +# PATTERN COMPARISON: +# - HandlerContractSource: Immediate + module-level (this file) +# - HandlerBootstrapSource: Deferred + thread-safe (see that file for rationale) +# +# See Also: +# - handler_bootstrap_source.py lines 68-100 for the deferred pattern rationale +# - OMN-1087 for the ticket tracking this design decision +# ============================================================================= +# # Rebuild ModelContractDiscoveryResult to resolve the forward reference # to ModelHandlerValidationError. This must happen after ModelHandlerValidationError # is imported to make the type available for Pydantic validation. # -# Why this pattern is used: +# Why forward reference resolution is needed: # ModelContractDiscoveryResult has a field typed as list[ModelHandlerValidationError]. # ModelHandlerValidationError imports ModelHandlerIdentifier from models.handlers. # If ModelContractDiscoveryResult directly imported ModelHandlerValidationError, @@ -477,6 +508,24 @@ def _parse_contract_file(self, contract_path: Path) -> ModelHandlerDescriptor: # Validate against ModelHandlerContract contract = ModelHandlerContract.model_validate(raw_data) + # TODO [OMN-1420]: Extract handler_class from ModelHandlerContract + # + # handler_contract.yaml files include a `handler_class` field for dynamic import + # (e.g., "omnibase_infra.handlers.handler_consul.HandlerConsul"), but + # ModelHandlerContract from omnibase_core does not have this field yet. + # + # Once ModelHandlerContract is updated to include handler_class, this code + # should be changed from: + # handler_class=raw_data.get("handler_class") + # to: + # handler_class=contract.handler_class + # + # For now, extract directly from raw YAML data to support dynamic handler loading. + # See: https://linear.app/omninode/issue/OMN-1420 + handler_class = ( + raw_data.get("handler_class") if isinstance(raw_data, dict) else None + ) + # Transform to descriptor return ModelHandlerDescriptor( handler_id=contract.handler_id, @@ -486,6 +535,7 @@ def _parse_contract_file(self, contract_path: Path) -> ModelHandlerDescriptor: input_model=contract.input_model, output_model=contract.output_model, description=contract.description, + handler_class=handler_class, contract_path=str(contract_path), ) diff --git a/src/omnibase_infra/runtime/service_message_dispatch_engine.py b/src/omnibase_infra/runtime/service_message_dispatch_engine.py index 6bb27542e9..a932b7a676 100644 --- a/src/omnibase_infra/runtime/service_message_dispatch_engine.py +++ b/src/omnibase_infra/runtime/service_message_dispatch_engine.py @@ -1512,7 +1512,9 @@ async def _execute_dispatcher( if context is not None: # Context-aware sync dispatcher - sync_ctx_dispatcher = cast(_SyncContextAwareDispatcherFunc, dispatcher) + sync_ctx_dispatcher = cast( + "_SyncContextAwareDispatcherFunc", dispatcher + ) return await loop.run_in_executor( None, sync_ctx_dispatcher, @@ -1524,7 +1526,7 @@ async def _execute_dispatcher( else: # Cast to sync-only type - safe because iscoroutinefunction check above # guarantees this branch only executes for non-async callables - sync_dispatcher = cast(_SyncDispatcherFunc, dispatcher) + sync_dispatcher = cast("_SyncDispatcherFunc", dispatcher) return await loop.run_in_executor( None, sync_dispatcher, diff --git a/src/omnibase_infra/runtime/service_runtime_host_process.py b/src/omnibase_infra/runtime/service_runtime_host_process.py index 20cd92622a..e20fb20380 100644 --- a/src/omnibase_infra/runtime/service_runtime_host_process.py +++ b/src/omnibase_infra/runtime/service_runtime_host_process.py @@ -43,6 +43,7 @@ async def main() -> None: from __future__ import annotations import asyncio +import importlib import json import logging from collections.abc import Awaitable, Callable @@ -967,33 +968,37 @@ async def stop(self) -> None: logger.info("RuntimeHostProcess stopped successfully") async def _discover_or_wire_handlers(self) -> None: - """Discover handlers from contracts or wire default handlers. + """Discover and register handlers for the runtime. This method implements the handler discovery/wiring step (Step 3) of the - start() sequence. It supports two modes: + start() sequence. + + Bootstrap Handlers (OMN-1087): + Bootstrap handlers (Consul, DB, HTTP, Vault) are ALWAYS registered first + via HandlerBootstrapSource. These core infrastructure handlers are + essential for the ONEX runtime and are loaded from descriptor-based + definitions rather than filesystem contracts. Contract-Based Discovery (OMN-1133): If contract_paths were provided at init, uses ContractHandlerDiscovery - to auto-discover and register handlers from the specified paths. + to auto-discover and register additional handlers from the specified paths. Discovery errors are logged but do not block startup, enabling graceful degradation where some handlers can be registered even if others fail to load. - Default Handler Wiring (Fallback): - If no contract_paths were provided, falls back to wire_default_handlers() - which registers the standard set of handlers (HTTP, DB, Consul, Vault). - The discovery/wiring step registers handler CLASSES with the handler registry. The subsequent _populate_handlers_from_registry() step instantiates and initializes these handler classes. """ + # Register bootstrap handlers FIRST (OMN-1087) + # Bootstrap handlers are core infrastructure handlers that are always + # available, loaded from HandlerBootstrapSource descriptors. + await self._register_bootstrap_handlers() + if self._contract_paths: # Contract-based handler discovery (OMN-1133) await self._discover_handlers_from_contracts() - else: - # Fallback to default handler wiring (existing behavior) - wire_handlers() async def _discover_handlers_from_contracts(self) -> None: """Discover and register handlers from contract files. @@ -1076,6 +1081,163 @@ async def _discover_handlers_from_contracts(self) -> None: }, ) + async def _register_bootstrap_handlers(self) -> None: + """Register core infrastructure handlers from HandlerBootstrapSource. + + This method implements descriptor-based handler registration as part of + OMN-1087. It uses HandlerBootstrapSource to discover and register the + core infrastructure handlers (Consul, DB, HTTP, Vault) without requiring + contract.yaml files on the filesystem. + + Bootstrap handlers are registered FIRST, before any contract-based or + default handler wiring. This ensures core infrastructure handlers are + always available regardless of how other handlers are discovered. + + Handler Registration Process: + 1. Create HandlerBootstrapSource instance + 2. Call discover_handlers() to get handler descriptors + 3. For each descriptor: + a. Extract protocol type from handler_id (e.g., "bootstrap.consul" -> "consul") + b. Import handler class from fully qualified class path + c. Register class with handler registry + + Error Handling: + Individual handler failures are logged but do not block registration + of other handlers. This enables graceful degradation where some + bootstrap handlers can be registered even if others fail to import. + + Related: + - HandlerBootstrapSource: Source that provides handler descriptors + - ModelHandlerDescriptor: Descriptor model with handler metadata + - _discover_or_wire_handlers: Caller that orchestrates all handler loading + """ + from omnibase_infra.runtime.handler_bootstrap_source import ( + SOURCE_TYPE_BOOTSTRAP, + HandlerBootstrapSource, + ) + + logger.info( + "Starting bootstrap handler registration", + extra={"source_type": SOURCE_TYPE_BOOTSTRAP}, + ) + + # Get handler registry for registration + handler_registry = await self._get_handler_registry() + + # Create bootstrap source and discover handlers + bootstrap_source = HandlerBootstrapSource() + discovery_result = await bootstrap_source.discover_handlers() + + registered_count = 0 + error_count = 0 + + for descriptor in discovery_result.descriptors: + try: + # Extract protocol type from handler_id + # Handler IDs are formatted as "bootstrap.{protocol_type}" + # e.g., "bootstrap.consul" -> "consul" + # removeprefix returns original string if prefix not found + protocol_type = descriptor.handler_id.removeprefix("bootstrap.") + + # Import the handler class from fully qualified path + handler_class_path = descriptor.handler_class + if handler_class_path is None: + logger.warning( + "Bootstrap handler missing handler_class, skipping", + extra={ + "handler_id": descriptor.handler_id, + "handler_name": descriptor.name, + }, + ) + error_count += 1 + continue + + # Import class using rsplit pattern (same as ContractHandlerDiscovery) + if "." not in handler_class_path: + logger.error( + "Invalid handler class path (must be fully qualified): %s", + handler_class_path, + extra={"handler_id": descriptor.handler_id}, + ) + error_count += 1 + continue + + module_path, class_name = handler_class_path.rsplit(".", 1) + module = importlib.import_module(module_path) + handler_cls = getattr(module, class_name) + + # Verify it's actually a class + if not isinstance(handler_cls, type): + logger.error( + "Handler path does not resolve to a class: %s", + handler_class_path, + extra={ + "handler_id": descriptor.handler_id, + "resolved_type": type(handler_cls).__name__, + }, + ) + error_count += 1 + continue + + # Register with handler registry + handler_registry.register(protocol_type, handler_cls) + registered_count += 1 + + logger.debug( + "Registered bootstrap handler: %s -> %s", + protocol_type, + handler_class_path, + extra={ + "handler_id": descriptor.handler_id, + "protocol_type": protocol_type, + "handler_class": handler_class_path, + "source_type": SOURCE_TYPE_BOOTSTRAP, + }, + ) + + except (ImportError, AttributeError): + # Module or class import failed + error_count += 1 + logger.exception( + "Failed to import bootstrap handler", + extra={ + "handler_id": descriptor.handler_id, + "handler_class": descriptor.handler_class, + }, + ) + + except Exception: + # Unexpected error - log but continue with other handlers + error_count += 1 + logger.exception( + "Unexpected error registering bootstrap handler", + extra={ + "handler_id": descriptor.handler_id, + "handler_class": descriptor.handler_class, + }, + ) + + # Log summary + if error_count > 0: + logger.warning( + "Bootstrap handler registration completed with errors", + extra={ + "registered_count": registered_count, + "error_count": error_count, + "total_descriptors": len(discovery_result.descriptors), + "source_type": SOURCE_TYPE_BOOTSTRAP, + }, + ) + else: + logger.info( + "Bootstrap handler registration completed successfully", + extra={ + "registered_count": registered_count, + "total_descriptors": len(discovery_result.descriptors), + "source_type": SOURCE_TYPE_BOOTSTRAP, + }, + ) + async def _populate_handlers_from_registry(self) -> None: """Populate self._handlers from handler registry (container or singleton). diff --git a/src/omnibase_infra/utils/util_dsn_validation.py b/src/omnibase_infra/utils/util_dsn_validation.py index 59844ee749..2001bbe821 100644 --- a/src/omnibase_infra/utils/util_dsn_validation.py +++ b/src/omnibase_infra/utils/util_dsn_validation.py @@ -73,7 +73,7 @@ def _assert_postgres_scheme(scheme: str) -> Literal["postgresql", "postgres"]: parameter="scheme", value=scheme, ) - return cast(Literal["postgresql", "postgres"], scheme) + return cast("Literal['postgresql', 'postgres']", scheme) def parse_and_validate_dsn(dsn: object) -> ModelParsedDSN: diff --git a/src/omnibase_infra/validation/validation_exemptions.yaml b/src/omnibase_infra/validation/validation_exemptions.yaml index 41c79889c0..6eb6609973 100644 --- a/src/omnibase_infra/validation/validation_exemptions.yaml +++ b/src/omnibase_infra/validation/validation_exemptions.yaml @@ -1032,6 +1032,27 @@ pattern_exemptions: - CLAUDE.md (ONEX Architecture - Handler Types) ticket: OMN-1097 # ========================================================================== + # Handler Bootstrap Source Exemptions (OMN-1087) + # ========================================================================== + # HandlerBootstrapSource provides hardcoded handler descriptors for core + # infrastructure handlers. The "Handler" in the name refers to ONEX handler + # concepts, consistent with HandlerContractSource and HandlerPluginLoader. + # + # Pattern specificity note: Uses separate class_pattern + violation_pattern + # (not combined "Class name .* contains...") for precise targeting. All three + # patterns must match: exact filename, exact class name, and violation type. + # This is consistent with HandlerContractSource/HandlerPluginLoader exemptions. + - file_pattern: 'handler_bootstrap_source\.py' + class_pattern: "Class name 'HandlerBootstrapSource'" + violation_pattern: "contains anti-pattern 'Handler'" + reason: > + HandlerBootstrapSource centralizes hardcoded handler registration for core infrastructure handlers (Consul, DB, HTTP, Vault). The "Handler" refers to ONEX handler concepts, consistent with HandlerContractSource. This is legitimate handler infrastructure code per OMN-1087. + + documentation: + - docs/architecture/HANDLER_PROTOCOL_DRIVEN_ARCHITECTURE.md + - CLAUDE.md (ONEX Architecture - Handler Types) + ticket: OMN-1087 + # ========================================================================== # Handler Plugin Loader Exemptions (OMN-1132) # ========================================================================== # HandlerPluginLoader discovers and loads ONEX handlers from contract YAML files. diff --git a/src/omnibase_infra/validation/validator_chain_propagation.py b/src/omnibase_infra/validation/validator_chain_propagation.py index de61f4aa04..6a89f0f7a4 100644 --- a/src/omnibase_infra/validation/validator_chain_propagation.py +++ b/src/omnibase_infra/validation/validator_chain_propagation.py @@ -183,7 +183,7 @@ def get_message_id(envelope: ModelEventEnvelope[object]) -> UUID: """ # envelope_id is typed as UUID in ModelEventEnvelope, but mypy sees it as Any # due to the generic type parameter. Cast is required for type safety. - return cast(UUID, envelope.envelope_id) + return cast("UUID", envelope.envelope_id) def get_correlation_id(envelope: ModelEventEnvelope[object]) -> UUID | None: @@ -200,7 +200,7 @@ def get_correlation_id(envelope: ModelEventEnvelope[object]) -> UUID | None: correlation_id = envelope.correlation_id if correlation_id is None: return None - return cast(UUID, correlation_id) + return cast("UUID", correlation_id) def get_causation_id(envelope: ModelEventEnvelope[object]) -> UUID | None: diff --git a/src/omnibase_infra/validation/validator_runtime_shape.py b/src/omnibase_infra/validation/validator_runtime_shape.py index 3b99dad032..e4380f4a65 100644 --- a/src/omnibase_infra/validation/validator_runtime_shape.py +++ b/src/omnibase_infra/validation/validator_runtime_shape.py @@ -893,7 +893,7 @@ def wrapper(*args: object, **kwargs: object) -> object: # Cast wrapper to F - functools.wraps preserves the signature at runtime, # and mypy cannot prove the equivalence, so we use an explicit cast. - return cast(F, wrapper) + return cast("F", wrapper) return decorator diff --git a/tests/chaos/test_recovery_dlq.py b/tests/chaos/test_recovery_dlq.py index 926c28f4bb..f01a5cffa0 100644 --- a/tests/chaos/test_recovery_dlq.py +++ b/tests/chaos/test_recovery_dlq.py @@ -654,9 +654,9 @@ async def test_complex_message_preserved( # Verify complex structure is preserved assert preserved == complex_message # Use nested access via cast for type-safe deep value verification - nested = cast(dict[str, object], preserved["nested"]) - level1 = cast(dict[str, object], nested["level1"]) - level2 = cast(dict[str, str], level1["level2"]) + nested = cast("dict[str, object]", preserved["nested"]) + level1 = cast("dict[str, object]", nested["level1"]) + level2 = cast("dict[str, str]", level1["level2"]) assert level2["value"] == "deep" assert preserved["array"] == [1, 2, 3, {"key": "value"}] diff --git a/tests/integration/registration/e2e/performance_utils.py b/tests/integration/registration/e2e/performance_utils.py index c6d0fb9ac2..e4f5b9e3cd 100644 --- a/tests/integration/registration/e2e/performance_utils.py +++ b/tests/integration/registration/e2e/performance_utils.py @@ -562,7 +562,7 @@ def print_summary(self) -> None: print(f"Passed: {summary['passed_count']}, Failed: {summary['failed_count']}") print(f"Total time: {summary['total_time_ms']:.2f}ms") - if cast(int, summary["total_operations"]) > 1: + if cast("int", summary["total_operations"]) > 1: print( f"Range: {summary['min_time_ms']:.2f}ms - {summary['max_time_ms']:.2f}ms" ) diff --git a/tests/integration/runtime/test_bootstrap_source_integration.py b/tests/integration/runtime/test_bootstrap_source_integration.py new file mode 100644 index 0000000000..ef6c972323 --- /dev/null +++ b/tests/integration/runtime/test_bootstrap_source_integration.py @@ -0,0 +1,425 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Integration tests for HandlerBootstrapSource integration (OMN-1087). + +This module validates that HandlerBootstrapSource is correctly integrated +into the RuntimeHostProcess bootstrap flow, ensuring core infrastructure +handlers are loaded from descriptor-based definitions. + +Test Coverage: +- HandlerBootstrapSource.discover_handlers() is called during runtime bootstrap +- Core 4 handlers (consul, db, http, vault) are loaded from bootstrap source +- Bootstrap handlers are registered before contract-based or default handlers +- Bootstrap handlers are available after RuntimeHostProcess.start() + +Related: + - OMN-1087: HandlerBootstrapSource for hardcoded handler registration + - src/omnibase_infra/runtime/handler_bootstrap_source.py + - src/omnibase_infra/runtime/service_runtime_host_process.py + +Note: + These tests verify handler REGISTRATION, not handler EXECUTION. + Handlers may fail during initialize() if external services are not available, + but they should still be registered in the handler registry. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from omnibase_infra.event_bus.event_bus_inmemory import EventBusInmemory +from omnibase_infra.runtime.handler_bootstrap_source import ( + SOURCE_TYPE_BOOTSTRAP, + HandlerBootstrapSource, +) +from omnibase_infra.runtime.handler_registry import ( + HANDLER_TYPE_CONSUL, + HANDLER_TYPE_DATABASE, + HANDLER_TYPE_HTTP, + HANDLER_TYPE_VAULT, + RegistryProtocolBinding, + get_handler_registry, +) +from omnibase_infra.runtime.service_runtime_host_process import RuntimeHostProcess + +# ============================================================================= +# Test Classes +# ============================================================================= + + +class TestHandlerBootstrapSourceDiscovery: + """Test HandlerBootstrapSource discover_handlers() functionality. + + These tests verify that HandlerBootstrapSource correctly provides + handler descriptors for the core infrastructure handlers. + """ + + @pytest.mark.asyncio + async def test_discover_handlers_returns_four_descriptors(self) -> None: + """HandlerBootstrapSource.discover_handlers() returns 4 handler descriptors. + + Verifies: + 1. discover_handlers() returns ModelContractDiscoveryResult + 2. Result contains exactly 4 descriptors + 3. No validation errors (hardcoded handlers are pre-validated) + """ + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + assert len(result.descriptors) == 4 + assert len(result.validation_errors) == 0 + + @pytest.mark.asyncio + async def test_discover_handlers_includes_core_handlers(self) -> None: + """HandlerBootstrapSource includes consul, db, http, vault handlers. + + Verifies that all four core infrastructure handlers are present + in the discovery result. + """ + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + handler_ids = {d.handler_id for d in result.descriptors} + expected_ids = { + "bootstrap.consul", + "bootstrap.db", + "bootstrap.http", + "bootstrap.vault", + } + + assert handler_ids == expected_ids + + @pytest.mark.asyncio + async def test_discover_handlers_descriptors_have_handler_class(self) -> None: + """All bootstrap handler descriptors have valid handler_class paths. + + Verifies: + 1. Each descriptor has a non-None handler_class + 2. Handler class paths are fully qualified (contain dots) + """ + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_class is not None + assert "." in descriptor.handler_class + assert descriptor.handler_class.startswith("omnibase_infra.handlers.") + + @pytest.mark.asyncio + async def test_source_type_is_bootstrap(self) -> None: + """HandlerBootstrapSource.source_type returns 'BOOTSTRAP'.""" + source = HandlerBootstrapSource() + assert source.source_type == SOURCE_TYPE_BOOTSTRAP + + @pytest.mark.asyncio + async def test_discover_handlers_is_idempotent(self) -> None: + """Multiple calls to discover_handlers() return consistent results. + + Verifies that discover_handlers() can be called multiple times + and returns the same descriptors each time. + """ + source = HandlerBootstrapSource() + + result1 = await source.discover_handlers() + result2 = await source.discover_handlers() + + # Same number of descriptors + assert len(result1.descriptors) == len(result2.descriptors) + + # Same handler IDs + ids1 = {d.handler_id for d in result1.descriptors} + ids2 = {d.handler_id for d in result2.descriptors} + assert ids1 == ids2 + + +class TestBootstrapSourceRuntimeIntegration: + """Test HandlerBootstrapSource integration with RuntimeHostProcess. + + These tests verify that RuntimeHostProcess correctly uses + HandlerBootstrapSource during the startup bootstrap process. + """ + + @pytest.mark.asyncio + async def test_bootstrap_handlers_registered_on_start(self) -> None: + """Bootstrap handlers are registered in handler registry on start. + + Verifies: + 1. RuntimeHostProcess.start() registers bootstrap handlers + 2. All 4 core handlers (consul, db, http, vault) are in registry + """ + event_bus = EventBusInmemory() + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + # No contract_paths - bootstrap handlers provide core infrastructure + ) + + try: + await process.start() + + # Verify bootstrap handlers are in the singleton registry + registry = get_handler_registry() + assert registry.is_registered(HANDLER_TYPE_CONSUL) + assert registry.is_registered(HANDLER_TYPE_DATABASE) + assert registry.is_registered(HANDLER_TYPE_HTTP) + assert registry.is_registered(HANDLER_TYPE_VAULT) + + finally: + await process.stop() + + @pytest.mark.asyncio + async def test_bootstrap_handlers_registered_before_contracts( + self, tmp_path: Path + ) -> None: + """Bootstrap handlers are registered BEFORE contract-based discovery. + + Verifies that bootstrap handlers are loaded first, then contract-based + handlers. This ensures core infrastructure is always available. + """ + # Create a contract directory (empty - no handlers) + contracts_dir = tmp_path / "contracts" + contracts_dir.mkdir() + + # Track registration order + registration_order: list[str] = [] + + original_register = RegistryProtocolBinding.register + + def tracking_register( + self: RegistryProtocolBinding, protocol_type: str, handler_class: type + ) -> None: + registration_order.append(protocol_type) + return original_register(self, protocol_type, handler_class) + + with patch.object(RegistryProtocolBinding, "register", tracking_register): + event_bus = EventBusInmemory() + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + contract_paths=[str(contracts_dir)], + ) + + try: + await process.start() + + # Bootstrap handlers should be registered first + # (consul, db, http, vault in some order) + bootstrap_handlers = { + HANDLER_TYPE_CONSUL, + HANDLER_TYPE_DATABASE, + HANDLER_TYPE_HTTP, + HANDLER_TYPE_VAULT, + } + + # Check that bootstrap handlers were registered + registered_set = set(registration_order) + assert bootstrap_handlers.issubset(registered_set), ( + f"Bootstrap handlers not found in registration. " + f"Expected: {bootstrap_handlers}, Got: {registered_set}" + ) + + finally: + await process.stop() + + @pytest.mark.asyncio + async def test_bootstrap_source_called_during_start(self) -> None: + """HandlerBootstrapSource.discover_handlers() is called during start. + + Verifies that RuntimeHostProcess actually calls the bootstrap source + discover_handlers() method during startup. + """ + event_bus = EventBusInmemory() + + # Patch at the source module where it's imported from + with patch( + "omnibase_infra.runtime.handler_bootstrap_source.HandlerBootstrapSource" + ) as MockBootstrapSource: + # Create a mock that returns proper discovery result + mock_source = MagicMock() + mock_discovery_result = MagicMock() + mock_discovery_result.descriptors = [] # Empty for simplicity + mock_source.discover_handlers = AsyncMock( + return_value=mock_discovery_result + ) + MockBootstrapSource.return_value = mock_source + + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + ) + + try: + await process.start() + + # Verify HandlerBootstrapSource was instantiated and called + MockBootstrapSource.assert_called_once() + mock_source.discover_handlers.assert_called_once() + + finally: + await process.stop() + + @pytest.mark.asyncio + async def test_bootstrap_handlers_logged( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Bootstrap handler registration is logged. + + Verifies that appropriate log messages are generated during + bootstrap handler registration. + """ + event_bus = EventBusInmemory() + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + ) + + with caplog.at_level(logging.INFO): + try: + await process.start() + + # Check for bootstrap-related log messages + log_messages = [r.message for r in caplog.records] + + has_bootstrap_log = any( + "bootstrap" in msg.lower() for msg in log_messages + ) + assert has_bootstrap_log, ( + f"Should have logs mentioning 'bootstrap'. " + f"Log messages: {log_messages}" + ) + + finally: + await process.stop() + + +class TestBootstrapSourceErrorHandling: + """Test error handling in bootstrap source integration. + + These tests verify that errors during bootstrap handler registration + are handled gracefully without crashing the runtime. + """ + + @pytest.mark.asyncio + async def test_single_handler_import_error_does_not_crash( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Import error for one handler doesn't prevent others from loading. + + Verifies graceful degradation: if one bootstrap handler fails to + import, other handlers should still be registered. + """ + event_bus = EventBusInmemory() + + # Patch importlib.import_module to fail for one specific handler + original_import = __import__("importlib").import_module + + def failing_import(name: str, package: str | None = None) -> object: + if "handler_consul" in name: + raise ImportError("Simulated import error for consul handler") + return original_import(name, package) + + with patch("importlib.import_module", side_effect=failing_import): + with caplog.at_level(logging.WARNING): + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + ) + + try: + await process.start() + + # Process should still start + assert process.is_running + + # Other handlers should be registered (db, http, vault) + registry = get_handler_registry() + # At least some handlers should be registered + registered = registry.list_protocols() + assert len(registered) >= 1, ( + "At least one handler should be registered despite consul failing" + ) + + # Check for error log about failed import + error_logs = [ + r for r in caplog.records if r.levelno >= logging.WARNING + ] + assert len(error_logs) >= 1, ( + "Should have warning/error logs for failed handler import" + ) + + finally: + await process.stop() + + @pytest.mark.asyncio + async def test_bootstrap_with_empty_descriptors_continues(self) -> None: + """Runtime continues if bootstrap source returns empty descriptors. + + Verifies that if HandlerBootstrapSource somehow returns no descriptors, + the runtime still starts (graceful degradation). + """ + event_bus = EventBusInmemory() + + # Patch at the source module where it's imported from + with patch( + "omnibase_infra.runtime.handler_bootstrap_source.HandlerBootstrapSource" + ) as MockBootstrapSource: + # Return empty descriptors + mock_source = MagicMock() + mock_discovery_result = MagicMock() + mock_discovery_result.descriptors = [] + mock_source.discover_handlers = AsyncMock( + return_value=mock_discovery_result + ) + MockBootstrapSource.return_value = mock_source + + process = RuntimeHostProcess( + event_bus=event_bus, + input_topic="test.input", + ) + + try: + await process.start() + + # Process should still start (graceful degradation) + assert process.is_running + + finally: + await process.stop() + + +class TestBootstrapSourcePerformance: + """Test performance characteristics of bootstrap source integration. + + These tests verify that bootstrap handler loading is fast and doesn't + add significant overhead to runtime startup. + """ + + @pytest.mark.asyncio + async def test_discover_handlers_is_fast(self) -> None: + """HandlerBootstrapSource.discover_handlers() completes in < 100ms. + + Bootstrap handler discovery should be very fast since it's just + creating in-memory descriptor objects (no file I/O). + """ + import time + + source = HandlerBootstrapSource() + + start = time.perf_counter() + await source.discover_handlers() + duration = time.perf_counter() - start + + # Should complete in under 100ms (generous for CI) + assert duration < 0.1, f"Discovery took {duration:.3f}s, expected < 0.1s" + + +__all__: list[str] = [ + "TestHandlerBootstrapSourceDiscovery", + "TestBootstrapSourceRuntimeIntegration", + "TestBootstrapSourceErrorHandling", + "TestBootstrapSourcePerformance", +] diff --git a/tests/unit/models/handlers/__init__.py b/tests/unit/models/handlers/__init__.py new file mode 100644 index 0000000000..0a4857f058 --- /dev/null +++ b/tests/unit/models/handlers/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Unit tests for handler models.""" diff --git a/tests/unit/models/handlers/test_model_bootstrap_handler_descriptor.py b/tests/unit/models/handlers/test_model_bootstrap_handler_descriptor.py new file mode 100644 index 0000000000..02452b9327 --- /dev/null +++ b/tests/unit/models/handlers/test_model_bootstrap_handler_descriptor.py @@ -0,0 +1,488 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +"""Unit tests for ModelBootstrapHandlerDescriptor with required handler_class. + +Tests the ModelBootstrapHandlerDescriptor functionality including: +- Required handler_class validation (fails if missing) +- Inheritance from ModelHandlerDescriptor +- Pattern validation for handler_class field +- Conversion to base ModelHandlerDescriptor +- Comparison with base descriptor behavior + +Related: + - OMN-1087: HandlerBootstrapSource descriptor-based validation + - src/omnibase_infra/models/handlers/model_bootstrap_handler_descriptor.py + - src/omnibase_infra/models/handlers/model_handler_descriptor.py + +Expected Behavior: + ModelBootstrapHandlerDescriptor extends ModelHandlerDescriptor with one key + difference: handler_class is REQUIRED (not optional). This ensures bootstrap + handlers always specify their implementation class since they have no + contract file to derive the class from. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from omnibase_core.models.primitives.model_semver import ModelSemVer +from omnibase_infra.models.handlers import ( + ModelBootstrapHandlerDescriptor, + ModelHandlerDescriptor, +) + +# ============================================================================= +# Test Constants +# ============================================================================= + +# Valid handler_class values for testing +VALID_HANDLER_CLASS = "omnibase_infra.handlers.handler_consul.HandlerConsul" +VALID_HANDLER_CLASS_SHORT = "myapp.handlers.MyHandler" + +# Invalid handler_class values for testing +INVALID_HANDLER_CLASS_NO_DOT = "HandlerConsul" +INVALID_HANDLER_CLASS_TOO_SHORT = "ab" # Below min_length of 3 +INVALID_HANDLER_CLASS_STARTS_WITH_NUMBER = "1module.Handler" + +# Base descriptor fields (shared between all tests) +BASE_DESCRIPTOR_FIELDS = { + "handler_id": "bootstrap.test", + "name": "Test Handler", + "version": "1.0.0", + "handler_kind": "effect", + "input_model": "omnibase_infra.models.types.JsonDict", + "output_model": "omnibase_core.models.dispatch.ModelHandlerOutput", +} + + +# ============================================================================= +# Required handler_class Validation Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorRequiredHandlerClass: + """Tests for the required handler_class field behavior. + + These tests verify that ModelBootstrapHandlerDescriptor enforces + handler_class as a required field, unlike the base ModelHandlerDescriptor + where it is optional. + """ + + def test_handler_class_required_fails_when_missing(self) -> None: + """Creating descriptor without handler_class should raise ValidationError. + + This is the key difference from ModelHandlerDescriptor where + handler_class defaults to None. + """ + with pytest.raises(ValidationError) as exc_info: + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + # handler_class intentionally omitted + ) + + # Verify the error mentions handler_class field + errors = exc_info.value.errors() + handler_class_errors = [e for e in errors if "handler_class" in str(e["loc"])] + assert len(handler_class_errors) > 0, ( + "Expected validation error for missing handler_class" + ) + + def test_handler_class_required_fails_when_none(self) -> None: + """Explicitly passing handler_class=None should raise ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=None, # type: ignore[arg-type] # intentional + ) + + errors = exc_info.value.errors() + handler_class_errors = [e for e in errors if "handler_class" in str(e["loc"])] + assert len(handler_class_errors) > 0 + + def test_handler_class_succeeds_when_provided(self) -> None: + """Creating descriptor with valid handler_class should succeed.""" + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + assert descriptor.handler_class == VALID_HANDLER_CLASS + + def test_base_descriptor_allows_none_handler_class(self) -> None: + """Base ModelHandlerDescriptor should allow handler_class=None. + + This test confirms the difference in behavior between the two classes. + """ + # This should succeed - base descriptor has optional handler_class + descriptor = ModelHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + # handler_class not provided - should default to None + ) + + assert descriptor.handler_class is None + + def test_base_descriptor_allows_explicit_none(self) -> None: + """Base ModelHandlerDescriptor should allow explicit handler_class=None.""" + descriptor = ModelHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=None, + ) + + assert descriptor.handler_class is None + + +# ============================================================================= +# handler_class Pattern Validation Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorPatternValidation: + """Tests for handler_class pattern validation. + + Both ModelBootstrapHandlerDescriptor and ModelHandlerDescriptor share + the same pattern constraint: the handler_class must be a valid + fully qualified Python module path. + """ + + def test_valid_handler_class_patterns(self) -> None: + """Various valid handler_class patterns should be accepted.""" + valid_patterns = [ + "omnibase_infra.handlers.handler_consul.HandlerConsul", + "myapp.handlers.MyHandler", + "a.b.C", + "module_name.SubModule.ClassName", + "_private.module.Handler", + ] + + for pattern in valid_patterns: + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=pattern, + ) + assert descriptor.handler_class == pattern, f"Failed for: {pattern}" + + def test_handler_class_without_dot_fails(self) -> None: + """handler_class without a dot should fail validation (not a module path).""" + with pytest.raises(ValidationError): + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=INVALID_HANDLER_CLASS_NO_DOT, + ) + + def test_handler_class_too_short_fails(self) -> None: + """handler_class shorter than min_length (3) should fail validation.""" + with pytest.raises(ValidationError): + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class="ab", # Only 2 chars, below min_length of 3 + ) + + def test_handler_class_starting_with_number_fails(self) -> None: + """handler_class starting with a number should fail validation.""" + with pytest.raises(ValidationError): + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=INVALID_HANDLER_CLASS_STARTS_WITH_NUMBER, + ) + + def test_handler_class_with_special_chars_fails(self) -> None: + """handler_class with invalid characters should fail validation.""" + invalid_patterns = [ + "module.class-name", # hyphen + "module.class name", # space + "module.class@name", # @ + "module/class.name", # / + ] + + for pattern in invalid_patterns: + with pytest.raises(ValidationError): + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=pattern, + ) + + +# ============================================================================= +# Inheritance and Type Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorInheritance: + """Tests for inheritance relationship with ModelHandlerDescriptor. + + ModelBootstrapHandlerDescriptor extends ModelHandlerDescriptor, + so instances should be compatible with code expecting the base type. + """ + + def test_is_subclass_of_model_handler_descriptor(self) -> None: + """ModelBootstrapHandlerDescriptor should be a subclass of base.""" + assert issubclass( + ModelBootstrapHandlerDescriptor, + ModelHandlerDescriptor, + ) + + def test_instance_is_model_handler_descriptor(self) -> None: + """Instances should be recognized as ModelHandlerDescriptor.""" + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + assert isinstance(descriptor, ModelHandlerDescriptor) + assert isinstance(descriptor, ModelBootstrapHandlerDescriptor) + + def test_can_be_used_where_base_expected(self) -> None: + """Bootstrap descriptor should work where base descriptor is expected. + + This simulates code that accepts ModelHandlerDescriptor and verifies + bootstrap descriptors are compatible. + """ + + def process_descriptor(desc: ModelHandlerDescriptor) -> str: + return desc.handler_id + + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + result = process_descriptor(descriptor) + assert result == "bootstrap.test" + + +# ============================================================================= +# to_base_descriptor() Method Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorToBase: + """Tests for the to_base_descriptor() conversion method.""" + + def test_to_base_descriptor_returns_model_handler_descriptor(self) -> None: + """to_base_descriptor() should return a ModelHandlerDescriptor instance.""" + bootstrap_desc = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + description="Test description", + ) + + base_desc = bootstrap_desc.to_base_descriptor() + + assert type(base_desc) is ModelHandlerDescriptor + assert not isinstance(base_desc, ModelBootstrapHandlerDescriptor) + + def test_to_base_descriptor_copies_all_fields(self) -> None: + """to_base_descriptor() should copy all field values correctly.""" + bootstrap_desc = ModelBootstrapHandlerDescriptor( + handler_id="bootstrap.consul", + name="Consul Handler", + version="2.1.3", + handler_kind="effect", + input_model="omnibase_infra.models.types.JsonDict", + output_model="omnibase_core.models.dispatch.ModelHandlerOutput", + description="Test handler description", + handler_class=VALID_HANDLER_CLASS, + contract_path="/path/to/contract.yaml", + ) + + base_desc = bootstrap_desc.to_base_descriptor() + + assert base_desc.handler_id == bootstrap_desc.handler_id + assert base_desc.name == bootstrap_desc.name + assert base_desc.version == bootstrap_desc.version + assert base_desc.handler_kind == bootstrap_desc.handler_kind + assert base_desc.input_model == bootstrap_desc.input_model + assert base_desc.output_model == bootstrap_desc.output_model + assert base_desc.description == bootstrap_desc.description + assert base_desc.handler_class == bootstrap_desc.handler_class + assert base_desc.contract_path == bootstrap_desc.contract_path + + def test_to_base_descriptor_with_none_optional_fields(self) -> None: + """to_base_descriptor() should handle None optional fields.""" + bootstrap_desc = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + # description and contract_path not provided (defaults to None) + ) + + base_desc = bootstrap_desc.to_base_descriptor() + + assert base_desc.description is None + assert base_desc.contract_path is None + assert base_desc.handler_class == VALID_HANDLER_CLASS + + +# ============================================================================= +# Frozen Model Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorFrozen: + """Tests for frozen (immutable) model behavior.""" + + def test_descriptor_is_frozen(self) -> None: + """ModelBootstrapHandlerDescriptor should be immutable.""" + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + with pytest.raises(ValidationError): + descriptor.handler_class = "new.module.Handler" # type: ignore[misc] + + def test_all_fields_are_immutable(self) -> None: + """All descriptor fields should be immutable.""" + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + immutable_fields = [ + ("handler_id", "new.id"), + ("name", "New Name"), + ("handler_kind", "compute"), + ("input_model", "new.Input"), + ("output_model", "new.Output"), + ("handler_class", "new.Handler.Class"), + ] + + for field_name, new_value in immutable_fields: + with pytest.raises(ValidationError): + setattr(descriptor, field_name, new_value) + + +# ============================================================================= +# Version Field Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorVersion: + """Tests for version field handling (inherited from base).""" + + def test_version_string_converted_to_model_semver(self) -> None: + """String version should be converted to ModelSemVer.""" + descriptor = ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class=VALID_HANDLER_CLASS, + ) + + assert isinstance(descriptor.version, ModelSemVer) + assert descriptor.version.major == 1 + assert descriptor.version.minor == 0 + assert descriptor.version.patch == 0 + + def test_version_accepts_model_semver_directly(self) -> None: + """ModelSemVer instance should be accepted directly.""" + semver = ModelSemVer(major=2, minor=3, patch=4) + + descriptor = ModelBootstrapHandlerDescriptor( + handler_id="bootstrap.test", + name="Test Handler", + version=semver, + handler_kind="effect", + input_model="omnibase_infra.models.types.JsonDict", + output_model="omnibase_core.models.dispatch.ModelHandlerOutput", + handler_class=VALID_HANDLER_CLASS, + ) + + assert descriptor.version.major == 2 + assert descriptor.version.minor == 3 + assert descriptor.version.patch == 4 + + +# ============================================================================= +# Integration with HandlerBootstrapSource Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorIntegration: + """Integration tests with HandlerBootstrapSource. + + These tests verify that the descriptor works correctly when used + by HandlerBootstrapSource for bootstrap handler validation. + """ + + @pytest.mark.asyncio + async def test_bootstrap_source_uses_bootstrap_descriptor(self) -> None: + """HandlerBootstrapSource should create ModelBootstrapHandlerDescriptor instances.""" + from omnibase_infra.runtime.handler_bootstrap_source import ( + HandlerBootstrapSource, + ) + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + # All descriptors should be ModelBootstrapHandlerDescriptor instances + for descriptor in result.descriptors: + assert isinstance(descriptor, ModelBootstrapHandlerDescriptor) + # And also ModelHandlerDescriptor due to inheritance + assert isinstance(descriptor, ModelHandlerDescriptor) + + @pytest.mark.asyncio + async def test_all_bootstrap_handlers_have_handler_class(self) -> None: + """All bootstrap handlers should have non-None handler_class. + + This verifies the fail-fast behavior: if a bootstrap definition + is missing handler_class, construction would fail during discovery. + """ + from omnibase_infra.runtime.handler_bootstrap_source import ( + HandlerBootstrapSource, + ) + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_class is not None, ( + f"Handler {descriptor.handler_id} missing handler_class" + ) + assert len(descriptor.handler_class) > 0 + + +# ============================================================================= +# Error Message Quality Tests +# ============================================================================= + + +class TestBootstrapHandlerDescriptorErrorMessages: + """Tests for validation error message quality. + + These tests ensure that error messages are clear and actionable + when validation fails. + """ + + def test_missing_handler_class_error_message_is_clear(self) -> None: + """Error for missing handler_class should clearly identify the issue.""" + with pytest.raises(ValidationError) as exc_info: + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + # handler_class intentionally omitted + ) + + error_str = str(exc_info.value) + assert "handler_class" in error_str + + def test_invalid_pattern_error_message_is_clear(self) -> None: + """Error for invalid pattern should clearly identify the issue.""" + with pytest.raises(ValidationError) as exc_info: + ModelBootstrapHandlerDescriptor( + **BASE_DESCRIPTOR_FIELDS, + handler_class="invalid-no-dot", + ) + + error_str = str(exc_info.value) + # Should mention pattern or string validation + assert "handler_class" in error_str + + +__all__ = [ + "TestBootstrapHandlerDescriptorRequiredHandlerClass", + "TestBootstrapHandlerDescriptorPatternValidation", + "TestBootstrapHandlerDescriptorInheritance", + "TestBootstrapHandlerDescriptorToBase", + "TestBootstrapHandlerDescriptorFrozen", + "TestBootstrapHandlerDescriptorVersion", + "TestBootstrapHandlerDescriptorIntegration", + "TestBootstrapHandlerDescriptorErrorMessages", +] diff --git a/tests/unit/runtime/test_binding_config_resolver.py b/tests/unit/runtime/test_binding_config_resolver.py index 3d81a42e46..f1a36f37c2 100644 --- a/tests/unit/runtime/test_binding_config_resolver.py +++ b/tests/unit/runtime/test_binding_config_resolver.py @@ -3607,7 +3607,9 @@ def test_symlink_allowed_by_default(self) -> None: def test_symlink_rejected_when_disabled(self) -> None: """Symlinks are rejected when allow_symlinks=False (within config_dir).""" with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) + # Resolve to real path to avoid macOS /var -> /private/var symlink + # This ensures we test explicit symlink rejection, not system symlinks + config_dir = Path(tmpdir).resolve() # Create actual config file inside config_dir real_config = config_dir / "real_config.yaml" @@ -3635,7 +3637,9 @@ def test_symlink_rejected_when_disabled(self) -> None: def test_regular_file_works_when_symlinks_disabled(self) -> None: """Regular (non-symlink) files work when allow_symlinks=False.""" with tempfile.TemporaryDirectory() as tmpdir: - config_dir = Path(tmpdir) + # Resolve to real path to avoid macOS /var -> /private/var symlink + # The test verifies regular files work, not system symlink handling + config_dir = Path(tmpdir).resolve() # Create regular config file (no symlink) config_file = config_dir / "config.yaml" @@ -3660,7 +3664,9 @@ def test_regular_file_works_when_symlinks_disabled(self) -> None: def test_symlink_in_parent_path_rejected_when_disabled(self) -> None: """Symlinks in parent directories are rejected when allow_symlinks=False.""" with tempfile.TemporaryDirectory() as tmpdir: - base_dir = Path(tmpdir) + # Resolve to real path to avoid macOS /var -> /private/var symlink + # This ensures we test explicit symlink parent rejection + base_dir = Path(tmpdir).resolve() # Create real directory with config real_configs = base_dir / "real_configs" diff --git a/tests/unit/runtime/test_handler_bootstrap_source.py b/tests/unit/runtime/test_handler_bootstrap_source.py new file mode 100644 index 0000000000..6984963840 --- /dev/null +++ b/tests/unit/runtime/test_handler_bootstrap_source.py @@ -0,0 +1,1058 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 OmniNode Team +""" +Unit tests for HandlerBootstrapSource hardcoded handler registration. + +Tests the HandlerBootstrapSource functionality including: +- Protocol compliance with ProtocolContractSource +- Bootstrap handler discovery (consul, db, http, vault) +- ModelHandlerDescriptor validation for all bootstrap handlers +- Graceful mode behavior (API consistency) +- Idempotency of discover_handlers() calls + +Related: + - OMN-1087: HandlerBootstrapSource for hardcoded handler registration + - src/omnibase_infra/runtime/handler_bootstrap_source.py + - docs/architecture/HANDLER_PROTOCOL_DRIVEN_ARCHITECTURE.md + +Expected Behavior: + HandlerBootstrapSource implements ProtocolContractSource from omnibase_infra. + It provides hardcoded handler descriptors for core infrastructure handlers + (Consul, Database, HTTP, Vault) without requiring contract.yaml files. + + The source_type property returns "BOOTSTRAP" as per the implementation. + All handlers have handler_kind="effect" since they perform external I/O. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from omnibase_infra.models.handlers import ( + ModelContractDiscoveryResult, + ModelHandlerDescriptor, +) +from omnibase_infra.runtime.handler_bootstrap_source import ( + SOURCE_TYPE_BOOTSTRAP, + HandlerBootstrapSource, +) +from omnibase_infra.runtime.protocol_contract_source import ProtocolContractSource + +# ============================================================================= +# Constants for Test Validation +# ============================================================================= + +# Expected bootstrap handler IDs +EXPECTED_HANDLER_IDS = frozenset( + { + "bootstrap.consul", + "bootstrap.db", + "bootstrap.http", + "bootstrap.vault", + } +) + +# Expected handler kind for all bootstrap handlers +EXPECTED_HANDLER_KIND = "effect" + +# Expected version for all bootstrap handlers +EXPECTED_VERSION = "1.0.0" + +# Expected count of bootstrap handlers +EXPECTED_HANDLER_COUNT = 4 + + +# ============================================================================= +# Protocol Compliance Tests +# ============================================================================= + + +class TestHandlerBootstrapSourceProtocolCompliance: + """Tests for ProtocolContractSource compliance. + + These tests verify that HandlerBootstrapSource correctly implements + the ProtocolContractSource protocol with all required methods and properties. + """ + + def test_handler_bootstrap_source_can_be_imported(self) -> None: + """HandlerBootstrapSource should be importable from omnibase_infra.runtime. + + Expected import path: + from omnibase_infra.runtime.handler_bootstrap_source import HandlerBootstrapSource + """ + from omnibase_infra.runtime.handler_bootstrap_source import ( + HandlerBootstrapSource, + ) + + assert HandlerBootstrapSource is not None + + def test_handler_bootstrap_source_implements_protocol(self) -> None: + """HandlerBootstrapSource should implement ProtocolContractSource. + + The implementation must satisfy ProtocolContractSource with: + - source_type property returning "BOOTSTRAP" + - async discover_handlers() method returning ModelContractDiscoveryResult + """ + source = HandlerBootstrapSource() + + # Protocol compliance check via duck typing (ONEX convention) + assert hasattr(source, "source_type") + assert hasattr(source, "discover_handlers") + assert callable(source.discover_handlers) + + # Runtime checkable protocol verification + assert isinstance(source, ProtocolContractSource) + + def test_handler_bootstrap_source_type_is_bootstrap(self) -> None: + """HandlerBootstrapSource.source_type should return "BOOTSTRAP". + + The source_type is used for observability and debugging purposes only. + The runtime MUST NOT branch on this value. + """ + source = HandlerBootstrapSource() + + assert source.source_type == SOURCE_TYPE_BOOTSTRAP + assert source.source_type == "BOOTSTRAP" + + def test_source_type_constant_exported(self) -> None: + """SOURCE_TYPE_BOOTSTRAP constant should be exported and match implementation.""" + from omnibase_infra.runtime.handler_bootstrap_source import ( + SOURCE_TYPE_BOOTSTRAP, + ) + + assert SOURCE_TYPE_BOOTSTRAP == "BOOTSTRAP" + + source = HandlerBootstrapSource() + assert source.source_type == SOURCE_TYPE_BOOTSTRAP + + def test_handler_bootstrap_source_in_runtime_exports(self) -> None: + """HandlerBootstrapSource should be exported from omnibase_infra.runtime.""" + from omnibase_infra.runtime import ( + SOURCE_TYPE_BOOTSTRAP as RUNTIME_SOURCE_TYPE, + ) + from omnibase_infra.runtime import ( + HandlerBootstrapSource as RuntimeExportedSource, + ) + + assert RuntimeExportedSource is HandlerBootstrapSource + assert RUNTIME_SOURCE_TYPE == SOURCE_TYPE_BOOTSTRAP + + +# ============================================================================= +# Handler Discovery Tests +# ============================================================================= + + +class TestHandlerBootstrapSourceDiscovery: + """Tests for handler discovery functionality. + + These tests verify that HandlerBootstrapSource.discover_handlers() correctly + returns the expected bootstrap handlers with proper descriptors. + """ + + @pytest.mark.asyncio + async def test_discover_handlers_returns_model_contract_discovery_result( + self, + ) -> None: + """discover_handlers() should return ModelContractDiscoveryResult. + + The result must be an instance of ModelContractDiscoveryResult containing + both descriptors and validation_errors fields. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + assert isinstance(result, ModelContractDiscoveryResult) + assert hasattr(result, "descriptors") + assert hasattr(result, "validation_errors") + + @pytest.mark.asyncio + async def test_discovers_exactly_four_handlers(self) -> None: + """discover_handlers() should return exactly 4 bootstrap handlers. + + The bootstrap handlers are: + - bootstrap.consul: HashiCorp Consul service discovery + - bootstrap.db: PostgreSQL database operations + - bootstrap.http: HTTP REST protocol + - bootstrap.vault: HashiCorp Vault secret management + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + assert len(result.descriptors) == EXPECTED_HANDLER_COUNT, ( + f"Expected {EXPECTED_HANDLER_COUNT} bootstrap handlers, " + f"got {len(result.descriptors)}" + ) + + @pytest.mark.asyncio + async def test_discovered_handler_ids_match_expected(self) -> None: + """All discovered handlers should have expected handler_id values. + + Handler IDs must follow the pattern "bootstrap." where + service_name is one of: consul, db, http, vault. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + discovered_ids = {d.handler_id for d in result.descriptors} + + assert discovered_ids == EXPECTED_HANDLER_IDS, ( + f"Handler ID mismatch. Expected: {EXPECTED_HANDLER_IDS}, " + f"Got: {discovered_ids}" + ) + + @pytest.mark.asyncio + async def test_all_handlers_have_bootstrap_prefix(self) -> None: + """All handler IDs should start with 'bootstrap.' prefix.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_id.startswith("bootstrap."), ( + f"Handler ID '{descriptor.handler_id}' does not have 'bootstrap.' prefix" + ) + + @pytest.mark.asyncio + async def test_all_handlers_have_effect_kind(self) -> None: + """All bootstrap handlers should have handler_kind='effect'. + + Effect handlers perform external I/O operations with infrastructure services. + All bootstrap handlers interact with external systems (Consul, DB, HTTP, Vault). + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_kind == EXPECTED_HANDLER_KIND, ( + f"Handler '{descriptor.handler_id}' has kind '{descriptor.handler_kind}', " + f"expected '{EXPECTED_HANDLER_KIND}'" + ) + + @pytest.mark.asyncio + async def test_all_handlers_have_version_1_0_0(self) -> None: + """All bootstrap handlers should have version='1.0.0'. + + Bootstrap handlers use a stable version since they are hardcoded + definitions that don't change through contract files. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + # version is ModelSemVer, compare using str() for string representation + assert str(descriptor.version) == EXPECTED_VERSION, ( + f"Handler '{descriptor.handler_id}' has version '{descriptor.version}', " + f"expected '{EXPECTED_VERSION}'" + ) + # Also verify components + assert descriptor.version.major == 1 + assert descriptor.version.minor == 0 + assert descriptor.version.patch == 0 + + @pytest.mark.asyncio + async def test_all_handlers_have_contract_path_none(self) -> None: + """All bootstrap handlers should have contract_path=None. + + Bootstrap handlers are hardcoded and don't come from contract files, + so their contract_path should always be None. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.contract_path is None, ( + f"Handler '{descriptor.handler_id}' has contract_path=" + f"'{descriptor.contract_path}', expected None" + ) + + @pytest.mark.asyncio + async def test_validation_errors_always_empty(self) -> None: + """validation_errors should always be empty for bootstrap source. + + Bootstrap handlers are hardcoded and validated at development time, + so there should never be validation errors. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + assert result.validation_errors == [], ( + f"Expected empty validation_errors, got {len(result.validation_errors)} errors" + ) + + @pytest.mark.asyncio + async def test_descriptors_are_model_handler_descriptor_instances(self) -> None: + """All descriptors should be instances of ModelHandlerDescriptor.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert isinstance(descriptor, ModelHandlerDescriptor), ( + f"Descriptor for '{descriptor.handler_id}' is not ModelHandlerDescriptor, " + f"got {type(descriptor).__name__}" + ) + + +# ============================================================================= +# ModelHandlerDescriptor Validation Tests +# ============================================================================= + + +class TestHandlerBootstrapSourceDescriptors: + """Tests for ModelHandlerDescriptor validation. + + These tests verify that all bootstrap handler descriptors have properly + populated required fields with valid values. + """ + + @pytest.mark.asyncio + async def test_all_descriptors_have_non_empty_handler_id(self) -> None: + """All descriptors should have a non-empty handler_id.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_id, "handler_id should not be empty" + assert len(descriptor.handler_id) > 0 + + @pytest.mark.asyncio + async def test_all_descriptors_have_non_empty_name(self) -> None: + """All descriptors should have a non-empty human-readable name.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.name, f"Handler '{descriptor.handler_id}' has empty name" + assert len(descriptor.name) > 0 + + @pytest.mark.asyncio + async def test_all_descriptors_have_non_empty_description(self) -> None: + """All descriptors should have a non-empty description.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.description, ( + f"Handler '{descriptor.handler_id}' has empty description" + ) + assert len(descriptor.description) > 0 + + @pytest.mark.asyncio + async def test_all_descriptors_have_valid_input_model_path(self) -> None: + """All descriptors should have a valid fully qualified input_model path. + + The input_model path must be a valid Python module path + (e.g., 'omnibase_core.types.JsonDict'). + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.input_model, ( + f"Handler '{descriptor.handler_id}' has empty input_model" + ) + # Verify it looks like a module path (contains at least one dot) + assert "." in descriptor.input_model, ( + f"Handler '{descriptor.handler_id}' input_model '{descriptor.input_model}' " + "does not look like a fully qualified path" + ) + + @pytest.mark.asyncio + async def test_all_descriptors_have_valid_output_model_path(self) -> None: + """All descriptors should have a valid fully qualified output_model path. + + The output_model path must be a valid Python module path + (e.g., 'omnibase_core.models.dispatch.ModelHandlerOutput'). + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.output_model, ( + f"Handler '{descriptor.handler_id}' has empty output_model" + ) + # Verify it looks like a module path (contains at least one dot) + assert "." in descriptor.output_model, ( + f"Handler '{descriptor.handler_id}' output_model '{descriptor.output_model}' " + "does not look like a fully qualified path" + ) + + @pytest.mark.asyncio + async def test_all_descriptors_have_same_output_model(self) -> None: + """All bootstrap handlers should use the same output model. + + Bootstrap handlers use envelope-based routing and all return + ModelHandlerOutput for consistency. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + output_models = {d.output_model for d in result.descriptors} + + # All handlers should have the same output model + assert len(output_models) == 1, ( + f"Expected all handlers to have same output_model, got {output_models}" + ) + + # Verify it's ModelHandlerOutput + expected_output = "omnibase_core.models.dispatch.ModelHandlerOutput" + assert output_models == {expected_output}, ( + f"Expected output_model '{expected_output}', got {output_models}" + ) + + @pytest.mark.asyncio + async def test_all_descriptors_have_same_input_model(self) -> None: + """All bootstrap handlers should use JsonDict as input model. + + Bootstrap handlers use envelope-based routing with JsonDict payloads. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + input_models = {d.input_model for d in result.descriptors} + + # All handlers should have the same input model + assert len(input_models) == 1, ( + f"Expected all handlers to have same input_model, got {input_models}" + ) + + # Verify it's JsonDict + expected_input = "omnibase_infra.models.types.JsonDict" + assert input_models == {expected_input}, ( + f"Expected input_model '{expected_input}', got {input_models}" + ) + + @pytest.mark.asyncio + async def test_consul_handler_descriptor_content(self) -> None: + """Verify the Consul handler descriptor has expected content.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + consul_descriptors = [ + d for d in result.descriptors if d.handler_id == "bootstrap.consul" + ] + + assert len(consul_descriptors) == 1, "Should have exactly one Consul handler" + + descriptor = consul_descriptors[0] + assert descriptor.name == "Consul Handler" + assert "consul" in descriptor.description.lower() + assert descriptor.handler_kind == "effect" + + @pytest.mark.asyncio + async def test_db_handler_descriptor_content(self) -> None: + """Verify the Database handler descriptor has expected content.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + db_descriptors = [ + d for d in result.descriptors if d.handler_id == "bootstrap.db" + ] + + assert len(db_descriptors) == 1, "Should have exactly one Database handler" + + descriptor = db_descriptors[0] + assert descriptor.name == "Database Handler" + assert ( + "database" in descriptor.description.lower() + or "postgres" in descriptor.description.lower() + ) + assert descriptor.handler_kind == "effect" + + @pytest.mark.asyncio + async def test_http_handler_descriptor_content(self) -> None: + """Verify the HTTP handler descriptor has expected content.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + http_descriptors = [ + d for d in result.descriptors if d.handler_id == "bootstrap.http" + ] + + assert len(http_descriptors) == 1, "Should have exactly one HTTP handler" + + descriptor = http_descriptors[0] + assert descriptor.name == "HTTP Handler" + assert "http" in descriptor.description.lower() + assert descriptor.handler_kind == "effect" + + @pytest.mark.asyncio + async def test_vault_handler_descriptor_content(self) -> None: + """Verify the Vault handler descriptor has expected content.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + vault_descriptors = [ + d for d in result.descriptors if d.handler_id == "bootstrap.vault" + ] + + assert len(vault_descriptors) == 1, "Should have exactly one Vault handler" + + descriptor = vault_descriptors[0] + assert descriptor.name == "Vault Handler" + assert "vault" in descriptor.description.lower() + assert descriptor.handler_kind == "effect" + + +# ============================================================================= +# Idempotency Tests +# ============================================================================= + + +class TestHandlerBootstrapSourceIdempotency: + """Tests for idempotency of discover_handlers() calls. + + The discover_handlers() method should be idempotent - calling it multiple + times should return the same results. + """ + + @pytest.mark.asyncio + async def test_multiple_calls_return_same_handler_count(self) -> None: + """Multiple calls to discover_handlers() should return same handler count.""" + source = HandlerBootstrapSource() + + result1 = await source.discover_handlers() + result2 = await source.discover_handlers() + result3 = await source.discover_handlers() + + assert len(result1.descriptors) == len(result2.descriptors) + assert len(result2.descriptors) == len(result3.descriptors) + + @pytest.mark.asyncio + async def test_multiple_calls_return_same_handler_ids(self) -> None: + """Multiple calls to discover_handlers() should return same handler IDs.""" + source = HandlerBootstrapSource() + + result1 = await source.discover_handlers() + result2 = await source.discover_handlers() + + ids1 = {d.handler_id for d in result1.descriptors} + ids2 = {d.handler_id for d in result2.descriptors} + + assert ids1 == ids2 + + @pytest.mark.asyncio + async def test_multiple_calls_return_consistent_descriptor_values(self) -> None: + """Multiple calls should return descriptors with consistent values.""" + source = HandlerBootstrapSource() + + result1 = await source.discover_handlers() + result2 = await source.discover_handlers() + + # Build lookup by handler_id + descriptors1 = {d.handler_id: d for d in result1.descriptors} + descriptors2 = {d.handler_id: d for d in result2.descriptors} + + for handler_id, d1 in descriptors1.items(): + d2 = descriptors2[handler_id] + + assert d1.name == d2.name + assert str(d1.version) == str(d2.version) + assert d1.handler_kind == d2.handler_kind + assert d1.input_model == d2.input_model + assert d1.output_model == d2.output_model + assert d1.description == d2.description + assert d1.contract_path == d2.contract_path + + @pytest.mark.asyncio + async def test_different_instances_return_same_results(self) -> None: + """Different HandlerBootstrapSource instances should return same results.""" + source1 = HandlerBootstrapSource() + source2 = HandlerBootstrapSource() + + result1 = await source1.discover_handlers() + result2 = await source2.discover_handlers() + + ids1 = {d.handler_id for d in result1.descriptors} + ids2 = {d.handler_id for d in result2.descriptors} + + assert ids1 == ids2 + assert len(result1.descriptors) == len(result2.descriptors) + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestHandlerBootstrapSourceEdgeCases: + """Tests for edge cases and boundary conditions.""" + + @pytest.mark.asyncio + async def test_descriptors_are_frozen(self) -> None: + """ModelHandlerDescriptor instances should be frozen (immutable). + + The ModelHandlerDescriptor model config has frozen=True. + """ + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + # Attempting to modify a frozen model should raise ValidationError + with pytest.raises(ValidationError): + descriptor.handler_id = "modified" # type: ignore[misc] + + @pytest.mark.asyncio + async def test_no_duplicate_handler_ids(self) -> None: + """There should be no duplicate handler IDs in the result.""" + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + handler_ids = [d.handler_id for d in result.descriptors] + unique_ids = set(handler_ids) + + assert len(handler_ids) == len(unique_ids), ( + f"Found duplicate handler IDs: " + f"{[hid for hid in handler_ids if handler_ids.count(hid) > 1]}" + ) + + @pytest.mark.asyncio + async def test_handler_kind_is_valid_literal_type(self) -> None: + """handler_kind should be one of the valid literal types. + + Valid handler kinds: compute, effect, reducer, orchestrator + """ + valid_kinds = {"compute", "effect", "reducer", "orchestrator"} + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert descriptor.handler_kind in valid_kinds, ( + f"Handler '{descriptor.handler_id}' has invalid kind " + f"'{descriptor.handler_kind}', expected one of {valid_kinds}" + ) + + @pytest.mark.asyncio + async def test_version_is_model_semver_instance(self) -> None: + """version field should be a ModelSemVer instance, not a string.""" + from omnibase_core.models.primitives.model_semver import ModelSemVer + + source = HandlerBootstrapSource() + + result = await source.discover_handlers() + + for descriptor in result.descriptors: + assert isinstance(descriptor.version, ModelSemVer), ( + f"Handler '{descriptor.handler_id}' version is " + f"{type(descriptor.version).__name__}, expected ModelSemVer" + ) + + @pytest.mark.asyncio + async def test_all_handler_classes_are_importable(self) -> None: + """Verify all bootstrap handler classes can be dynamically imported. + + This test ensures that handler_class paths are valid and importable, + catching refactoring issues (renamed/moved handlers) early. + """ + import importlib + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + for descriptor in result.descriptors: + handler_class_path = descriptor.handler_class + assert handler_class_path is not None, ( + f"Handler {descriptor.handler_id} missing handler_class" + ) + + # Split into module and class name + module_path, class_name = handler_class_path.rsplit(".", 1) + + # Attempt to import the module + try: + module = importlib.import_module(module_path) + except ImportError as e: + pytest.fail( + f"Handler {descriptor.handler_id}: Could not import module " + f"'{module_path}': {e}" + ) + + # Verify the class exists in the module + assert hasattr(module, class_name), ( + f"Handler {descriptor.handler_id}: Class '{class_name}' not found " + f"in module '{module_path}'" + ) + + # Get the class and verify it's a class + handler_cls = getattr(module, class_name) + assert isinstance(handler_cls, type), ( + f"Handler {descriptor.handler_id}: '{class_name}' is not a class" + ) + + +# ============================================================================= +# Model Path Importability Tests (OMN-1087 Review Feedback) +# ============================================================================= + + +class TestHandlerBootstrapSourceModelImportability: + """Tests for input_model and output_model path importability. + + These tests verify that all model paths specified in bootstrap handlers + can be dynamically imported, catching path errors or refactoring issues early. + + Added per OMN-1087 PR review recommendation to add test coverage for + input_model and output_model importability. + """ + + @pytest.mark.asyncio + async def test_all_input_models_are_importable(self) -> None: + """Verify all bootstrap handler input_model paths can be dynamically imported. + + This test ensures that input_model paths are valid and importable, + catching issues like: + - Wrong module path (e.g., omnibase_core.types.JsonDict vs omnibase_infra.models.types.JsonDict) + - Missing types after refactoring + - Typos in module paths + """ + import importlib + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + for descriptor in result.descriptors: + input_model_path = descriptor.input_model + assert input_model_path, ( + f"Handler {descriptor.handler_id} missing input_model" + ) + + # Split into module and type name + module_path, type_name = input_model_path.rsplit(".", 1) + + # Attempt to import the module + try: + module = importlib.import_module(module_path) + except ImportError as e: + pytest.fail( + f"Handler {descriptor.handler_id}: Could not import input_model " + f"module '{module_path}': {e}" + ) + + # Verify the type exists in the module + assert hasattr(module, type_name), ( + f"Handler {descriptor.handler_id}: input_model type '{type_name}' " + f"not found in module '{module_path}'" + ) + + @pytest.mark.asyncio + async def test_all_output_models_are_importable(self) -> None: + """Verify all bootstrap handler output_model paths can be dynamically imported. + + This test ensures that output_model paths are valid and importable, + catching refactoring issues (renamed/moved models) early. + """ + import importlib + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + for descriptor in result.descriptors: + output_model_path = descriptor.output_model + assert output_model_path, ( + f"Handler {descriptor.handler_id} missing output_model" + ) + + # Split into module and type name + module_path, type_name = output_model_path.rsplit(".", 1) + + # Attempt to import the module + try: + module = importlib.import_module(module_path) + except ImportError as e: + pytest.fail( + f"Handler {descriptor.handler_id}: Could not import output_model " + f"module '{module_path}': {e}" + ) + + # Verify the type exists in the module + assert hasattr(module, type_name), ( + f"Handler {descriptor.handler_id}: output_model type '{type_name}' " + f"not found in module '{module_path}'" + ) + + # Verify it's a class (Pydantic model) + model_cls = getattr(module, type_name) + assert isinstance(model_cls, type), ( + f"Handler {descriptor.handler_id}: output_model '{type_name}' is not a class" + ) + + @pytest.mark.asyncio + async def test_input_model_jsondict_is_correct_type(self) -> None: + """Verify JsonDict input_model is the correct type alias. + + JsonDict should be dict[str, object] from omnibase_infra.models.types. + This test catches path confusion between omnibase_core and omnibase_infra. + """ + from omnibase_infra.models.types import JsonDict + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + # All bootstrap handlers should use JsonDict from omnibase_infra + expected_path = "omnibase_infra.models.types.JsonDict" + for descriptor in result.descriptors: + assert descriptor.input_model == expected_path, ( + f"Handler {descriptor.handler_id} uses '{descriptor.input_model}', " + f"expected '{expected_path}'" + ) + + # Verify JsonDict is the correct type alias + # JsonDict is dict[str, object] per CLAUDE.md guidelines + assert JsonDict is not None + + @pytest.mark.asyncio + async def test_output_model_handler_output_is_pydantic_model(self) -> None: + """Verify ModelHandlerOutput output_model is a valid Pydantic model. + + This ensures the output model path points to a proper Pydantic BaseModel + that can be used for response serialization. + """ + from pydantic import BaseModel + + from omnibase_core.models.dispatch import ModelHandlerOutput + + source = HandlerBootstrapSource() + result = await source.discover_handlers() + + # All bootstrap handlers should use ModelHandlerOutput + expected_path = "omnibase_core.models.dispatch.ModelHandlerOutput" + for descriptor in result.descriptors: + assert descriptor.output_model == expected_path, ( + f"Handler {descriptor.handler_id} uses '{descriptor.output_model}', " + f"expected '{expected_path}'" + ) + + # Verify ModelHandlerOutput is a Pydantic model + assert issubclass(ModelHandlerOutput, BaseModel), ( + "ModelHandlerOutput should be a Pydantic BaseModel" + ) + + +# ============================================================================= +# Concurrent Discovery Thread Safety Tests (OMN-1087 Review Feedback) +# ============================================================================= + + +class TestHandlerBootstrapSourceThreadSafety: + """Tests for concurrent discovery thread safety. + + These tests verify that HandlerBootstrapSource.discover_handlers() is + thread-safe and can be called concurrently without race conditions. + + Added per OMN-1087 PR review recommendation for concurrent discovery tests. + """ + + @pytest.mark.asyncio + async def test_concurrent_discovery_returns_consistent_results(self) -> None: + """Multiple concurrent discover_handlers() calls should return consistent results. + + This tests that the double-checked locking pattern in _ensure_model_rebuilt() + works correctly under concurrent access. + """ + import asyncio + + source = HandlerBootstrapSource() + concurrent_count = 10 + + # Run multiple discover_handlers() calls concurrently + tasks = [source.discover_handlers() for _ in range(concurrent_count)] + results = await asyncio.gather(*tasks) + + # All results should have the same handler count + handler_counts = [len(r.descriptors) for r in results] + assert all(count == handler_counts[0] for count in handler_counts), ( + f"Inconsistent handler counts from concurrent calls: {handler_counts}" + ) + + # All results should have the same handler IDs + reference_ids = {d.handler_id for d in results[0].descriptors} + for i, result in enumerate(results[1:], start=2): + result_ids = {d.handler_id for d in result.descriptors} + assert result_ids == reference_ids, ( + f"Result {i} has different handler IDs than result 1" + ) + + @pytest.mark.asyncio + async def test_concurrent_discovery_with_multiple_sources(self) -> None: + """Multiple HandlerBootstrapSource instances accessed concurrently. + + This tests that multiple independent sources work correctly when + their discover_handlers() methods are called concurrently. + """ + import asyncio + + source_count = 5 + + async def discover_from_new_source() -> set[str]: + source = HandlerBootstrapSource() + result = await source.discover_handlers() + return {d.handler_id for d in result.descriptors} + + # Run discovery on multiple source instances concurrently + tasks = [discover_from_new_source() for _ in range(source_count)] + results = await asyncio.gather(*tasks) + + # All results should have the same handler IDs + reference_ids = results[0] + for i, result_ids in enumerate(results[1:], start=2): + assert result_ids == reference_ids, ( + f"Source {i} returned different handler IDs" + ) + + def test_model_rebuild_lock_is_thread_safe(self) -> None: + """The _ensure_model_rebuilt() function should be thread-safe. + + This tests the thread safety of the model rebuild initialization + using actual threads (not asyncio). + """ + import threading + from concurrent.futures import ThreadPoolExecutor, as_completed + + from omnibase_infra.runtime.handler_bootstrap_source import ( + _ensure_model_rebuilt, + ) + + thread_count = 20 + results: list[bool] = [] + exceptions: list[Exception] = [] + lock = threading.Lock() + + def call_ensure_rebuild() -> bool: + try: + _ensure_model_rebuilt() + return True + except Exception as e: + with lock: + exceptions.append(e) + return False + + # Call _ensure_model_rebuilt from multiple threads simultaneously + with ThreadPoolExecutor(max_workers=thread_count) as executor: + futures = [ + executor.submit(call_ensure_rebuild) for _ in range(thread_count) + ] + for future in as_completed(futures): + with lock: + results.append(future.result()) + + # All calls should succeed + assert all(results), f"Some threads failed: {exceptions}" + assert len(exceptions) == 0, ( + f"Exceptions during concurrent rebuild: {exceptions}" + ) + + @pytest.mark.asyncio + async def test_rapid_concurrent_discovery_stress(self) -> None: + """Stress test with many rapid concurrent discovery calls. + + This tests behavior under high concurrency to catch subtle + race conditions in the discovery process. + """ + import asyncio + + source = HandlerBootstrapSource() + concurrent_count = 50 + + # Run many discover_handlers() calls concurrently + tasks = [source.discover_handlers() for _ in range(concurrent_count)] + results = await asyncio.gather(*tasks) + + # Verify all results are valid + for i, result in enumerate(results): + assert len(result.descriptors) == 4, ( + f"Result {i + 1} has wrong handler count: {len(result.descriptors)}" + ) + assert result.validation_errors == [], ( + f"Result {i + 1} has validation errors" + ) + + +# ============================================================================= +# Performance Characteristics Tests +# ============================================================================= + + +class TestHandlerBootstrapSourcePerformance: + """Tests for performance characteristics. + + These tests verify the documented performance guarantees: + - No filesystem or network I/O required + - Constant time O(1) discovery + - Typical performance: <1ms for all handlers + """ + + @pytest.mark.asyncio + async def test_discovery_is_fast(self) -> None: + """discover_handlers() should complete quickly (no I/O). + + The bootstrap source should be very fast since it uses + hardcoded definitions with no filesystem or network I/O. + """ + import time + + source = HandlerBootstrapSource() + + start = time.perf_counter() + await source.discover_handlers() + duration = time.perf_counter() - start + + # Should complete in under 50ms (generous bound for CI variance) + # Typical execution is <10ms since this is pure in-memory operation + assert duration < 0.05, ( + f"discover_handlers() took {duration:.3f}s, expected < 0.05s" + ) + + @pytest.mark.asyncio + async def test_multiple_rapid_calls_are_fast(self) -> None: + """Multiple rapid calls should all be fast.""" + import time + + source = HandlerBootstrapSource() + call_count = 100 + + start = time.perf_counter() + for _ in range(call_count): + await source.discover_handlers() + total_duration = time.perf_counter() - start + + avg_duration = total_duration / call_count + + # Average should be under 10ms per call + assert avg_duration < 0.01, ( + f"Average call took {avg_duration * 1000:.2f}ms, expected < 10ms" + ) + + +__all__ = [ + "TestHandlerBootstrapSourceDescriptors", + "TestHandlerBootstrapSourceDiscovery", + "TestHandlerBootstrapSourceEdgeCases", + "TestHandlerBootstrapSourceIdempotency", + "TestHandlerBootstrapSourceModelImportability", + "TestHandlerBootstrapSourcePerformance", + "TestHandlerBootstrapSourceProtocolCompliance", + "TestHandlerBootstrapSourceThreadSafety", +] diff --git a/tests/unit/runtime/test_handler_discovery.py b/tests/unit/runtime/test_handler_discovery.py index 770e4f1d6c..e1d02916d2 100644 --- a/tests/unit/runtime/test_handler_discovery.py +++ b/tests/unit/runtime/test_handler_discovery.py @@ -16,10 +16,24 @@ - Empty contracts directory handling - Health check no_handlers_registered field +Behavior Note (OMN-1087): + With bootstrap handler integration, the runtime NOW registers core + infrastructure handlers (consul, db, http, vault) FIRST via + HandlerBootstrapSource before contract-based discovery runs. + + This means: + - Even when all contracts fail to load, the runtime can start with + bootstrap handlers (graceful degradation) + - Tests verify that contract errors are logged as warnings while + the runtime starts successfully with bootstrap handlers + - The no_handlers_registered field will be False as long as bootstrap + handlers are successfully registered + Related: - src/omnibase_infra/runtime/runtime_host_process.py - src/omnibase_infra/runtime/contract_handler_discovery.py - src/omnibase_infra/runtime/handler_plugin_loader.py + - src/omnibase_infra/runtime/handler_bootstrap_source.py Note: These tests create temporary handler contract YAML files that point to @@ -505,24 +519,33 @@ class TestInvalidContractYamlHandling: These tests verify that various types of invalid contracts are handled gracefully with proper error messages. - Note: When using isolated registries and all contracts are invalid, - the runtime will fail fast with ProtocolConfigurationError. These - tests verify the logging and error handling behavior. + Behavior Note (OMN-1087): + With bootstrap handler integration, the runtime NOW registers core + infrastructure handlers (consul, db, http, vault) FIRST before contract + discovery. This means even when all contract-based discovery fails, the + runtime can still start because bootstrap handlers are available. + + Tests verify graceful degradation: contract errors are logged as warnings, + but the runtime starts successfully with bootstrap handlers. """ @pytest.mark.asyncio - async def test_invalid_yaml_syntax_raises_configuration_error( + async def test_invalid_yaml_syntax_logs_warning_and_starts_with_bootstrap( self, invalid_yaml_contract_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify invalid YAML syntax is logged and raises ProtocolConfigurationError. + """Verify invalid YAML syntax is logged but runtime starts with bootstrap handlers. - When using an isolated registry and all contracts have invalid YAML, - the runtime should fail fast because no handlers can be registered. - """ - from omnibase_infra.errors import ProtocolConfigurationError + With bootstrap handler integration (OMN-1087), even when all contracts + have invalid YAML, the runtime starts successfully because bootstrap + handlers (consul, db, http, vault) are registered FIRST. + This test verifies graceful degradation behavior: + - Contract errors are logged as warnings + - Bootstrap handlers ARE registered despite contract failures + - Runtime starts successfully + """ event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -536,8 +559,8 @@ async def test_invalid_yaml_syntax_raises_configuration_error( try: with caplog.at_level(logging.WARNING): - with pytest.raises(ProtocolConfigurationError) as exc_info: - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() # Should have warning about invalid YAML in logs warning_messages = " ".join( @@ -547,27 +570,41 @@ async def test_invalid_yaml_syntax_raises_configuration_error( "invalid" in warning_messages.lower() or "yaml" in warning_messages.lower() or "error" in warning_messages.lower() + ), "Should have warnings about invalid YAML contracts" + + # Runtime should be running with bootstrap handlers + assert process.is_running, ( + "Runtime should start with bootstrap handlers" ) - # Verify the exception - error_message = str(exc_info.value) - assert "No handlers registered" in error_message + # Verify at least one bootstrap handler is registered + # Note: Some bootstrap handlers (consul, db, vault) may fail to + # instantiate in test environment due to missing infrastructure, + # but HTTP handler should always succeed. + health = await process.health_check() + assert health["no_handlers_registered"] is False, ( + "At least one bootstrap handler should be registered" + ) + # At minimum, HTTP handler should be registered + registered = health["registered_handlers"] + assert len(registered) >= 1, ( + f"Expected at least 1 bootstrap handler, got {len(registered)}" + ) finally: await process.stop() @pytest.mark.asyncio - async def test_missing_required_fields_raises_configuration_error( + async def test_missing_required_fields_logs_warning_and_starts_with_bootstrap( self, missing_fields_contract_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify contracts with missing required fields raise ProtocolConfigurationError. + """Verify contracts with missing fields log warnings but runtime starts. - When using an isolated registry and all contracts are missing fields, - the runtime should fail fast. + With bootstrap handler integration (OMN-1087), even when all contracts + are missing required fields, the runtime starts successfully because + bootstrap handlers are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -581,30 +618,38 @@ async def test_missing_required_fields_raises_configuration_error( try: with caplog.at_level(logging.WARNING): - with pytest.raises(ProtocolConfigurationError): - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() # Should have warnings about missing fields warning_logs = [ r for r in caplog.records if r.levelno >= logging.WARNING ] assert len(warning_logs) > 0, "Should have warnings for missing fields" + + # Runtime should be running with bootstrap handlers + assert process.is_running, ( + "Runtime should start with bootstrap handlers" + ) + + # Verify bootstrap handlers are registered + health = await process.health_check() + assert health["no_handlers_registered"] is False finally: await process.stop() @pytest.mark.asyncio - async def test_nonexistent_handler_module_raises_configuration_error( + async def test_nonexistent_handler_module_logs_warning_and_starts_with_bootstrap( self, nonexistent_handler_contract_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify contracts pointing to non-existent modules raise ProtocolConfigurationError. + """Verify contracts with non-existent modules log warnings but runtime starts. - Import errors should be logged and the runtime should fail fast - when no handlers can be loaded. + With bootstrap handler integration (OMN-1087), import errors are logged + as warnings but the runtime starts successfully because bootstrap handlers + are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -618,30 +663,34 @@ async def test_nonexistent_handler_module_raises_configuration_error( try: with caplog.at_level(logging.WARNING): - with pytest.raises(ProtocolConfigurationError): - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() # Should have warnings about import failures warning_logs = [ r for r in caplog.records if r.levelno >= logging.WARNING ] assert len(warning_logs) > 0, "Should have warnings for import failures" + + # Runtime should be running with bootstrap handlers + assert process.is_running, ( + "Runtime should start with bootstrap handlers" + ) finally: await process.stop() @pytest.mark.asyncio - async def test_empty_yaml_file_raises_configuration_error( + async def test_empty_yaml_file_starts_with_bootstrap_handlers( self, empty_yaml_contract_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify empty YAML files cause ProtocolConfigurationError. + """Verify empty YAML files are skipped but runtime starts with bootstrap handlers. - Empty contracts should be skipped, and with an isolated registry, - the runtime should fail fast because no handlers can be registered. + With bootstrap handler integration (OMN-1087), empty contracts are skipped + but the runtime starts successfully because bootstrap handlers are + registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -655,23 +704,30 @@ async def test_empty_yaml_file_raises_configuration_error( try: with caplog.at_level(logging.WARNING): - with pytest.raises(ProtocolConfigurationError): - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() + + # Runtime should be running with bootstrap handlers + assert process.is_running, ( + "Runtime should start with bootstrap handlers" + ) + + # Verify bootstrap handlers are registered + health = await process.health_check() + assert health["no_handlers_registered"] is False finally: await process.stop() @pytest.mark.asyncio - async def test_empty_contract_directory_raises_configuration_error( + async def test_empty_contract_directory_starts_with_bootstrap_handlers( self, empty_contract_dir: Path, ) -> None: - """Verify empty directories raise ProtocolConfigurationError. + """Verify empty directories work with bootstrap handlers. - An empty directory with an isolated registry should cause - the runtime to fail fast because no handlers can be registered. + With bootstrap handler integration (OMN-1087), an empty contract + directory is fine because bootstrap handlers are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -684,27 +740,32 @@ async def test_empty_contract_directory_raises_configuration_error( ) try: - with pytest.raises(ProtocolConfigurationError) as exc_info: - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() - error_message = str(exc_info.value) - assert "No handlers registered" in error_message + # Runtime should be running + assert process.is_running, "Runtime should start with bootstrap handlers" + + # Verify bootstrap handlers are registered + health = await process.health_check() + assert health["no_handlers_registered"] is False, ( + "Bootstrap handlers should be registered" + ) finally: await process.stop() @pytest.mark.asyncio - async def test_nonexistent_contract_path_raises_configuration_error( + async def test_nonexistent_contract_path_starts_with_bootstrap_handlers( self, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify non-existent contract paths raise ProtocolConfigurationError. + """Verify non-existent paths log errors but runtime starts with bootstrap handlers. - A path that doesn't exist with an isolated registry should cause - the runtime to fail fast. + With bootstrap handler integration (OMN-1087), non-existent contract + paths are logged as errors but the runtime starts successfully because + bootstrap handlers are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - nonexistent_path = tmp_path / "does_not_exist" event_bus = EventBusInmemory() @@ -720,11 +781,17 @@ async def test_nonexistent_contract_path_raises_configuration_error( try: with caplog.at_level(logging.WARNING): - with pytest.raises(ProtocolConfigurationError): - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() + + # Runtime should be running + assert process.is_running, ( + "Runtime should start with bootstrap handlers" + ) - # Should have warning about non-existent path - # (logged by discovery service) + # Verify bootstrap handlers are registered + health = await process.health_check() + assert health["no_handlers_registered"] is False finally: await process.stop() @@ -741,24 +808,27 @@ class TestNoHandlersRegisteredHealthCheck: A runtime with no handlers is considered unhealthy as it cannot process any events. - Note: RuntimeHostProcess has a FAIL-FAST validation that raises - ProtocolConfigurationError if no handlers are registered during start(). - Therefore, tests for empty/invalid scenarios must expect this exception. + Behavior Note (OMN-1087): + With bootstrap handler integration, core infrastructure handlers + (consul, db, http, vault) are ALWAYS registered FIRST before contract + discovery. This means even with empty/invalid contract paths, the + runtime will have bootstrap handlers registered and can start. + + The no_handlers_registered field will be False as long as bootstrap + handlers are successfully registered (which is the normal case). """ @pytest.mark.asyncio - async def test_empty_directory_raises_configuration_error( + async def test_empty_directory_starts_with_bootstrap_handlers( self, empty_contract_dir: Path, ) -> None: - """Verify empty contract directory raises ProtocolConfigurationError. + """Verify empty contract directory works with bootstrap handlers. - When contract_paths points to an empty directory with an isolated - registry, no handlers can be registered, and the runtime should - fail fast with a configuration error. + With bootstrap handler integration (OMN-1087), even when contract_paths + points to an empty directory, the runtime starts successfully because + bootstrap handlers are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry to prevent interference from other tests isolated_registry = RegistryProtocolBinding() @@ -771,12 +841,24 @@ async def test_empty_directory_raises_configuration_error( ) try: - with pytest.raises(ProtocolConfigurationError) as exc_info: - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() + + # Verify runtime is running + assert process.is_running, "Runtime should start with bootstrap handlers" - # Verify error message indicates no handlers - error_message = str(exc_info.value) - assert "No handlers registered" in error_message + # Verify at least one bootstrap handler is registered + # Note: Some bootstrap handlers (consul, db, vault) may fail to + # instantiate in test environment due to missing infrastructure, + # but HTTP handler should always succeed. + health = await process.health_check() + assert health["no_handlers_registered"] is False, ( + "At least one bootstrap handler should be registered" + ) + # At minimum, HTTP handler should be registered + assert len(health["registered_handlers"]) >= 1, ( + "Expected at least 1 bootstrap handler" + ) finally: await process.stop() @@ -816,17 +898,16 @@ async def test_no_handlers_registered_false_with_valid_handlers( await process.stop() @pytest.mark.asyncio - async def test_all_invalid_contracts_raises_configuration_error( + async def test_all_invalid_contracts_starts_with_bootstrap_handlers( self, invalid_yaml_contract_dir: Path, ) -> None: - """Verify all invalid contracts raises ProtocolConfigurationError. + """Verify all invalid contracts still works with bootstrap handlers. - When all provided contracts fail to load with an isolated registry, - the runtime should fail fast with a configuration error. + With bootstrap handler integration (OMN-1087), even when all contracts + fail to load, the runtime starts successfully because bootstrap handlers + are registered FIRST. """ - from omnibase_infra.errors import ProtocolConfigurationError - event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -839,12 +920,17 @@ async def test_all_invalid_contracts_raises_configuration_error( ) try: - with pytest.raises(ProtocolConfigurationError) as exc_info: - await process.start() + # Runtime should start successfully due to bootstrap handlers + await process.start() + + # Verify runtime is running + assert process.is_running, "Runtime should start with bootstrap handlers" - # Verify error message indicates no handlers - error_message = str(exc_info.value) - assert "No handlers registered" in error_message + # Verify bootstrap handlers are registered despite invalid contracts + health = await process.health_check() + assert health["no_handlers_registered"] is False, ( + "Bootstrap handlers should be registered" + ) finally: await process.stop() @@ -1101,9 +1187,12 @@ async def test_discovery_logs_errors_with_details( invalid_yaml_contract_dir: Path, caplog: pytest.LogCaptureFixture, ) -> None: - """Verify discovery logs error details for failed handlers.""" - from omnibase_infra.errors import ProtocolConfigurationError + """Verify discovery logs error details for failed handlers. + With bootstrap handler integration (OMN-1087), the runtime starts + successfully even when contracts are invalid, but warnings should + still be logged about the invalid contracts. + """ event_bus = EventBusInmemory() # Use isolated registry isolated_registry = RegistryProtocolBinding() @@ -1117,9 +1206,8 @@ async def test_discovery_logs_errors_with_details( try: with caplog.at_level(logging.WARNING): - # With isolated registry, this will raise ProtocolConfigurationError - with pytest.raises(ProtocolConfigurationError): - await process.start() + # Runtime starts successfully due to bootstrap handlers + await process.start() # Should have warning logs with details about the invalid contract warning_logs = [ @@ -1134,6 +1222,11 @@ async def test_discovery_logs_errors_with_details( for term in ["error", "fail", "invalid", "yaml"] ) assert has_error_indicator, "Warnings should indicate errors" + + # Verify runtime is running with bootstrap handlers + assert process.is_running, ( + "Runtime should start with bootstrap handlers" + ) finally: await process.stop() diff --git a/tests/unit/runtime/test_kernel.py b/tests/unit/runtime/test_kernel.py index 760d8fba10..c8e35bb106 100644 --- a/tests/unit/runtime/test_kernel.py +++ b/tests/unit/runtime/test_kernel.py @@ -663,6 +663,7 @@ async def mock_wait_for_impl( async def test_bootstrap_passes_container_to_service_health( self, + mock_wire_infrastructure: MagicMock, mock_runtime_host: MagicMock, mock_event_bus: MagicMock, mock_health_server: MagicMock, @@ -686,14 +687,14 @@ async def test_bootstrap_passes_container_to_service_health( assert "container" in call_kwargs, ( "Expected 'container' parameter to be passed to ServiceHealth" ) - # Verify the container is a ModelONEXContainer instance + # Verify the container is the mocked container instance + # (mock_wire_infrastructure mocks ModelONEXContainer, so it returns MagicMock) container_arg = call_kwargs["container"] - assert isinstance(container_arg, ModelONEXContainer), ( - f"Expected container to be ModelONEXContainer, got {type(container_arg)}" - ) + assert container_arg is not None, "Container should not be None" async def test_bootstrap_passes_all_required_args_to_service_health( self, + mock_wire_infrastructure: MagicMock, mock_runtime_host: MagicMock, mock_event_bus: MagicMock, mock_health_server: MagicMock, @@ -725,7 +726,8 @@ async def test_bootstrap_passes_all_required_args_to_service_health( ) # Verify specific values - assert isinstance(call_kwargs["container"], ModelONEXContainer) + # Note: container is mocked by mock_wire_infrastructure, so we just verify it's passed + assert call_kwargs["container"] is not None, "Container should not be None" assert call_kwargs["runtime"] == mock_runtime_host.return_value assert call_kwargs["port"] == DEFAULT_HTTP_PORT assert call_kwargs["version"] == KERNEL_VERSION diff --git a/tests/unit/runtime/test_runtime_host_process.py b/tests/unit/runtime/test_runtime_host_process.py index dee564d349..4fe88e24db 100644 --- a/tests/unit/runtime/test_runtime_host_process.py +++ b/tests/unit/runtime/test_runtime_host_process.py @@ -647,25 +647,32 @@ async def test_start_starts_event_bus(self) -> None: await process.stop() @pytest.mark.asyncio - async def test_start_wires_handlers(self) -> None: - """Test that start() wires handlers via the wiring module. + async def test_start_registers_bootstrap_handlers(self) -> None: + """Test that start() registers bootstrap handlers. - The RuntimeHostProcess should use the wiring module to register - all configured handlers when started. + The RuntimeHostProcess should register bootstrap handlers (consul, db, + http, vault) via HandlerBootstrapSource when started. """ + from omnibase_infra.runtime.handler_registry import ( + HANDLER_TYPE_CONSUL, + HANDLER_TYPE_DATABASE, + HANDLER_TYPE_HTTP, + HANDLER_TYPE_VAULT, + get_handler_registry, + ) process = RuntimeHostProcess() + await process.start() - with patch( - "omnibase_infra.runtime.service_runtime_host_process.wire_handlers" - ) as mock_wire: - mock_wire.return_value = {} - await process.start() - - try: - mock_wire.assert_called_once() - finally: - await process.stop() + try: + # Verify bootstrap handlers are registered + registry = get_handler_registry() + assert registry.is_registered(HANDLER_TYPE_CONSUL) + assert registry.is_registered(HANDLER_TYPE_DATABASE) + assert registry.is_registered(HANDLER_TYPE_HTTP) + assert registry.is_registered(HANDLER_TYPE_VAULT) + finally: + await process.stop() @pytest.mark.asyncio async def test_start_subscribes_to_input_topic(self) -> None: diff --git a/tests/unit/runtime/test_secret_resolver.py b/tests/unit/runtime/test_secret_resolver.py index 61d80d52f6..4963fdb0c0 100644 --- a/tests/unit/runtime/test_secret_resolver.py +++ b/tests/unit/runtime/test_secret_resolver.py @@ -1250,7 +1250,10 @@ def test_file_permission_error_returns_none(self) -> None: # Mock Path.open to raise PermissionError only for the target file # We need a surgical mock that doesn't break pytest's file handling original_open = Path.open - target_path = str(secret_file) + # IMPORTANT: Resolve the target path to handle symlinks (e.g., on macOS + # /var -> /private/var). The SecretResolver uses resolved_path.open(), + # so we must compare against the resolved path. + target_path = str(secret_file.resolve()) def mock_open(self: Path, *args: object, **kwargs: object) -> object: if str(self) == target_path: