From baa54e0293e7b8a3587c8f1bbdf0c7f62bbf7992 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Tue, 29 Sep 2026 07:57:03 +0530 Subject: [PATCH 1/4] 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/4] 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 be74017374540dfd0494c638a87e9f87e19da4c4 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 08:13:08 +0530 Subject: [PATCH 3/4] 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 9e6c421ee436e58913ab9a6e8620d255310d0d81 Mon Sep 17 00:00:00 2001 From: Ashish Upadhyay Date: Wed, 30 Sep 2026 10:25:04 +0530 Subject: [PATCH 4/4] 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" },