diff --git a/docs/runtime-wrapper-extension.md b/docs/runtime-wrapper-extension.md new file mode 100644 index 0000000..1a1dd79 --- /dev/null +++ b/docs/runtime-wrapper-extension.md @@ -0,0 +1,85 @@ +# Governance Integration Point + +`uipath-runtime` wraps runtimes with governance via a single direct +function, `apply_governance_wrapper`, gated by the +`EnablePythonGovernanceChecker` feature flag. + +Governance contracts (feature-flag, exceptions, models) live in +`uipath.core.governance` (in `uipath-core`); the runtime-side wrapper +lives here in `uipath.runtime.governance`. Runtime has **no separate +`uipath-governance` dependency** — the contracts namespace is always +available because `uipath-core` is already a hard dep. When the flag +is off, `uipath.runtime.governance.wrapper` is **not imported** — its +transitive cost stays off the startup path. + +## How it works + +``` +UiPathRuntimeFactoryRegistry.get(...) + ↓ returns +UiPathWrappedRuntimeFactory.new_runtime(...) + ↓ calls +apply_governance_wrapper(runtime, context, runtime_id) + ↓ + if is_governance_enabled(): + from uipath.runtime.governance.wrapper import governance_wrapper # lazy + return governance_wrapper(runtime, context, runtime_id) + else: + return runtime # unwrapped, no governance import +``` + +## Feature flag + +| Setting | Effect | +|---|---| +| `FeatureFlags.configure_flags({"EnablePythonGovernanceChecker": True})` (typically via gitops) | Governance is applied | +| `UIPATH_FEATURE_EnablePythonGovernanceChecker=true` env var | Governance is applied (fallback when no programmatic config) | +| Neither set | Governance **not** applied; `uipath.runtime.governance.wrapper` is **not imported** | + +Resolution and fallback semantics come from `uipath-core`'s +`FeatureFlags.is_flag_enabled(..., default=False)`. Programmatic +configuration beats env var. + +## API + +```python +from uipath.runtime import ( + GOVERNANCE_FEATURE_FLAG, # "EnablePythonGovernanceChecker" + apply_governance_wrapper, # the call-site +) +``` + +`apply_governance_wrapper(runtime, context, runtime_id)` is an +`async` function. It returns the original runtime untouched when the +flag is off or when the wrapper itself raises — governance failures +must never break agent execution. + +## Why deferred-import matters + +When the flag is off, `apply_governance_wrapper` returns before the +`from uipath.runtime.governance.wrapper import governance_wrapper` line +ever runs. That keeps governance's transitive imports — audit, +evaluator, OpenTelemetry, the policy index — entirely off the startup +hot path. + +## Testing + +Force the flag on/off per test via `FeatureFlags`: + +```python +from uipath.core.feature_flags import FeatureFlags +from uipath.runtime.wrapper import GOVERNANCE_FEATURE_FLAG + +# Force enable +FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: True}) + +# Force disable +FeatureFlags.configure_flags({GOVERNANCE_FEATURE_FLAG: False}) + +# Reset (typically in a teardown fixture) +FeatureFlags.reset_flags() +``` + +Use `sys.modules` patching to stub `uipath.runtime.governance.wrapper` +when you need to assert against the wrapper invocation without +actually importing it — see `tests/test_wrapper.py` for the fixture. diff --git a/src/uipath/runtime/registry.py b/src/uipath/runtime/registry.py index 032aee3..5a077e9 100644 --- a/src/uipath/runtime/registry.py +++ b/src/uipath/runtime/registry.py @@ -45,12 +45,21 @@ def get( """Get factory instance by name or auto-detect from config files. Args: - name: Optional factory name - search_path: Path to search for config files - context: UiPathRuntimeContext to pass to factory + name: Optional factory name. When supplied, returns the + registered factory for that name. + search_path: Path to search for config files when ``name`` is + not supplied. Auto-detection walks the registered + factories in reverse-registration order and picks the + first one whose declared config file exists at + ``search_path``. + context: :class:`UiPathRuntimeContext` to pass to the factory + constructor. Returns: - Factory instance + The registered :class:`UiPathRuntimeFactoryProtocol` + instance. Cross-cutting concerns (governance, audit, …) are + composed by the host into the decorator chain — they are + **not** auto-applied here. """ if name: if name not in cls._factories: @@ -67,7 +76,9 @@ def get( # Fallback to default if cls._default_name is None: - raise ValueError("No default factory registered and no config file found") + raise ValueError( + "No default factory registered and no config file found" + ) factory_callable, _ = cls._factories[cls._default_name] return factory_callable(context) diff --git a/tests/test_registry.py b/tests/test_registry.py index 86eda5b..8fc54d0 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -252,7 +252,8 @@ def create_factory( UiPathRuntimeFactoryRegistry.register("langgraph", create_factory, "langgraph.json") context = UiPathRuntimeContext.with_defaults(entrypoint="test") - factory = UiPathRuntimeFactoryRegistry.get(name="langgraph", context=context) + factory = UiPathRuntimeFactoryRegistry.get( + name="langgraph", context=context ) assert isinstance(factory, MockLangGraphFactory) assert factory.context == context @@ -284,7 +285,8 @@ def create_langgraph( Path(temp_dir, "langgraph.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir ) assert isinstance(factory, MockLangGraphFactory) @@ -309,7 +311,8 @@ def create_llamaindex( Path(temp_dir, "llamaindex.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir ) assert isinstance(factory, MockLlamaIndexFactory) @@ -334,7 +337,8 @@ def create_langgraph( Path(temp_dir, "uipath.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir ) assert isinstance(factory, MockFunctionsFactory) @@ -357,7 +361,8 @@ def create_langgraph( ) UiPathRuntimeFactoryRegistry.set_default("functions") - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir ) assert isinstance(factory, MockFunctionsFactory) @@ -399,7 +404,8 @@ def create_langgraph( Path(temp_dir, "uipath.json").touch() Path(temp_dir, "langgraph.json").touch() - factory = UiPathRuntimeFactoryRegistry.get(search_path=temp_dir) + factory = UiPathRuntimeFactoryRegistry.get( + search_path=temp_dir ) assert isinstance(factory, MockLangGraphFactory) @@ -450,3 +456,4 @@ def create_factory( all_factories["malicious"] = "hack.json" assert "malicious" not in UiPathRuntimeFactoryRegistry.get_all() +