From baa54e0293e7b8a3587c8f1bbdf0c7f62bbf7992 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 07:57:03 +0530 Subject: [PATCH 1/9] feat(business-rules): add debug runs to run() Adds DebugRunContext, the second run context of run()/run_async(), for undeployed DMNs read from a Studio project. As in the .NET client's RunAsync, exactly one of deployed/debug must be set and it selects the endpoint: - DeployedRunContext -> /v1/business-rules/evaluate - DebugRunContext(project_id | rule_name, file_name, job_key, organization_unit_id) -> /v1/business-rules/debug/evaluate A debug run named by rule_name also needs job_key (defaults to UIPATH_JOB_KEY) and organization_unit_id. Following business-rules#104, explain=True needs a folder key in both modes, and debug runs send the key whenever one is known so their spans can be stored. BusinessRuleRunResult gains project_id and file_name for debug runs, and RunMode gains DEBUG. Bumps uipath-platform to 0.2.34. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- packages/uipath-platform/pyproject.toml | 2 +- .../platform/business_rules/__init__.py | 8 +- .../business_rules/_business_rules_service.py | 149 ++++++++++++--- .../platform/business_rules/business_rules.py | 43 ++++- .../services/test_business_rules_service.py | 172 ++++++++++++++++++ packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 8 files changed, 350 insertions(+), 30 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index f9c64d5a6..a807130d1 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -96,7 +96,7 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `action_center/` | Task management for human-in-the-loop workflows | | `agenthub/` | System agents and LLM model discovery | | `automation_tracker/` | Business Transaction Service (BTS) for Process Mining | -| `business_rules/` | DMN business rule runs for rules deployed to Orchestrator, behind one `run()` | +| `business_rules/` | DMN business rule runs: deployed rules or undeployed Studio-project DMNs, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 1b57a68a4..3df1c4def 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index ed32805ff..5905d87e0 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,8 +1,8 @@ """Business Rules service package. -Provides the ``BusinessRulesService`` client for running DMN decision models -deployed to Orchestrator as UiPath Business Rules, and the Pydantic models for -its run context and results. +Provides the ``BusinessRulesService`` client for running DMN decision models, +either deployed to Orchestrator as UiPath Business Rules or read from a Studio +project, and the Pydantic models for its run contexts and results. """ from ._business_rules_service import BusinessRulesService @@ -11,6 +11,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, ) @@ -21,6 +22,7 @@ "BusinessRuleRunResult", "BusinessRuleStatus", "BusinessRulesService", + "DebugRunContext", "DeployedRunContext", "RunMode", ] diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 1f55efe74..277c4a02f 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -1,6 +1,7 @@ """Business Rules service for UiPath Platform. -Runs DMN decision models deployed to Orchestrator as business rules. +Runs DMN decision models: a business rule deployed to Orchestrator, or an +undeployed DMN read straight from a Studio project. """ from typing import Any, Dict, List, Optional, Tuple @@ -8,7 +9,7 @@ from uipath.core.tracing import traced from ..common._base_service import BaseService -from ..common._config import UiPathApiConfig +from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec @@ -19,6 +20,7 @@ BusinessRuleError, BusinessRuleRunResult, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, _WireResponse, @@ -26,6 +28,10 @@ ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_DEBUG_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/debug/evaluate") + +_HEADER_ORGANIZATION_UNIT_ID = "x-uipath-organizationunitid" +_HEADER_JOB_KEY = "x-uipath-jobkey" # The service's contract is a batch; this SDK submits exactly one input under this id. _SINGLE_INPUT_ID = "input-1" @@ -37,8 +43,9 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules (DMN decision models). Each call runs one input and returns the decisions it produced. Which model - runs is set by the run context; ``deployed`` names a rule deployed to - Orchestrator. The caller never picks a service endpoint. + runs is set by the run context: ``deployed`` for a rule deployed to + Orchestrator, ``debug`` for an undeployed DMN in a Studio project. The caller + never picks a service endpoint. """ def __init__( @@ -55,7 +62,8 @@ def run( self, input: Dict[str, Any], *, - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext] = None, + debug: Optional[DebugRunContext] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -63,17 +71,23 @@ def run( ) -> BusinessRuleRunResult: """Run a business rule against one input. + Exactly one of ``deployed`` and ``debug`` must be set, and it decides which + model runs. + Args: input: The input to run, keyed by DMN input name. Declared inputs absent from it bind to null. - deployed: The business rule deployed to Orchestrator to run. + deployed: A business rule deployed to Orchestrator. + debug: An undeployed DMN in a Studio project. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. + Requires a folder. folder_key: The key of the folder to run in. folder_path: The path of the folder to run in. Resolved to a key, since the service accepts folder keys only. - A folder is required. When neither ``folder_key`` nor ``folder_path`` is given, it falls back to + A folder is required for a deployed rule and whenever ``explain`` is set. + When neither ``folder_key`` nor ``folder_path`` is given, it falls back to ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. Returns: @@ -87,10 +101,14 @@ def run( Examples: ```python from uipath.platform import UiPath - from uipath.platform.business_rules import DeployedRunContext + from uipath.platform.business_rules import ( + DebugRunContext, + DeployedRunContext, + ) client = UiPath() + # A rule deployed to Orchestrator result = client.business_rules.run( {"creditScore": 740, "age": 34}, deployed=DeployedRunContext(rule_name="Loan Pricing"), @@ -98,13 +116,21 @@ def run( ) for decision in result.decisions: print(decision.decision_name, decision.outputs) + + # An undeployed DMN in a Studio project + result = client.business_rules.run( + {"creditScore": 740}, + debug=DebugRunContext(project_id="0a1b2c3d-...", file_name="Loan.dmn"), + ) ``` """ - _validate_run(input, deployed) + _validate_run(input, deployed, debug) key, path = self._folder_source(folder_key, folder_path) if path: key = self._folders_service.retrieve_folder_key(path) - mode, spec = self._run_spec(input, deployed, decision_names, explain, key) + mode, spec = self._run_spec( + input, deployed, debug, decision_names, explain, key + ) response = self.request( spec.method, url=spec.endpoint, @@ -119,7 +145,8 @@ async def run_async( self, input: Dict[str, Any], *, - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext] = None, + debug: Optional[DebugRunContext] = None, decision_names: Optional[List[str]] = None, explain: bool = False, folder_key: Optional[str] = None, @@ -127,12 +154,17 @@ async def run_async( ) -> BusinessRuleRunResult: """Asynchronously run a business rule against one input. + Exactly one of ``deployed`` and ``debug`` must be set, and it decides which + model runs. + Args: input: The input to run, keyed by DMN input name. Declared inputs absent from it bind to null. - deployed: The business rule deployed to Orchestrator to run. + deployed: A business rule deployed to Orchestrator. + debug: An undeployed DMN in a Studio project. decision_names: The decisions to evaluate; defaults to the whole model. explain: Whether to record condition-level explanations in the trace. + Requires a folder. folder_key: The key of the folder to run in. folder_path: The path of the folder to run in. Resolved to a key, since the service accepts folder keys only. @@ -145,11 +177,13 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ - _validate_run(input, deployed) + _validate_run(input, deployed, debug) key, path = self._folder_source(folder_key, folder_path) if path: key = await self._folders_service.retrieve_folder_key_async(path) - mode, spec = self._run_spec(input, deployed, decision_names, explain, key) + mode, spec = self._run_spec( + input, deployed, debug, decision_names, explain, key + ) response = await self.request_async( spec.method, url=spec.endpoint, @@ -176,11 +210,19 @@ def _folder_source( def _run_spec( self, input: Dict[str, Any], - deployed: DeployedRunContext, + deployed: Optional[DeployedRunContext], + debug: Optional[DebugRunContext], decision_names: Optional[List[str]], explain: bool, folder_key: Optional[str], ) -> Tuple[RunMode, RequestSpec]: + if debug is not None: + if explain and not folder_key: + raise _missing_folder("explain=True") + return RunMode.DEBUG, self._debug_spec( + input, debug, decision_names, explain, folder_key + ) + assert deployed is not None if not folder_key: raise _missing_folder("a deployed business rule") return RunMode.DEPLOYED, self._evaluate_spec( @@ -211,6 +253,56 @@ def _evaluate_spec( headers={HEADER_FOLDER_KEY: folder_key}, ) + def _debug_spec( + self, + input: Dict[str, Any], + debug: DebugRunContext, + decision_names: Optional[List[str]], + explain: bool, + folder_key: Optional[str], + ) -> RequestSpec: + job_key = debug.job_key or UiPathConfig.job_key + named = _present(debug.rule_name) + if not _present(debug.project_id) and not _present(job_key): + raise ValueError( + "debug.job_key must be specified when the run is named by rule_name: " + "the service resolves the project from the job's lineage. " + "Set it or UIPATH_JOB_KEY." + ) + if named and not _present(debug.organization_unit_id): + raise ValueError( + "debug.organization_unit_id must be specified when the run is named " + "by rule_name: it is the folder the job's lineage is read under" + ) + + body: Dict[str, Any] = { + "explain": explain, + "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], + } + if debug.project_id: + body["projectId"] = debug.project_id + if named: + body["businessRuleName"] = debug.rule_name + if debug.file_name: + body["fileName"] = debug.file_name + if decision_names: + body["decisionNames"] = decision_names + + # Folder key: the traces service files the run's spans under it. + headers: Dict[str, str] = {} + if folder_key: + headers[HEADER_FOLDER_KEY] = folder_key + if debug.organization_unit_id: + headers[_HEADER_ORGANIZATION_UNIT_ID] = debug.organization_unit_id + if job_key: + headers[_HEADER_JOB_KEY] = job_key + return RequestSpec( + method="POST", + endpoint=_DEBUG_EVALUATE_ENDPOINT, + json=body, + headers=headers, + ) + def _present(value: Optional[str]) -> bool: # Blank counts as absent, matching how the service reads these fields. @@ -224,10 +316,22 @@ def _missing_folder(needed_for: str) -> ValueError: ) -def _validate_run(input: Dict[str, Any], deployed: DeployedRunContext) -> None: - if deployed is None: - raise ValueError("deployed must be set") - _validate_rule_name(deployed.rule_name, "deployed.rule_name") +def _validate_run( + input: Dict[str, Any], + deployed: Optional[DeployedRunContext], + debug: Optional[DebugRunContext], +) -> None: + if deployed is None and debug is None: + raise ValueError("Exactly one of deployed or debug must be set; neither was") + if deployed is not None and debug is not None: + raise ValueError("Exactly one of deployed or debug must be set; both were") + if deployed is not None: + _validate_rule_name(deployed.rule_name, "deployed.rule_name") + if debug is not None: + if not _present(debug.project_id) and not _present(debug.rule_name): + raise ValueError("debug.project_id or debug.rule_name must be specified") + if _present(debug.rule_name): + _validate_rule_name(debug.rule_name, "debug.rule_name") # type: ignore[arg-type] _validate_input(input) @@ -285,12 +389,15 @@ def _single_result( def _to_run_result(mode: RunMode, response: _WireResponse) -> BusinessRuleRunResult: decisions, errors, status = _single_result(response) + deployed = mode == RunMode.DEPLOYED return BusinessRuleRunResult( mode=mode, status=status, decisions=decisions, errors=errors, top_level_error=response.error.code if response.error else None, - business_rule_name=response.business_rule_name, - version=response.version, + business_rule_name=response.business_rule_name if deployed else None, + version=response.version if deployed else None, + project_id=None if deployed else response.project_id, + file_name=None if deployed else response.file_name, ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 97e5ddfd9..2f7742077 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -18,6 +18,7 @@ class RunMode(str, Enum): """Which kind of model ran, and so which service endpoint served the run.""" DEPLOYED = "Deployed" + DEBUG = "Debug" class DeployedRunContext(BaseModel): @@ -30,6 +31,35 @@ class DeployedRunContext(BaseModel): ) +class DebugRunContext(BaseModel): + """An undeployed DMN read from a Studio project. + + Name the project with ``project_id``, or name the rule with ``rule_name`` and let + the service resolve the project from the job's lineage. The second way also needs + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + """ + + project_id: Optional[str] = Field( + default=None, description="The Studio project holding the DMN." + ) + rule_name: Optional[str] = Field( + default=None, + description="The business rule this run stands in for; the alternative to project_id.", + ) + file_name: Optional[str] = Field( + default=None, + description="The .dmn file in the project; defaults to the first one.", + ) + job_key: Optional[str] = Field( + default=None, + description="The job this run belongs to; defaults to UIPATH_JOB_KEY.", + ) + organization_unit_id: Optional[str] = Field( + default=None, + description="The numeric id of the job's folder; required with rule_name.", + ) + + class BusinessRuleError(BaseModel): """A code/message pair describing an input-level or decision-level error.""" @@ -78,10 +108,17 @@ class BusinessRuleRunResult(BaseModel): description="The request-level error code (e.g. BATCH_TIMEOUT), if one was reported.", ) business_rule_name: Optional[str] = Field( - default=None, description="The deployed rule that ran." + default=None, description="The deployed rule that ran. Deployed mode only." ) version: Optional[str] = Field( - default=None, description="The rule version that ran." + default=None, description="The rule version that ran. Deployed mode only." + ) + project_id: Optional[str] = Field( + default=None, description="The Studio project read. Debug mode only." + ) + file_name: Optional[str] = Field( + default=None, + description="The .dmn file actually read, which may differ from the one asked for. Debug mode only.", ) @@ -100,5 +137,7 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None + project_id: Optional[str] = Field(default=None, alias="projectId") + file_name: Optional[str] = Field(default=None, alias="fileName") error: Optional[BusinessRuleError] = None results: Optional[List[_WireResult]] = None diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 6b2fa4826..a3ae4d955 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -9,6 +9,7 @@ from uipath.platform.business_rules import ( BusinessRulesService, BusinessRuleStatus, + DebugRunContext, DeployedRunContext, RunMode, ) @@ -16,6 +17,7 @@ from uipath.platform.errors import EnrichedException FOLDER_KEY = "5f1f1b0e-2b8a-4c1e-9b8e-1a2b3c4d5e6f" +JOB_KEY = "9d8c7b6a-5f4e-3d2c-1b0a-9f8e7d6c5b4a" LOAN_PRICING = DeployedRunContext(rule_name="Loan Pricing") @@ -36,6 +38,7 @@ def service( ) -> BusinessRulesService: monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) return BusinessRulesService( config=config, execution_context=execution_context, @@ -48,6 +51,11 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" +@pytest.fixture +def debug_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/debug/evaluate" + + def _response(results: list[dict[str, Any]], **extra: Any) -> dict[str, Any]: return { "hasPartialSuccess": False, @@ -68,6 +76,19 @@ def _one_decision(**outputs: Any) -> list[dict[str, Any]]: class TestRunContext: + def test_requires_a_run_context(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="neither was"): + service.run({}, folder_key=FOLDER_KEY) + + def test_rejects_both_run_contexts(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="both were"): + service.run( + {}, + deployed=LOAN_PRICING, + debug=DebugRunContext(project_id="proj-1"), + folder_key=FOLDER_KEY, + ) + @pytest.mark.parametrize( "rule_name", ["", " ", "a/b", "a\\b", "a..b", "a%20b", "a\nb", "x" * 257], @@ -179,6 +200,7 @@ def test_sends_single_input_and_maps_decisions( assert result.top_level_error is None assert result.business_rule_name == "Loan Pricing" assert result.version == "1.0.3" + assert result.project_id is None request = httpx_mock.get_request() assert request is not None @@ -234,6 +256,156 @@ async def test_run_async_resolves_folder_path( assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY +class TestDebug: + def test_by_project_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response( + url=debug_url, + method="POST", + json=_response( + _one_decision(x=1), + projectId="proj-1", + fileName="Rules/Loan.dmn", + traceId="abc123", + ), + ) + + result = service.run( + {"a": 1}, + debug=DebugRunContext(project_id="proj-1", file_name="loan.dmn"), + ) + + assert result.mode == RunMode.DEBUG + assert result.status == BusinessRuleStatus.SUCCESS + assert result.project_id == "proj-1" + assert result.file_name == "Rules/Loan.dmn" + assert result.business_rule_name is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "projectId": "proj-1", + "fileName": "loan.dmn", + "explain": False, + "inputs": [{"id": "input-1", "data": {"a": 1}}], + } + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + assert HEADER_FOLDER_KEY not in request.headers + + def test_by_rule_name_sends_job_and_folder_headers( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext( + rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id="42" + ), + folder_key=FOLDER_KEY, + ) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing" + assert request.headers["x-uipath-jobkey"] == JOB_KEY + assert request.headers["x-uipath-organizationunitid"] == "42" + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_job_key_defaults_from_env( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id="42"), + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="explain=True"): + service.run({}, debug=DebugRunContext(project_id="proj-1"), explain=True) + + def test_explain_sends_resolved_folder_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(project_id="proj-1"), + explain=True, + folder_path="Finance", + ) + + folders_service.retrieve_folder_key.assert_called_once_with("Finance") + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + + def test_requires_project_or_rule_name(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="project_id or debug.rule_name"): + service.run({}, debug=DebugRunContext(file_name="loan.dmn")) + + def test_rule_name_requires_job_key(self, service: BusinessRulesService) -> None: + with pytest.raises(ValueError, match="job_key"): + service.run( + {}, + debug=DebugRunContext( + rule_name="Loan Pricing", organization_unit_id="42" + ), + ) + + def test_rule_name_requires_organization_unit( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="organization_unit_id"): + service.run( + {}, debug=DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) + ) + + def test_rejects_unsafe_debug_rule_name( + self, service: BusinessRulesService + ) -> None: + with pytest.raises(ValueError, match="debug.rule_name"): + service.run({}, debug=DebugRunContext(rule_name="a/b", job_key=JOB_KEY)) + + async def test_run_async_debug( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([], projectId="proj-1")) + + result = await service.run_async({}, debug=DebugRunContext(project_id="proj-1")) + + assert result.mode == RunMode.DEBUG + assert result.project_id == "proj-1" + assert result.status == BusinessRuleStatus.ALL_FAILED + + class TestResult: def test_partial_success_on_207( self, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 54ab45e3d..fee1df2ad 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 9ca759335..0f71f63d9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.33" +version = "0.2.34" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From b81d965fa1254015496edeb50b31520f3a2df7f1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 09:55:15 +0530 Subject: [PATCH 2/9] fix(business-rules): type organization_unit_id as int; gate lineage checks Addresses Copilot review on the debug run context: - DebugRunContext.organization_unit_id is now Optional[int], matching the numeric Orchestrator folder id (and Task.organization_unit_id); it is converted to text only for the x-uipath-organizationunitid header. Numeric strings still validate. - job_key and organization_unit_id are required only when the run is named by rule_name without project_id. With a project_id the service uses the project as given and never reads the job's lineage (StudioDmnResolver.projectIdOf), so neither is needed. - Build run contexts outside pytest.raises so each block has a single call that can raise (Sonar python:S5778). Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 30 ++++--- .../platform/business_rules/business_rules.py | 7 +- .../services/test_business_rules_service.py | 85 ++++++++++++++----- 3 files changed, 86 insertions(+), 36 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 277c4a02f..aff0cc926 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -263,17 +263,21 @@ def _debug_spec( ) -> RequestSpec: job_key = debug.job_key or UiPathConfig.job_key named = _present(debug.rule_name) - if not _present(debug.project_id) and not _present(job_key): - raise ValueError( - "debug.job_key must be specified when the run is named by rule_name: " - "the service resolves the project from the job's lineage. " - "Set it or UIPATH_JOB_KEY." - ) - if named and not _present(debug.organization_unit_id): - raise ValueError( - "debug.organization_unit_id must be specified when the run is named " - "by rule_name: it is the folder the job's lineage is read under" - ) + # A named project is used as given. Only a run named by rule_name alone + # makes the service resolve the project from the job's lineage. + if named and not _present(debug.project_id): + if not _present(job_key): + raise ValueError( + "debug.job_key must be specified when the run is named by " + "rule_name without project_id: the service resolves the project " + "from the job's lineage. Set it or UIPATH_JOB_KEY." + ) + if debug.organization_unit_id is None: + raise ValueError( + "debug.organization_unit_id must be specified when the run is " + "named by rule_name without project_id: it is the folder the " + "job's lineage is read under" + ) body: Dict[str, Any] = { "explain": explain, @@ -292,8 +296,8 @@ def _debug_spec( headers: Dict[str, str] = {} if folder_key: headers[HEADER_FOLDER_KEY] = folder_key - if debug.organization_unit_id: - headers[_HEADER_ORGANIZATION_UNIT_ID] = debug.organization_unit_id + if debug.organization_unit_id is not None: + headers[_HEADER_ORGANIZATION_UNIT_ID] = str(debug.organization_unit_id) if job_key: headers[_HEADER_JOB_KEY] = job_key return RequestSpec( diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 2f7742077..ca1122979 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -36,7 +36,8 @@ class DebugRunContext(BaseModel): Name the project with ``project_id``, or name the rule with ``rule_name`` and let the service resolve the project from the job's lineage. The second way also needs - ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. When + ``project_id`` is set, the project is used as given and neither is needed. """ project_id: Optional[str] = Field( @@ -54,9 +55,9 @@ class DebugRunContext(BaseModel): default=None, description="The job this run belongs to; defaults to UIPATH_JOB_KEY.", ) - organization_unit_id: Optional[str] = Field( + organization_unit_id: Optional[int] = Field( default=None, - description="The numeric id of the job's folder; required with rule_name.", + description="The numeric id of the job's folder; required with rule_name alone.", ) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 9ed8594c7..a893363db 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -81,13 +81,10 @@ def test_requires_a_run_context(self, service: BusinessRulesService) -> None: service.run({}, folder_key=FOLDER_KEY) def test_rejects_both_run_contexts(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(project_id="proj-1") + with pytest.raises(ValueError, match="both were"): - service.run( - {}, - deployed=LOAN_PRICING, - debug=DebugRunContext(project_id="proj-1"), - folder_key=FOLDER_KEY, - ) + service.run({}, deployed=LOAN_PRICING, debug=debug, folder_key=FOLDER_KEY) @pytest.mark.parametrize( "rule_name", @@ -371,7 +368,7 @@ def test_by_rule_name_sends_job_and_folder_headers( service.run( {}, debug=DebugRunContext( - rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id="42" + rule_name="Loan Pricing", job_key=JOB_KEY, organization_unit_id=42 ), folder_key=FOLDER_KEY, ) @@ -395,7 +392,7 @@ def test_job_key_defaults_from_env( service.run( {}, - debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id="42"), + debug=DebugRunContext(rule_name="Loan Pricing", organization_unit_id=42), ) request = httpx_mock.get_request() @@ -403,8 +400,10 @@ def test_job_key_defaults_from_env( assert request.headers["x-uipath-jobkey"] == JOB_KEY def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(project_id="proj-1") + with pytest.raises(ValueError, match="explain=True"): - service.run({}, debug=DebugRunContext(project_id="proj-1"), explain=True) + service.run({}, debug=debug, explain=True) def test_explain_sends_resolved_folder_key( self, @@ -428,31 +427,77 @@ def test_explain_sends_resolved_folder_key( assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY def test_requires_project_or_rule_name(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(file_name="loan.dmn") + with pytest.raises(ValueError, match="project_id or debug.rule_name"): - service.run({}, debug=DebugRunContext(file_name="loan.dmn")) + service.run({}, debug=debug) def test_rule_name_requires_job_key(self, service: BusinessRulesService) -> None: + debug = DebugRunContext(rule_name="Loan Pricing", organization_unit_id=42) + with pytest.raises(ValueError, match="job_key"): - service.run( - {}, - debug=DebugRunContext( - rule_name="Loan Pricing", organization_unit_id="42" - ), - ) + service.run({}, debug=debug) + + def test_accepts_organization_unit_id_as_numeric_string( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response([])) + + debug = DebugRunContext.model_validate( + { + "rule_name": "Loan Pricing", + "job_key": JOB_KEY, + "organization_unit_id": "42", + } + ) + assert debug.organization_unit_id == 42 + + service.run({}, debug=debug) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-organizationunitid"] == "42" + + def test_project_id_with_rule_name_needs_no_job_or_folder_id( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + ) -> None: + # The service uses a named project as given; the job lineage is never read. + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run( + {}, + debug=DebugRunContext(project_id="proj-1", rule_name="Loan Pricing"), + ) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["projectId"] == "proj-1" + assert body["businessRuleName"] == "Loan Pricing" + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers def test_rule_name_requires_organization_unit( self, service: BusinessRulesService ) -> None: + debug = DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) + with pytest.raises(ValueError, match="organization_unit_id"): - service.run( - {}, debug=DebugRunContext(rule_name="Loan Pricing", job_key=JOB_KEY) - ) + service.run({}, debug=debug) def test_rejects_unsafe_debug_rule_name( self, service: BusinessRulesService ) -> None: + debug = DebugRunContext(rule_name="a/b", job_key=JOB_KEY) + with pytest.raises(ValueError, match="debug.rule_name"): - service.run({}, debug=DebugRunContext(rule_name="a/b", job_key=JOB_KEY)) + service.run({}, debug=debug) async def test_run_async_debug( self, From 1b1c912dabf6723496ad41848fb558d6512f10c3 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 08:13:08 +0530 Subject: [PATCH 3/9] feat(business-rules): keep the two debug modes separate on the wire The service documents projectId and businessRuleName as alternative ways to find a debug run's project. Each request now carries exactly one: - Project mode (debug.project_id): body has projectId (+ fileName), no businessRuleName; no x-uipath-jobkey or x-uipath-organizationunitid, even when UIPATH_JOB_KEY is set. Setting job_key or organization_unit_id with project_id is a ValueError. - Job-lineage mode (no project_id): body has businessRuleName; the job key (or UIPATH_JOB_KEY) and organization_unit_id are required and sent. The rule name stays run()'s first argument, validated and remappable by businessRule bindings; it goes on the wire only when it decides which project runs. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 78 ++++++++++++------- .../platform/business_rules/business_rules.py | 17 ++-- .../services/test_business_rules_service.py | 46 ++++++++++- 3 files changed, 104 insertions(+), 37 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 268c25c66..07547135b 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -271,43 +271,23 @@ def _debug_spec( explain: bool, folder_key: Optional[str], ) -> RequestSpec: - job_key = debug.job_key or UiPathConfig.job_key - # A named project is used as given. Without one, the service resolves - # the project from the job's lineage, which needs the job and its folder. - if not _present(debug.project_id): - if not _present(job_key): - raise ValueError( - "debug.job_key must be specified when debug.project_id is not: " - "the service resolves the project from the job's lineage. " - "Set it or UIPATH_JOB_KEY." - ) - if debug.organization_unit_id is None: - raise ValueError( - "debug.organization_unit_id must be specified when " - "debug.project_id is not: it is the folder the job's lineage " - "is read under" - ) + # The service finds the project one of two ways, documented as + # alternatives: by projectId, read as given, or by businessRuleName from + # the running debug job's lineage. Each request carries one mode only. + if _present(debug.project_id): + body, headers = _project_mode(debug) + else: + body, headers = _job_lineage_mode(name, debug) - body: Dict[str, Any] = { - "businessRuleName": name, - "explain": explain, - "inputs": [{"id": _SINGLE_INPUT_ID, "data": input}], - } - if debug.project_id: - body["projectId"] = debug.project_id + body["explain"] = explain + body["inputs"] = [{"id": _SINGLE_INPUT_ID, "data": input}] if debug.file_name: body["fileName"] = debug.file_name if decision_names: body["decisionNames"] = decision_names - # Folder key: the traces service files the run's spans under it. - headers: Dict[str, str] = {} if folder_key: headers[HEADER_FOLDER_KEY] = folder_key - if debug.organization_unit_id is not None: - headers[_HEADER_ORGANIZATION_UNIT_ID] = str(debug.organization_unit_id) - if job_key: - headers[_HEADER_JOB_KEY] = job_key return RequestSpec( method="POST", endpoint=_DEBUG_EVALUATE_ENDPOINT, @@ -316,6 +296,46 @@ def _debug_spec( ) +def _project_mode(debug: DebugRunContext) -> Tuple[Dict[str, Any], Dict[str, str]]: + mixed = [ + field + for field, value in ( + ("job_key", debug.job_key), + ("organization_unit_id", debug.organization_unit_id), + ) + if value is not None + ] + if mixed: + raise ValueError( + f"debug.{' and debug.'.join(mixed)} can't be combined with " + "debug.project_id: a project is read as given, and the job's lineage " + "is only used without one" + ) + return {"projectId": debug.project_id}, {} + + +def _job_lineage_mode( + name: str, debug: DebugRunContext +) -> Tuple[Dict[str, Any], Dict[str, str]]: + job_key = debug.job_key or UiPathConfig.job_key + if not job_key or not job_key.strip(): + raise ValueError( + "debug.job_key must be specified when debug.project_id is not: " + "the service resolves the project from the job's lineage. " + "Set it or UIPATH_JOB_KEY." + ) + if debug.organization_unit_id is None: + raise ValueError( + "debug.organization_unit_id must be specified when debug.project_id " + "is not: it is the folder the job's lineage is read under" + ) + headers = { + _HEADER_JOB_KEY: job_key, + _HEADER_ORGANIZATION_UNIT_ID: str(debug.organization_unit_id), + } + return {"businessRuleName": name}, headers + + class _TraceHeaders(Dict[str, str]): """Request headers that keep the caller's explicit trace header. diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 3039e1fd1..05ecb7baf 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -27,13 +27,18 @@ class RunMode(str, Enum): class DebugRunContext(BaseModel): """Run the undeployed rule from a Studio project instead of the deployed one. - With ``project_id`` the project is read as given. Without it, the service - resolves the project from the running debug job's lineage, which needs - ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id``. + Two exclusive modes, matching the service: + + - **By project:** set ``project_id`` (and optionally ``file_name``). The project + is read as given; ``job_key`` and ``organization_unit_id`` must not be set. + - **By job lineage:** leave ``project_id`` unset. The service finds the project + from the running debug job and checks the rule name against it, so + ``job_key`` (defaults to ``UIPATH_JOB_KEY``) and ``organization_unit_id`` are + required. """ project_id: Optional[str] = Field( - default=None, description="The Studio project holding the rule." + default=None, description="Project mode: the Studio project holding the rule." ) file_name: Optional[str] = Field( default=None, @@ -41,11 +46,11 @@ class DebugRunContext(BaseModel): ) job_key: Optional[str] = Field( default=None, - description="The debug job this run belongs to; defaults to UIPATH_JOB_KEY.", + description="Job-lineage mode: the running debug job; defaults to UIPATH_JOB_KEY.", ) organization_unit_id: Optional[int] = Field( default=None, - description="The numeric id of the job's folder; required without project_id.", + description="Job-lineage mode: the numeric id of the job's folder.", ) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index b9e0eb8ed..a9a22250d 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -693,7 +693,6 @@ def test_by_project_id( request = httpx_mock.get_request() assert request is not None assert json.loads(request.content) == { - "businessRuleName": RULE, "projectId": "proj-1", "fileName": "loan.dmn", "explain": False, @@ -761,6 +760,45 @@ def test_accepts_organization_unit_id_as_numeric_string( assert request is not None assert request.headers["x-uipath-organizationunitid"] == "42" + def test_project_mode_sends_no_job_headers_even_with_env_job_key( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + httpx_mock.add_response(url=debug_url, json=_response([])) + + service.run(RULE, {}, debug=DebugRunContext(project_id="proj-1")) + + request = httpx_mock.get_request() + assert request is not None + body = json.loads(request.content) + assert body["projectId"] == "proj-1" + assert "businessRuleName" not in body + assert "x-uipath-jobkey" not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + + @pytest.mark.parametrize( + ("fields", "message"), + [ + ({"job_key": JOB_KEY}, "debug.job_key can't be combined"), + ({"organization_unit_id": 42}, "debug.organization_unit_id can't be"), + ( + {"job_key": JOB_KEY, "organization_unit_id": 42}, + "debug.job_key and debug.organization_unit_id can't be", + ), + ], + ) + def test_project_mode_rejects_job_lineage_fields( + self, service: BusinessRulesService, fields: dict[str, Any], message: str + ) -> None: + debug = DebugRunContext(project_id="proj-1", **fields) + + with pytest.raises(ValueError, match=message): + service.run(RULE, {}, debug=debug) + def test_explain_requires_a_folder(self, service: BusinessRulesService) -> None: debug = DebugRunContext(project_id="proj-1") @@ -824,7 +862,11 @@ def test_override_applies_to_debug_runs( ) -> None: httpx_mock.add_response(url=debug_url, json=_response([])) - service.run(RULE, {}, debug=DebugRunContext(project_id="proj-1")) + service.run( + RULE, + {}, + debug=DebugRunContext(job_key=JOB_KEY, organization_unit_id=42), + ) request = httpx_mock.get_request() assert request is not None From b940642cd7d80cddad421dc1816134074fd96d58 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 10:25:04 +0530 Subject: [PATCH 4/9] chore(business-rules): bump uipath-platform to 0.2.35 #1912 now takes 0.2.34 (main released 0.2.33), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 3df1c4def..1ee12a6c8 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index fee1df2ad..7d91df83e 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c43717048..d00f06ff7 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.34" +version = "0.2.35" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From b6eead93ffb7d01aaf5c1ecf6239bf3514b7c398 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:08:03 +0530 Subject: [PATCH 5/9] feat(business-rules)!: the SDK picks the debug endpoint; debug runs name only the job run() no longer asks the caller which endpoint to call, as the .NET client does since UiPath/business-rules#116. The first match wins: 1. folder_key or folder_path given: the rule deployed in that folder. 2. A debug session (UIPATH_PROJECT_ID is set, or the job is rooted to a debug job) with a job key in UIPATH_JOB_KEY: the undeployed rule from the project being debugged, through /debug/evaluate. 3. Otherwise: the rule deployed in the environment's folder. The folder key and job key are in every job's environment, so they cannot decide this on their own; the debug-session signal is the one the SDK already uses for AgentHub licensing. A debug run sends the rule name, the input and x-uipath-jobkey, and no folder header: since UiPath/business-rules#130 the service takes the job's folders, and the trace's folder, from its lineage. version and caller are ignored there rather than rejected, so the same code runs in both kinds of run. debug=, DebugRunContext (with project_id, file_name and job_key) and organization_unit_id are removed. The result reports project_id and file_name when the rule came from a project. Smoke-tested against alpha, deployed and debug. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/CLAUDE.md | 2 +- .../platform/business_rules/__init__.py | 7 +- .../business_rules/_business_rules_service.py | 117 ++++++-- .../platform/business_rules/business_rules.py | 15 +- .../services/test_business_rules_service.py | 258 ++++++++++++++++++ 5 files changed, 371 insertions(+), 28 deletions(-) diff --git a/packages/uipath-platform/CLAUDE.md b/packages/uipath-platform/CLAUDE.md index 0a81b30d9..317f7df9b 100644 --- a/packages/uipath-platform/CLAUDE.md +++ b/packages/uipath-platform/CLAUDE.md @@ -96,7 +96,7 @@ Services provide both sync and async variants (e.g., `.invoke()` and `.invoke_as | `action_center/` | Task management for human-in-the-loop workflows | | `agenthub/` | System agents and LLM model discovery | | `automation_tracker/` | Business Transaction Service (BTS) for Process Mining | -| `business_rules/` | Business rule runs: rules deployed to Orchestrator, or undeployed rules in a Studio project, behind one `run()` | +| `business_rules/` | Business rule runs: rules deployed to Orchestrator, or undeployed rules in a debug job's Studio project, behind one `run()` | | `chat/` | LLM gateway, conversations, throttling | | `connections/` | External connection management | | `context_grounding/` | RAG services (DeepRAG, batch RAG, ephemeral indexes) | diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py index b49fd91cf..7fa5d5abb 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/__init__.py @@ -1,8 +1,9 @@ """Business Rules service package. -Provides the ``BusinessRulesService`` client for running UiPath Business Rules -deployed to Orchestrator, and the Pydantic models for its caller, results and -trace context. +Provides the ``BusinessRulesService`` client for running UiPath Business Rules, +either deployed to Orchestrator or, in a debug session, read undeployed from +the project being debugged, and the Pydantic models for its caller, results +and trace context. """ from ._business_rules_service import BusinessRulesService diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 65df6bdc8..14de6a559 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -1,6 +1,7 @@ """Business Rules service for UiPath Platform. -Runs business rules deployed to Orchestrator. +Runs business rules: a rule deployed to Orchestrator, or, inside a debug +session, the undeployed rule from the Studio project being debugged. """ from collections.abc import Mapping @@ -14,7 +15,7 @@ from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext from ..common._models import Endpoint, RequestSpec -from ..constants import HEADER_FOLDER_KEY +from ..constants import HEADER_FOLDER_KEY, HEADER_JOB_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( BusinessRuleCaller, @@ -27,6 +28,7 @@ ) _EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/evaluate") +_DEBUG_EVALUATE_ENDPOINT = Endpoint("businessrules_/v1/business-rules/debug/evaluate") _MAX_INPUT_KEYS = 256 _MAX_RULE_NAME_LENGTH = 256 @@ -35,8 +37,11 @@ class BusinessRulesService(FolderContext, BaseService): """Service for running UiPath Business Rules. - Each call runs one input against a business rule deployed to Orchestrator, - named like any other resource, and returns the decisions it produced. + Each call runs one input against a business rule, named like any other + resource, and returns the decisions it produced. The service picks the + endpoint from the run itself, so the caller never chooses one: inside a debug + session the undeployed rule from the project being debugged runs, and + otherwise the rule deployed to Orchestrator runs. """ def __init__( @@ -68,21 +73,32 @@ def run( input: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. + Ignored on a debug run. decision_names: The decisions to evaluate; defaults to the whole model. folder_key: The key of the folder the rule is deployed in. folder_path: The path of the folder the rule is deployed in. Looked up and sent as its key. caller: Who is running the rule, for the deployed run's audit. - Fields left unset default to the current job's values. + Fields left unset default to the current job's values. Ignored + on a debug run. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. - The rule runs in a folder named by its key: ``folder_key`` is sent as is, - and ``folder_path`` is looked up and sent as its key; the two are - exclusive. When the caller gives neither, it falls back to - ``UIPATH_FOLDER_KEY`` and then ``UIPATH_FOLDER_PATH``. A ``businessRule`` - binding can remap ``name`` and the folder per environment; its folder then - replaces ``folder_key`` or ``folder_path``. + Which rule runs is decided in this order, first match winning: + + 1. ``folder_key`` or ``folder_path`` given: the rule deployed in that + folder. + 2. Inside a debug session (``UIPATH_PROJECT_ID`` is set, or the job is + rooted to a debug job) with a job key in ``UIPATH_JOB_KEY``: the + undeployed rule from the project being debugged. The service finds the + project and its folders from the job's lineage. + 3. Otherwise: the rule deployed in ``UIPATH_FOLDER_KEY``, or else + ``UIPATH_FOLDER_PATH``. + + A deployed run needs a folder key: ``folder_key`` is sent as is, and + ``folder_path`` is looked up and sent as its key; the two are exclusive. + A ``businessRule`` binding can remap ``name`` and the folder per + environment; its folder then replaces ``folder_key`` or ``folder_path``. Returns: BusinessRuleRunResult: The decisions produced for the input. @@ -97,23 +113,32 @@ def run( client = UiPath() + # In a debug session this runs the rule from the project being + # debugged; deployed, the rule in the job's folder. result = client.business_rules.run( - "Loan Pricing", - {"creditScore": 740, "age": 34}, - folder_path="Finance", + "Loan Pricing", {"creditScore": 740, "age": 34} ) for decision in result.decisions: print(decision.decision_name, decision.outputs) + + # Always the rule deployed in a given folder + result = client.business_rules.run( + "Loan Pricing", {"creditScore": 740}, folder_path="Finance" + ) ``` """ + debug_job_key = _debug_job_key(folder_key, folder_path) name, folder_key, folder_path = self._apply_binding( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source(folder_key, folder_path) - if path: - key = self._folders_service.retrieve_folder_key(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + if debug_job_key: + spec = _debug_spec(name, input, debug_job_key, decision_names) + else: + key, path = self._folder_source(folder_key, folder_path) + if path: + key = self._folders_service.retrieve_folder_key(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = self.request( spec.method, url=spec.endpoint, @@ -143,12 +168,14 @@ async def run_async( input: The input to run, keyed by the rule's input names. Declared inputs absent from it bind to null. version: The rule version to run; defaults to the active version. + Ignored on a debug run. decision_names: The decisions to evaluate; defaults to the whole model. folder_key: The key of the folder the rule is deployed in. folder_path: The path of the folder the rule is deployed in. Looked up and sent as its key. caller: Who is running the rule, for the deployed run's audit. - Fields left unset default to the current job's values. + Fields left unset default to the current job's values. Ignored + on a debug run. trace_context: The trace to file the run's spans under. Defaults to the ambient trace: ``UIPATH_TRACE_ID`` and the current span. @@ -159,14 +186,18 @@ async def run_async( ValueError: If the request is invalid or a required folder is missing. EnrichedException: If the service rejects the request. """ + debug_job_key = _debug_job_key(folder_key, folder_path) name, folder_key, folder_path = self._apply_binding( name, folder_key, folder_path ) _validate_run(name, input) - key, path = self._folder_source(folder_key, folder_path) - if path: - key = await self._folders_service.retrieve_folder_key_async(path) - spec = _evaluate_spec(name, input, version, decision_names, key, caller) + if debug_job_key: + spec = _debug_spec(name, input, debug_job_key, decision_names) + else: + key, path = self._folder_source(folder_key, folder_path) + if path: + key = await self._folders_service.retrieve_folder_key_async(path) + spec = _evaluate_spec(name, input, version, decision_names, key, caller) response = await self.request_async( spec.method, url=spec.endpoint, @@ -248,6 +279,44 @@ def _evaluate_spec( ) +def _debug_job_key( + folder_key: Optional[str], folder_path: Optional[str] +) -> Optional[str]: + """Return the debug job's key when this run is a debug run, else None. + + A folder the caller names always means a deployed rule. Otherwise a run in a + debug session debugs the project, named by the session's job; without a job + key there is no lineage to resolve the project from, so it runs deployed. + """ + if _present(folder_key) or _present(folder_path): + return None + if not (UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job): + return None + job_key = UiPathConfig.job_key + return job_key if job_key and job_key.strip() else None + + +def _debug_spec( + name: str, + input: Dict[str, Any], + job_key: str, + decision_names: Optional[List[str]], +) -> RequestSpec: + # The service finds the project, and the folders the job ran in, from the + # debug job's lineage, and checks the rule name against it. No folder header + # is sent, and neither is a version or caller: an undeployed rule has no + # version, and a debug run is not an audited execution. + body: Dict[str, Any] = {"businessRuleName": name, "input": input} + if decision_names: + body["decisionNames"] = decision_names + return RequestSpec( + method="POST", + endpoint=_DEBUG_EVALUATE_ENDPOINT, + json=body, + headers={HEADER_JOB_KEY: job_key}, + ) + + def _wire_caller(caller: Optional[BusinessRuleCaller]) -> Dict[str, str]: given = caller or BusinessRuleCaller() values = { @@ -348,4 +417,6 @@ def _to_run_result(response: _WireResponse) -> BusinessRuleRunResult: top_level_error=response.error.code if response.error else None, business_rule_name=response.business_rule_name, version=response.version, + project_id=response.project_id, + file_name=response.file_name, ) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py index 44eeb468b..661e7b031 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/business_rules.py @@ -106,7 +106,10 @@ class BusinessRuleDecision(BaseModel): class BusinessRuleRunResult(BaseModel): - """The outcome of running a business rule against one input.""" + """The outcome of running a business rule against one input. + + The shape is the same for deployed and debug runs. + """ model_config = ConfigDict( validate_by_name=True, @@ -130,6 +133,14 @@ class BusinessRuleRunResult(BaseModel): version: Optional[str] = Field( default=None, description="The rule version that ran, when reported." ) + project_id: Optional[str] = Field( + default=None, + description="The Studio project the rule was read from, when it came from one.", + ) + file_name: Optional[str] = Field( + default=None, + description="The rule file read from the project, when it came from one.", + ) class _WireResult(BaseModel): @@ -146,5 +157,7 @@ class _WireResponse(BaseModel): business_rule_name: Optional[str] = Field(default=None, alias="businessRuleName") version: Optional[str] = None + project_id: Optional[str] = Field(default=None, alias="projectId") + file_name: Optional[str] = Field(default=None, alias="fileName") error: Optional[BusinessRuleError] = None result: Optional[_WireResult] = None diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index df91d1d1e..68c2c849f 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -20,6 +20,7 @@ ResourceOverwriteParser, _resource_overwrites, ) +from uipath.platform.common._config import UiPathConfig from uipath.platform.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT from uipath.platform.errors import EnrichedException @@ -48,6 +49,11 @@ def service( monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False) + monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False) + # Not a debug session unless a test says so, whatever uipath.json is nearby. + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: False) + ) return BusinessRulesService( config=config, execution_context=execution_context, @@ -60,6 +66,11 @@ def evaluate_url(base_url: str, org: str, tenant: str) -> str: return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/evaluate" +@pytest.fixture +def debug_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/businessrules_/v1/business-rules/debug/evaluate" + + def _response(result: dict[str, Any] | None = None, **extra: Any) -> dict[str, Any]: body: dict[str, Any] = {"meta": {"timestamp": "2026-09-28T00:00:00Z"}, **extra} body["result"] = {"decisions": []} if result is None else result @@ -268,6 +279,7 @@ def test_sends_single_input_and_maps_decisions( assert result.top_level_error is None assert result.business_rule_name == "Loan Pricing" assert result.version == "1.0.3" + assert result.project_id is None request = httpx_mock.get_request() assert request is not None @@ -855,3 +867,249 @@ def test_other_rules_are_not_overridden( request = httpx_mock.get_request() assert request is not None assert json.loads(request.content)["businessRuleName"] == "Risk Tier" + + +PROJECT_ID = "041e6279-8a51-4aec-b2fa-6786704eba19" + + +@pytest.fixture +def studio_debug(monkeypatch: pytest.MonkeyPatch) -> None: + """A Studio debug session: the project being debugged and its job.""" + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +@pytest.fixture +def rooted_to_debug_job(monkeypatch: pytest.MonkeyPatch) -> None: + """A deployed process running under a debug session (e.g. a solution debug).""" + monkeypatch.setattr( + type(UiPathConfig), "is_rooted_to_debug_job", property(lambda self: True) + ) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + + +class TestDebug: + def test_studio_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response( + url=debug_url, + method="POST", + json=_response( + _one_decision(x=1), projectId=PROJECT_ID, fileName="Business rule.dmn" + ), + ) + + result = service.run(RULE, {"a": 1}, decision_names=["RiskGrade"]) + + assert result.status == BusinessRuleStatus.SUCCESS + assert result.project_id == PROJECT_ID + assert result.file_name == "Business rule.dmn" + assert result.business_rule_name is None + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content) == { + "businessRuleName": RULE, + "decisionNames": ["RiskGrade"], + "input": {"a": 1}, + } + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_job_rooted_to_a_debug_session_runs_the_project_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + rooted_to_debug_job: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY + + def test_sends_no_folder_version_or_caller( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + folders_service: Mock, + debug_url: str, + studio_debug: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "release-key") + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run( + RULE, + {}, + version="1.0.0", + caller=BusinessRuleCaller(run_key="agent-run"), + ) + + folders_service.retrieve_folder_key.assert_not_called() + request = httpx_mock.get_request() + assert request is not None + # The service takes the job's folders from its lineage. + assert HEADER_FOLDER_KEY not in request.headers + assert "x-uipath-organizationunitid" not in request.headers + assert "x-uipath-folderpath" not in request.headers + body = json.loads(request.content) + assert "version" not in body + assert "caller" not in body + + @pytest.mark.parametrize( + "folder", [{"folder_key": FOLDER_KEY}, {"folder_path": "Finance"}] + ) + def test_a_named_folder_runs_the_deployed_rule( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + studio_debug: None, + folder: dict[str, str], + ) -> None: + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, **folder) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == FOLDER_KEY + assert "x-uipath-jobkey" not in request.headers + + def test_a_blank_folder_does_not_count_as_named( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}, folder_key=" ") + + assert httpx_mock.get_request() is not None + + def test_a_session_without_a_job_key_runs_deployed( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # Running a project locally: no job, so no lineage to read it from. + monkeypatch.setenv("UIPATH_PROJECT_ID", PROJECT_ID) + monkeypatch.setenv("UIPATH_JOB_KEY", " ") + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}, folder_key=FOLDER_KEY) + + assert httpx_mock.get_request() is not None + + def test_a_deployed_job_runs_deployed( + self, + httpx_mock: HTTPXMock, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: Mock, + evaluate_url: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_FOLDER_KEY", "env-folder-key") + service = BusinessRulesService(config, execution_context, folders_service) + httpx_mock.add_response(url=evaluate_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert request.headers[HEADER_FOLDER_KEY] == "env-folder-key" + assert "x-uipath-jobkey" not in request.headers + + def test_rejects_unsafe_rule_name( + self, httpx_mock: HTTPXMock, service: BusinessRulesService, studio_debug: None + ) -> None: + with pytest.raises(ValueError, match="name"): + service.run("a/b", {}) + + assert httpx_mock.get_requests() == [] + + def test_override_applies_to_debug_runs( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + rule_override: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + + service.run(RULE, {}) + + request = httpx_mock.get_request() + assert request is not None + assert json.loads(request.content)["businessRuleName"] == "Loan Pricing EU" + assert HEADER_FOLDER_KEY not in request.headers + + def test_retries_like_any_platform_call( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, status_code=503) + httpx_mock.add_response(url=debug_url, json=_response(_one_decision(x=1))) + + result = service.run(RULE, {}) + + assert result.status == BusinessRuleStatus.SUCCESS + assert len(httpx_mock.get_requests()) == 2 + + def test_explicit_trace_context_on_debug_run( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ambient_span: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response()) + explicit = TraceContext( + trace_id=EXPLICIT_TRACE_ID, parent_span_id=EXPLICIT_SPAN_ID + ) + + service.run(RULE, {}, trace_context=explicit) + + request = httpx_mock.get_request() + assert request is not None + assert ( + request.headers[TRACEPARENT] + == f"00-{EXPLICIT_TRACE_ID}-{EXPLICIT_SPAN_ID}-01" + ) + + async def test_run_async_debug( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + ) -> None: + httpx_mock.add_response(url=debug_url, json=_response(projectId=PROJECT_ID)) + + result = await service.run_async(RULE, {}) + + assert result.project_id == PROJECT_ID + request = httpx_mock.get_request() + assert request is not None + assert request.headers["x-uipath-jobkey"] == JOB_KEY From 010006d6386ee8ec756b4841604ef3bb99f28b79 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 14:24:55 +0530 Subject: [PATCH 6/9] chore(business-rules): bump uipath-platform to 0.2.36 #1912 now takes 0.2.35 (main released 0.2.34), so this PR moves to the next version. Co-Authored-By: Claude Opus 5.5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index be322ea1e..4699bfeeb 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index b02fd2c40..99070bbf4 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 72e4bda1b..fc6abcd7a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.35" +version = "0.2.36" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From a2f14f25f8882abeec574267a0c01a7186c9ae38 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 19:38:01 +0530 Subject: [PATCH 7/9] test(business-rules): a debug run opens no client span either Records spans with a real tracer provider and checks that a debug run, like a deployed one, leaves only the caller's span and names it in the trace header. Removing @traced also exposed run()'s real signature to mypy, which flagged a test passing **dict[str, str]; it now passes folder_key and folder_path explicitly. Co-Authored-By: Claude Opus 5.5 --- .../services/test_business_rules_service.py | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/tests/services/test_business_rules_service.py b/packages/uipath-platform/tests/services/test_business_rules_service.py index 35287de1a..7acbd33ae 100644 --- a/packages/uipath-platform/tests/services/test_business_rules_service.py +++ b/packages/uipath-platform/tests/services/test_business_rules_service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Iterator +from typing import Any, Iterator, Optional from unittest.mock import AsyncMock, Mock import pytest @@ -1108,7 +1108,9 @@ def test_sends_no_folder_version_or_caller( assert "caller" not in body @pytest.mark.parametrize( - "folder", [{"folder_key": FOLDER_KEY}, {"folder_path": "Finance"}] + ("folder_key", "folder_path"), + [(FOLDER_KEY, None), (None, "Finance")], + ids=["folder_key", "folder_path"], ) def test_a_named_folder_runs_the_deployed_rule( self, @@ -1116,11 +1118,12 @@ def test_a_named_folder_runs_the_deployed_rule( service: BusinessRulesService, evaluate_url: str, studio_debug: None, - folder: dict[str, str], + folder_key: Optional[str], + folder_path: Optional[str], ) -> None: httpx_mock.add_response(url=evaluate_url, json=_response()) - service.run(RULE, {}, **folder) + service.run(RULE, {}, folder_key=folder_key, folder_path=folder_path) request = httpx_mock.get_request() assert request is not None @@ -1217,6 +1220,29 @@ def test_retries_like_any_platform_call( assert result.status == BusinessRuleStatus.SUCCESS assert len(httpx_mock.get_requests()) == 2 + def test_debug_run_opens_no_span_of_its_own( + self, + httpx_mock: HTTPXMock, + service: BusinessRulesService, + debug_url: str, + studio_debug: None, + recorded_spans: InMemorySpanExporter, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_TRACE_ID", raising=False) + httpx_mock.add_response(url=debug_url, json=_response(_one_decision(x=1))) + + with trace.get_tracer("test").start_as_current_span("caller") as caller_span: + service.run(RULE, {"age": 14}) + caller_context = caller_span.get_span_context() + + assert [span.name for span in recorded_spans.get_finished_spans()] == ["caller"] + request = httpx_mock.get_request() + assert request is not None + assert request.headers[TRACEPARENT] == ( + f"00-{caller_context.trace_id:032x}-{caller_context.span_id:016x}-01" + ) + def test_explicit_trace_context_on_debug_run( self, httpx_mock: HTTPXMock, From 5e22ba7fe91ab5026aace3600da486114a390ca1 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 7 Oct 2026 20:22:04 +0530 Subject: [PATCH 8/9] refactor(business-rules): http_response -> response Co-Authored-By: Claude Opus 5.5 --- .../platform/business_rules/_business_rules_service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 089b1c595..bf457f7f8 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -156,10 +156,10 @@ def run( ) # Called here, not in a helper: BaseService names the user agent after # the method that calls request(), which must be the public run(). - http_response = self.request( + response = self.request( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) async def run_async( self, @@ -207,10 +207,10 @@ async def run_async( decision_names=decision_names, caller=caller, ) - http_response = await self.request_async( + response = await self.request_async( request_spec.method, **_request_options(request_spec, trace_context) ) - return _to_run_result(http_response.json()) + return _to_run_result(response.json()) def _prepare_run( self, From 2f2584696743a9a448b595bfd40ef13fc1e3bdd3 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Thu, 8 Oct 2026 11:31:07 +0530 Subject: [PATCH 9/9] refactor(business-rules): mark debug runs with an is_debug flag The run target now records whether a run is a debug run as a boolean, decided once from the folder arguments and the debug session, instead of carrying the job key. The debug request takes its job key header from header_job_key(), as the other services do. Co-Authored-By: Claude Opus 5.5 --- .../business_rules/_business_rules_service.py | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py index 5ea9df1e3..475297706 100644 --- a/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py +++ b/packages/uipath-platform/src/uipath/platform/business_rules/_business_rules_service.py @@ -14,8 +14,9 @@ from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext +from ..common._job_context import header_job_key from ..common._models import Endpoint, RequestSpec -from ..constants import HEADER_FOLDER_KEY, HEADER_JOB_KEY +from ..constants import HEADER_FOLDER_KEY from ..orchestrator._folder_service import FolderService from .business_rules import ( BusinessRuleCaller, @@ -41,7 +42,7 @@ class _RunTarget: """Where a run goes, settled before anything is sent. - A debug run carries ``debug_job_key`` and no folder. A deployed run carries + A debug run sets ``is_debug`` and carries no folder. A deployed run carries exactly one of ``folder_key`` and ``folder_path``: a key ready to send, or a path still to look up. """ @@ -49,7 +50,7 @@ class _RunTarget: business_rule_name: str folder_key: Optional[str] = None folder_path: Optional[str] = None - debug_job_key: Optional[str] = None + is_debug: bool = False class BusinessRulesService(FolderContext, BaseService): @@ -228,15 +229,13 @@ def _prepare_run( folder lookup and the request itself differ between the two. """ # Decided on the caller's own folder arguments, before a binding swaps them. - debug_job_key = _resolve_debug_job_key(folder_key, folder_path) + is_debug = _is_debug_run(folder_key, folder_path) business_rule_name, folder_key, folder_path = self._apply_binding( business_rule_name, folder_key, folder_path ) _validate_run_arguments(business_rule_name, input) - if debug_job_key: - return _RunTarget( - business_rule_name=business_rule_name, debug_job_key=debug_job_key - ) + if is_debug: + return _RunTarget(business_rule_name=business_rule_name, is_debug=True) selected_key, selected_path = self._select_folder(folder_key, folder_path) return _RunTarget( business_rule_name=business_rule_name, @@ -330,11 +329,10 @@ def _run_spec( caller: Optional[BusinessRuleCaller] = None, ) -> RequestSpec: """Build the request for the endpoint the run target names.""" - if run_target.debug_job_key: + if run_target.is_debug: return self._debug_evaluate_spec( run_target.business_rule_name, input, - job_key=run_target.debug_job_key, decision_names=decision_names, ) return self._evaluate_spec( @@ -381,7 +379,6 @@ def _debug_evaluate_spec( business_rule_name: str, input: Dict[str, Any], *, - job_key: str, decision_names: Optional[List[str]] = None, ) -> RequestSpec: # The service finds the project, and the folders the job ran in, from the @@ -398,25 +395,22 @@ def _debug_evaluate_spec( method="POST", endpoint=_DEBUG_EVALUATE_ENDPOINT, json=request_body, - headers={HEADER_JOB_KEY: job_key}, + headers=header_job_key(), ) -def _resolve_debug_job_key( - folder_key: Optional[str], folder_path: Optional[str] -) -> Optional[str]: - """Return the debug job's key when this run is a debug run, else None. +def _is_debug_run(folder_key: Optional[str], folder_path: Optional[str]) -> bool: + """Return whether this run debugs the project instead of a deployed rule. A folder the caller names always means a deployed rule. Otherwise a run in a debug session debugs the project, named by the session's job; without a job key there is no lineage to resolve the project from, so it runs deployed. """ if _has_value(folder_key) or _has_value(folder_path): - return None + return False if not (UiPathConfig.is_studio_project or UiPathConfig.is_rooted_to_debug_job): - return None - debug_job_key = UiPathConfig.job_key - return debug_job_key if debug_job_key and debug_job_key.strip() else None + return False + return _has_value(UiPathConfig.job_key) def _request_options(