From 008c5099416c956f797a6c4ffd43378a665d5328 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Mon, 1 Dec 2025 19:14:20 +0200 Subject: [PATCH 1/5] fix: use bindings overwrites on resume trigger data --- src/uipath/_utils/_bindings.py | 44 +++++ .../platform/resume_triggers/_protocol.py | 44 +++-- tests/sdk/test_bindings.py | 152 ++++++++++++++++++ 3 files changed, 228 insertions(+), 12 deletions(-) diff --git a/src/uipath/_utils/_bindings.py b/src/uipath/_utils/_bindings.py index b92a48c9b..bc486a57d 100644 --- a/src/uipath/_utils/_bindings.py +++ b/src/uipath/_utils/_bindings.py @@ -197,3 +197,47 @@ def get_inferred_bindings_names(cls: T): inferred_bindings[name] = method._infer_bindings_mappings # type: ignore # probably a better way to do this return inferred_bindings + + +def resolve_folder_from_bindings( + resource_type: str, + resource_name: Optional[str], + folder_path: Optional[str] = None, +) -> tuple[Optional[str], Optional[str]]: + """Resolve folder path and key from bindings context. + + This function looks up the bindings context variable to find the folder + information for a given resource. It's used to ensure that resume triggers + are created with the correct folder information from bindings. + + Args: + resource_type: The type of resource (e.g., "app", "process", "index") + resource_name: The name/identifier of the resource + folder_path: Optional current folder path for more specific matching + + Returns: + Tuple of (folder_path, folder_key) from bindings. Returns (None, None) + if no bindings context is available or no matching resource is found. + """ + if not resource_name: + return (None, None) + + context_overwrites = _resource_overwrites.get() + if context_overwrites is None: + return (None, None) + + key = f"{resource_type}.{resource_name}" + + if folder_path: + specific_key = f"{key}.{folder_path}" + if specific_key in context_overwrites: + key = specific_key + + matched = context_overwrites.get(key) + if matched is None: + return (None, None) + + if isinstance(matched, ConnectionResourceOverwrite): + return (None, matched.folder_identifier) + + return (matched.folder_identifier, None) diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py index 303c16a96..34c2bb6ed 100644 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ b/src/uipath/platform/resume_triggers/_protocol.py @@ -4,6 +4,18 @@ import uuid from typing import Any +from uipath._cli._utils._common import serialize_object +from uipath._utils._bindings import resolve_folder_from_bindings +from uipath.platform import UiPath +from uipath.platform.action_center import Task +from uipath.platform.common import ( + CreateEscalation, + CreateTask, + InvokeProcess, + WaitEscalation, + WaitJob, + WaitTask, +) from uipath.runtime import ( UiPathApiTrigger, UiPathResumeTrigger, @@ -17,18 +29,6 @@ UiPathRuntimeError, ) -from uipath._cli._utils._common import serialize_object -from uipath.platform import UiPath -from uipath.platform.action_center import Task -from uipath.platform.common import ( - CreateEscalation, - CreateTask, - InvokeProcess, - WaitEscalation, - WaitJob, - WaitTask, -) - def _try_convert_to_json_format(value: str | None) -> str | None: """Attempts to parse a string as JSON and returns the parsed object or original string. @@ -266,6 +266,16 @@ async def _handle_task_trigger( if isinstance(value, (WaitTask, WaitEscalation)): resume_trigger.item_key = value.action.key elif isinstance(value, (CreateTask, CreateEscalation)): + resolved_path, resolved_key = resolve_folder_from_bindings( + resource_type="app", + resource_name=value.app_name, + folder_path=value.app_folder_path, + ) + if resolved_path: + resume_trigger.folder_path = resolved_path + if resolved_key: + resume_trigger.folder_key = resolved_key + action = await uipath.tasks.create_async( title=value.title, app_name=value.app_name if value.app_name else "", @@ -295,6 +305,16 @@ async def _handle_job_trigger( if isinstance(value, WaitJob): resume_trigger.item_key = value.job.key elif isinstance(value, InvokeProcess): + resolved_path, resolved_key = resolve_folder_from_bindings( + resource_type="process", + resource_name=value.name, + folder_path=value.process_folder_path, + ) + if resolved_path: + resume_trigger.folder_path = resolved_path + if resolved_key: + resume_trigger.folder_key = resolved_key + job = await uipath.processes.invoke_async( name=value.name, input_arguments=value.input_arguments, diff --git a/tests/sdk/test_bindings.py b/tests/sdk/test_bindings.py index 973dedf6c..29503097d 100644 --- a/tests/sdk/test_bindings.py +++ b/tests/sdk/test_bindings.py @@ -4,9 +4,11 @@ from uipath._utils import resource_override from uipath._utils._bindings import ( + ConnectionResourceOverwrite, GenericResourceOverwrite, ResourceOverwritesContext, _resource_overwrites, + resolve_folder_from_bindings, ) @@ -335,3 +337,153 @@ def get_asset(name, folder_path): # Verify context was cleaned up despite the exception result_after = get_asset("test", "original") assert result_after == ("test", "original") + + +class TestResolveFolderFromBindings: + """Tests for the resolve_folder_from_bindings utility function.""" + + def test_returns_none_when_no_context(self): + """Test that resolve_folder_from_bindings returns (None, None) when context is not set.""" + result = resolve_folder_from_bindings("app", "my_app") + assert result == (None, None) + + def test_returns_none_when_resource_name_is_none(self): + """Test that resolve_folder_from_bindings returns (None, None) when resource_name is None.""" + overwrites = { + "app.my_app": GenericResourceOverwrite( + resource_type="app", name="new_app", folder_path="new_folder" + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", None) + assert result == (None, None) + finally: + _resource_overwrites.reset(token) + + def test_returns_none_when_resource_name_is_empty(self): + """Test that resolve_folder_from_bindings returns (None, None) when resource_name is empty.""" + overwrites = { + "app.my_app": GenericResourceOverwrite( + resource_type="app", name="new_app", folder_path="new_folder" + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", "") + assert result == (None, None) + finally: + _resource_overwrites.reset(token) + + def test_returns_folder_path_for_generic_overwrite(self): + """Test that resolve_folder_from_bindings returns folder_path for GenericResourceOverwrite.""" + overwrites = { + "app.my_app": GenericResourceOverwrite( + resource_type="app", name="new_app", folder_path="new_folder" + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", "my_app") + assert result == ("new_folder", None) + finally: + _resource_overwrites.reset(token) + + def test_returns_folder_key_for_connection_overwrite(self): + """Test that resolve_folder_from_bindings returns folder_key for ConnectionResourceOverwrite.""" + overwrites = { + "connection.my_connection": ConnectionResourceOverwrite( + resource_type="connection", + connection_id="conn_123", + element_instance_id="elem_456", + folder_key="folder_key_789", + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("connection", "my_connection") + assert result == (None, "folder_key_789") + finally: + _resource_overwrites.reset(token) + + def test_returns_none_when_no_matching_key(self): + """Test that resolve_folder_from_bindings returns (None, None) when no matching key exists.""" + overwrites = { + "app.other_app": GenericResourceOverwrite( + resource_type="app", name="new_app", folder_path="new_folder" + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", "my_app") + assert result == (None, None) + finally: + _resource_overwrites.reset(token) + + def test_prefers_specific_key_with_folder_path(self): + """Test that resolve_folder_from_bindings prefers specific key with folder_path.""" + overwrites = { + "app.my_app": GenericResourceOverwrite( + resource_type="app", name="generic_app", folder_path="generic_folder" + ), + "app.my_app.specific_folder": GenericResourceOverwrite( + resource_type="app", name="specific_app", folder_path="specific_folder" + ), + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", "my_app", "specific_folder") + assert result == ("specific_folder", None) + finally: + _resource_overwrites.reset(token) + + def test_falls_back_to_generic_key_when_specific_not_found(self): + """Test that resolve_folder_from_bindings falls back to generic key.""" + overwrites = { + "app.my_app": GenericResourceOverwrite( + resource_type="app", name="generic_app", folder_path="generic_folder" + ), + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("app", "my_app", "unknown_folder") + assert result == ("generic_folder", None) + finally: + _resource_overwrites.reset(token) + + def test_works_with_process_resource_type(self): + """Test that resolve_folder_from_bindings works with process resource type.""" + overwrites = { + "process.my_process": GenericResourceOverwrite( + resource_type="process", + name="new_process", + folder_path="process_folder", + ) + } + token = _resource_overwrites.set(overwrites) + try: + result = resolve_folder_from_bindings("process", "my_process") + assert result == ("process_folder", None) + finally: + _resource_overwrites.reset(token) + + @pytest.mark.anyio + async def test_works_within_resource_overwrites_context(self): + """Test that resolve_folder_from_bindings works within ResourceOverwritesContext.""" + + async def get_overwrites(): + return { + "app.my_app": GenericResourceOverwrite( + resource_type="app", + name="resolved_app", + folder_path="resolved_folder", + ) + } + + async with ResourceOverwritesContext(get_overwrites): + result = resolve_folder_from_bindings("app", "my_app") + assert result == ("resolved_folder", None) + + # Context should be cleaned up + result_after = resolve_folder_from_bindings("app", "my_app") + assert result_after == (None, None) From f6c75ed95fa86c839230f92cef4eae7120b2fea2 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Mon, 1 Dec 2025 22:08:57 +0200 Subject: [PATCH 2/5] chore: pkg version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index faa1389c6..f852d47fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.2.8" +version = "2.2.9" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index d1ff7d291..0617906ff 100644 --- a/uv.lock +++ b/uv.lock @@ -2401,7 +2401,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.8" +version = "2.2.9" source = { editable = "." } dependencies = [ { name = "click" }, From 616ae58b4b5afec1a4100910d865bffd1c59762a Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Sat, 29 Nov 2025 22:31:21 +0200 Subject: [PATCH 3/5] feat: change apps resolution endpoint to filter by name and only deployed apps --- src/uipath/_services/tasks_service.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/uipath/_services/tasks_service.py b/src/uipath/_services/tasks_service.py index 15bd4da91..c2bfd5a98 100644 --- a/src/uipath/_services/tasks_service.py +++ b/src/uipath/_services/tasks_service.py @@ -132,7 +132,11 @@ def _retrieve_app_key_spec(app_name: str) -> RequestSpec: return RequestSpec( method="GET", endpoint=Endpoint("/apps_/default/api/v1/default/deployed-action-apps-schemas"), - params={"search": app_name}, + params={ + "search": app_name, + "filterByDeploymentTitle": "true", + "state": "deployed", + }, headers={HEADER_TENANT_ID: tenant_id}, ) From fc0091a96a45ceaacbf10fbcc3a76899874ff3ff Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Mon, 1 Dec 2025 19:14:38 +0200 Subject: [PATCH 4/5] feat: extend escalation model for other behaviour --- src/uipath/_services/tasks_service.py | 171 +++++++++++++++--- .../platform/common/interrupt_models.py | 15 +- 2 files changed, 162 insertions(+), 24 deletions(-) diff --git a/src/uipath/_services/tasks_service.py b/src/uipath/_services/tasks_service.py index c2bfd5a98..2675ec35b 100644 --- a/src/uipath/_services/tasks_service.py +++ b/src/uipath/_services/tasks_service.py @@ -24,6 +24,16 @@ def _create_spec( app_key: Optional[str] = None, app_folder_key: Optional[str] = None, app_folder_path: Optional[str] = None, + app_version: Optional[int] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + agent_id: Optional[str] = None, + instance_id: Optional[str] = None, + job_key: Optional[str] = None, + process_key: Optional[str] = None, + resource_key: Optional[str] = None, ) -> RequestSpec: field_list = [] outcome_list = [] @@ -76,30 +86,89 @@ def _create_spec( } ) - return RequestSpec( - method="POST", - endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), - json={ - "appId": app_key, - "title": title, - "data": data if data is not None else {}, - "actionableMessageMetaData": { - "fieldSet": { - "id": str(uuid.uuid4()), - "fields": field_list, - } - if len(field_list) != 0 - else {}, - "actionSet": { - "id": str(uuid.uuid4()), - "actions": outcome_list, + # Build tags array from labels + tags = [] + if labels: + for label in labels: + tags.append( + { + "Name": label, + "DisplayName": label, + "Value": label, + "DisplayValue": label, } - if len(outcome_list) != 0 - else {}, + ) + + # Build additional properties + additional_properties: Dict[str, Any] = {} + if resource_key: + additional_properties["AppType"] = "Dynamic" + additional_properties["FolderKey"] = app_folder_key or "" + + # Build task source metadata + task_source_metadata: Dict[str, Any] = {} + if instance_id: + task_source_metadata["InstanceId"] = instance_id + if app_folder_key: + task_source_metadata["FolderKey"] = app_folder_key + if job_key: + task_source_metadata["JobKey"] = job_key + if process_key: + task_source_metadata["ProcessKey"] = process_key + + # Build task source + task_source: Optional[Dict[str, Any]] = None + if agent_id: + task_source = { + "SourceName": "Agent", + "SourceId": agent_id, + "TaskSourceMetadata": task_source_metadata, + } + + # Build base payload matching .NET structure + payload: Dict[str, Any] = { + "appId": app_key, + "title": title, + "data": data if data is not None else {}, + } + + # Add optional fields + if app_version is not None: + payload["appVersion"] = app_version + if priority is not None: + payload["priority"] = priority + if tags: + payload["tags"] = tags + if is_actionable_message_enabled is not None: + payload["isActionableMessageEnabled"] = is_actionable_message_enabled + if additional_properties: + payload["additionalProperties"] = additional_properties + if task_source: + payload["taskSource"] = task_source + + # Add actionable message metadata (legacy format for backward compatibility) + if actionable_message_metadata: + payload["actionableMessageMetaData"] = actionable_message_metadata + elif action_schema is not None: + payload["actionableMessageMetaData"] = { + "fieldSet": { + "id": str(uuid.uuid4()), + "fields": field_list, } - if action_schema is not None + if len(field_list) != 0 else {}, - }, + "actionSet": { + "id": str(uuid.uuid4()), + "actions": outcome_list, + } + if len(outcome_list) != 0 + else {}, + } + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), + json=payload, headers=folder_headers(app_folder_key, app_folder_path), ) @@ -185,6 +254,16 @@ async def create_async( app_folder_path: Optional[str] = None, app_folder_key: Optional[str] = None, assignee: Optional[str] = None, + app_version: Optional[int] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + agent_id: Optional[str] = None, + instance_id: Optional[str] = None, + job_key: Optional[str] = None, + process_key: Optional[str] = None, + resource_key: Optional[str] = None, ) -> Task: """Creates a new action asynchronously. @@ -199,6 +278,16 @@ async def create_async( app_folder_path: Optional folder path for the action app_folder_key: Optional folder key for the action assignee: Optional username or email to assign the task to + app_version: Optional application version + priority: Optional task priority (Low, Medium, High, Critical) + labels: Optional list of tag labels for the task + is_actionable_message_enabled: Optional flag for actionable message feature + actionable_message_metadata: Optional metadata for actionable messages + agent_id: Optional agent identifier for task source + instance_id: Optional instance/trace identifier + job_key: Optional job key for task source metadata + process_key: Optional process key for task source metadata + resource_key: Optional resource key for additional properties Returns: Action: The created action object @@ -220,6 +309,16 @@ async def create_async( action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, + app_version=app_version, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + agent_id=agent_id, + instance_id=instance_id, + job_key=job_key, + process_key=process_key, + resource_key=resource_key, ) response = await self.request_async( @@ -253,6 +352,16 @@ def create( app_folder_path: Optional[str] = None, app_folder_key: Optional[str] = None, assignee: Optional[str] = None, + app_version: Optional[int] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + agent_id: Optional[str] = None, + instance_id: Optional[str] = None, + job_key: Optional[str] = None, + process_key: Optional[str] = None, + resource_key: Optional[str] = None, ) -> Task: """Creates a new task synchronously. @@ -267,6 +376,16 @@ def create( app_folder_path: Optional folder path for the action app_folder_key: Optional folder key for the action assignee: Optional username or email to assign the task to + app_version: Optional application version + priority: Optional task priority (Low, Medium, High, Critical) + labels: Optional list of tag labels for the task + is_actionable_message_enabled: Optional flag for actionable message feature + actionable_message_metadata: Optional metadata for actionable messages + agent_id: Optional agent identifier for task source + instance_id: Optional instance/trace identifier + job_key: Optional job key for task source metadata + process_key: Optional process key for task source metadata + resource_key: Optional resource key for additional properties Returns: Action: The created action object @@ -288,6 +407,16 @@ def create( action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, + app_version=app_version, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + agent_id=agent_id, + instance_id=instance_id, + job_key=job_key, + process_key=process_key, + resource_key=resource_key, ) response = self.request( diff --git a/src/uipath/platform/common/interrupt_models.py b/src/uipath/platform/common/interrupt_models.py index 22cb1f4c7..b3f140077 100644 --- a/src/uipath/platform/common/interrupt_models.py +++ b/src/uipath/platform/common/interrupt_models.py @@ -38,9 +38,18 @@ class CreateTask(BaseModel): class CreateEscalation(CreateTask): - """Model representing an escalation creation.""" - - pass + """Model representing an escalation creation with additional metadata.""" + + app_version: Optional[int] = None + priority: Optional[str] = None + labels: Optional[list[str]] = None + is_actionable_message_enabled: Optional[bool] = None + actionable_message_metadata: Optional[Dict[str, Any]] = None + agent_id: Optional[str] = None + instance_id: Optional[str] = None + job_key: Optional[str] = None + process_key: Optional[str] = None + resource_key: Optional[str] = None class WaitTask(BaseModel): From 82ceecf967a41811eec6e0e6f3555adcbe1a14c7 Mon Sep 17 00:00:00 2001 From: cotovanu-cristian Date: Mon, 1 Dec 2025 22:20:55 +0200 Subject: [PATCH 5/5] add back params --- .../platform/resume_triggers/_protocol.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py index 34c2bb6ed..45cb98ef8 100644 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ b/src/uipath/platform/resume_triggers/_protocol.py @@ -2,7 +2,7 @@ import json import uuid -from typing import Any +from typing import Any, Dict from uipath._cli._utils._common import serialize_object from uipath._utils._bindings import resolve_folder_from_bindings @@ -276,6 +276,34 @@ async def _handle_task_trigger( if resolved_key: resume_trigger.folder_key = resolved_key + # Extract additional escalation metadata if present + additional_params: Dict[str, Any] = {} + if isinstance(value, CreateEscalation): + if value.app_version is not None: + additional_params["app_version"] = value.app_version + if value.priority is not None: + additional_params["priority"] = value.priority + if value.labels is not None: + additional_params["labels"] = value.labels + if value.is_actionable_message_enabled is not None: + additional_params["is_actionable_message_enabled"] = ( + value.is_actionable_message_enabled + ) + if value.actionable_message_metadata is not None: + additional_params["actionable_message_metadata"] = ( + value.actionable_message_metadata + ) + if value.agent_id is not None: + additional_params["agent_id"] = value.agent_id + if value.instance_id is not None: + additional_params["instance_id"] = value.instance_id + if value.job_key is not None: + additional_params["job_key"] = value.job_key + if value.process_key is not None: + additional_params["process_key"] = value.process_key + if value.resource_key is not None: + additional_params["resource_key"] = value.resource_key + action = await uipath.tasks.create_async( title=value.title, app_name=value.app_name if value.app_name else "", @@ -284,6 +312,7 @@ async def _handle_task_trigger( app_key=value.app_key if value.app_key else "", assignee=value.assignee if value.assignee else "", data=value.data, + **additional_params, ) if not action: raise Exception("Failed to create action")