From c6d906260991ff50917d5f2ba51dc4499009dc37 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Wed, 3 Dec 2025 14:49:08 +0200 Subject: [PATCH 1/2] fix: use overrides on trigger retrieve --- .../platform/action_center/_tasks_service.py | 26 ++++++++++++++++--- src/uipath/platform/action_center/tasks.py | 10 +++++++ .../platform/orchestrator/_jobs_service.py | 18 +++++++++---- src/uipath/platform/orchestrator/job.py | 12 +++++++++ tests/cli/test_hitl.py | 16 +++++++++--- 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/uipath/platform/action_center/_tasks_service.py b/src/uipath/platform/action_center/_tasks_service.py index 557e08dbc..a35b9a727 100644 --- a/src/uipath/platform/action_center/_tasks_service.py +++ b/src/uipath/platform/action_center/_tasks_service.py @@ -303,8 +303,17 @@ def create( return Task.model_validate(json_response) @traced(name="tasks_retrieve", run_type="uipath") + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) def retrieve( - self, action_key: str, app_folder_path: str = "", app_folder_key: str = "" + self, + action_key: str, + app_folder_path: str = "", + app_folder_key: str = "", + app_name: str | None = None, ) -> Task: """Retrieves a task by its key synchronously. @@ -312,7 +321,7 @@ def retrieve( action_key: The unique identifier of the task to retrieve app_folder_path: Optional folder path for the task app_folder_key: Optional folder key for the task - + app_name: app name hint for resource override Returns: Task: The retrieved task object """ @@ -328,8 +337,17 @@ def retrieve( return Task.model_validate(response.json()) @traced(name="tasks_retrieve", run_type="uipath") + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) async def retrieve_async( - self, action_key: str, app_folder_path: str = "", app_folder_key: str = "" + self, + action_key: str, + app_folder_path: str = "", + app_folder_key: str = "", + app_name: str | None = None, ) -> Task: """Retrieves a task by its key asynchronously. @@ -337,7 +355,7 @@ async def retrieve_async( action_key: The unique identifier of the task to retrieve app_folder_path: Optional folder path for the task app_folder_key: Optional folder key for the task - + app_name: app name hint for resource override Returns: Task: The retrieved task object """ diff --git a/src/uipath/platform/action_center/tasks.py b/src/uipath/platform/action_center/tasks.py index 6d10f0340..3dcb45784 100644 --- a/src/uipath/platform/action_center/tasks.py +++ b/src/uipath/platform/action_center/tasks.py @@ -1,11 +1,20 @@ """Data model for an Action in the UiPath Platform.""" +import enum from datetime import datetime from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field, field_serializer +class TaskStatus(enum.IntEnum): + """Enum representing possible Task status.""" + + UNASSIGNED = 0 + PENDING = 1 + COMPLETED = 2 + + class Task(BaseModel): """Model representing a Task in the UiPath Platform.""" @@ -29,6 +38,7 @@ def serialize_datetime(self, value): ) app_tasks_metadata: Optional[Any] = Field(default=None, alias="appTasksMetadata") action_label: Optional[str] = Field(default=None, alias="actionLabel") + # 2.3.0 change to TaskStatus enum status: Optional[Union[str, int]] = None data: Optional[Dict[str, Any]] = None action: Optional[str] = None diff --git a/src/uipath/platform/orchestrator/_jobs_service.py b/src/uipath/platform/orchestrator/_jobs_service.py index c89dca508..1cb1c4f37 100644 --- a/src/uipath/platform/orchestrator/_jobs_service.py +++ b/src/uipath/platform/orchestrator/_jobs_service.py @@ -8,7 +8,7 @@ from ..._config import Config from ..._execution_context import ExecutionContext from ..._folder_context import FolderContext -from ..._utils import Endpoint, RequestSpec, header_folder +from ..._utils import Endpoint, RequestSpec, header_folder, resource_override from ..._utils.constants import TEMP_ATTACHMENTS_FOLDER from ...tracing import traced from ..common._base_service import BaseService @@ -148,12 +148,15 @@ async def main(): # noqa: D103 def custom_headers(self) -> Dict[str, str]: return self.folder_headers + @traced(name="jobs_retrieve", run_type="uipath") + @resource_override(resource_type="process", resource_identifier="process_name") def retrieve( self, job_key: str, *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, + folder_key: str | None = None, + folder_path: str | None = None, + process_name: str | None = None, ) -> Job: """Retrieve a job identified by its key. @@ -161,6 +164,7 @@ def retrieve( job_key (str): The job unique identifier. folder_key (Optional[str]): The key of the folder in which the job was executed. folder_path (Optional[str]): The path of the folder in which the job was executed. + process_name: process name hint for resource override Returns: Job: The retrieved job. @@ -184,12 +188,15 @@ def retrieve( return Job.model_validate(response.json()) + @traced(name="jobs_retrieve_async", run_type="uipath") + @resource_override(resource_type="process", resource_identifier="process_name") async def retrieve_async( self, job_key: str, *, - folder_key: Optional[str] = None, - folder_path: Optional[str] = None, + folder_key: str | None = None, + folder_path: str | None = None, + process_name: str | None = None, ) -> Job: """Asynchronously retrieve a job identified by its key. @@ -197,6 +204,7 @@ async def retrieve_async( job_key (str): The job unique identifier. folder_key (Optional[str]): The key of the folder in which the job was executed. folder_path (Optional[str]): The path of the folder in which the job was executed. + process_name: process name hint for resource override Returns: Job: The retrieved job. diff --git a/src/uipath/platform/orchestrator/job.py b/src/uipath/platform/orchestrator/job.py index 17c5e7f4f..05a175a81 100644 --- a/src/uipath/platform/orchestrator/job.py +++ b/src/uipath/platform/orchestrator/job.py @@ -1,10 +1,21 @@ """Models for Orchestrator Jobs.""" +from enum import Enum from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict, Field +class JobState(str, Enum): + """Job state enum.""" + + SUCCESSFUL = "successful" + FAULTED = "faulted" + SUSPENDED = "suspended" + RUNNING = "running" + PENDING = "pending" + + class JobErrorInfo(BaseModel): """Model representing job error information.""" @@ -35,6 +46,7 @@ class Job(BaseModel): key: Optional[str] = Field(default=None, alias="Key") start_time: Optional[str] = Field(default=None, alias="StartTime") end_time: Optional[str] = Field(default=None, alias="EndTime") + # 2.3.0 change to JobState enum state: Optional[str] = Field(default=None, alias="State") job_priority: Optional[str] = Field(default=None, alias="JobPriority") specific_priority_value: Optional[int] = Field( diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index 74474efef..f73c09d78 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -66,7 +66,10 @@ async def test_read_action_trigger( result = await reader.read_trigger(resume_trigger) assert result == action_data mock_retrieve_async.assert_called_once_with( - action_key, app_folder_key="test-folder", app_folder_path="test-path" + action_key, + app_folder_key="test-folder", + app_folder_path="test-path", + app_name=None, ) @pytest.mark.anyio @@ -101,7 +104,10 @@ async def test_read_job_trigger_successful( result = await reader.read_trigger(resume_trigger) assert result == output_args mock_retrieve_async.assert_called_once_with( - job_key, folder_key="test-folder", folder_path="test-path" + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name=None, ) @pytest.mark.anyio @@ -128,6 +134,7 @@ async def test_read_job_trigger_failed( item_key=job_key, folder_key="test-folder", folder_path="test-path", + payload={"name": "process_name"}, ) with pytest.raises(UiPathRuntimeError) as exc_info: @@ -138,7 +145,10 @@ async def test_read_job_trigger_failed( assert error_dict["title"] == "Invoked process did not finish successfully." assert job_error_info.code in error_dict["detail"] mock_retrieve_async.assert_called_once_with( - job_key, folder_key="test-folder", folder_path="test-path" + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name="process_name", ) @pytest.mark.anyio From fa77086bfc2dcde7a4fd0fa20d9c9c2509ad6b48 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Wed, 3 Dec 2025 14:49:43 +0200 Subject: [PATCH 2/2] fix: decouple platform from runtime (remove UipathRuntimeError) --- pyproject.toml | 4 +- .../platform/resume_triggers/_protocol.py | 125 +++++++++++------- tests/cli/test_hitl.py | 16 +-- uv.lock | 10 +- 4 files changed, 91 insertions(+), 64 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27348d4f4..e5edf8e4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath" -version = "2.2.12" +version = "2.2.13" 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" dependencies = [ - "uipath-core>=0.0.6, <0.1.0", + "uipath-core>=0.0.9, <0.1.0", "uipath-runtime>=0.1.1, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py index 303c16a96..edc26d7dd 100644 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ b/src/uipath/platform/resume_triggers/_protocol.py @@ -4,22 +4,22 @@ import uuid from typing import Any +from uipath.core.errors import ( + ErrorCategory, + UiPathFaultedTriggerError, + UiPathPendingTriggerError, +) from uipath.runtime import ( UiPathApiTrigger, UiPathResumeTrigger, UiPathResumeTriggerName, UiPathResumeTriggerType, - UiPathRuntimeStatus, -) -from uipath.runtime.errors import ( - UiPathErrorCategory, - UiPathErrorCode, - UiPathRuntimeError, ) from uipath._cli._utils._common import serialize_object from uipath.platform import UiPath from uipath.platform.action_center import Task +from uipath.platform.action_center.tasks import TaskStatus from uipath.platform.common import ( CreateEscalation, CreateTask, @@ -28,6 +28,7 @@ WaitJob, WaitTask, ) +from uipath.platform.orchestrator.job import JobState def _try_convert_to_json_format(value: str | None) -> str | None: @@ -53,6 +54,20 @@ class UiPathResumeTriggerReader: Implements UiPathResumeTriggerReaderProtocol. """ + def _extract_name_hint(self, field_name: str, payload: Any) -> str | None: + if not payload: + return payload + + if isinstance(payload, dict): + return payload.get(field_name) + + # 2.3.0 remove + try: + payload_dict = json.loads(payload) + return payload_dict.get(field_name) + except json.decoder.JSONDecodeError: + return None + async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: """Read a resume trigger and convert it to runtime-compatible input. @@ -80,15 +95,31 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: match trigger.trigger_type: case UiPathResumeTriggerType.TASK: if trigger.item_key: - action: Task = await uipath.tasks.retrieve_async( + task: Task = await uipath.tasks.retrieve_async( trigger.item_key, app_folder_key=trigger.folder_key, app_folder_path=trigger.folder_path, + app_name=self._extract_name_hint("app_name", trigger.payload), ) + pending_status = TaskStatus.PENDING.value + unassigned_status = TaskStatus.UNASSIGNED.value + + if task.status in (pending_status, unassigned_status): + # 2.3.0 remove (task.status will already be the enum) + current_status = ( + TaskStatus(task.status).name + if isinstance(task.status, int) + else "Unknown" + ) + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Task is not completed yet. Current status: {current_status}", + ) + if trigger.trigger_name == UiPathResumeTriggerName.ESCALATION: - return action + return task - return action.data + return task.data case UiPathResumeTriggerType.JOB: if trigger.item_key: @@ -96,23 +127,34 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: trigger.item_key, folder_key=trigger.folder_key, folder_path=trigger.folder_path, + process_name=self._extract_name_hint("name", trigger.payload), ) job_state = (job.state or "").lower() - successful_state = UiPathRuntimeStatus.SUCCESSFUL.value.lower() - faulted_state = UiPathRuntimeStatus.FAULTED.value.lower() - - if job_state == successful_state: - output_data = await uipath.jobs.extract_output_async(job) - return _try_convert_to_json_format(output_data) - - raise UiPathRuntimeError( - UiPathErrorCode.INVOKED_PROCESS_FAILURE, - "Invoked process did not finish successfully.", - _try_convert_to_json_format(str(job.job_error or job.info)) - or "Job error unavailable." - if job_state == faulted_state - else f"Job {job.key} is {job_state}.", - ) + successful_state = JobState.SUCCESSFUL.value + faulted_state = JobState.FAULTED.value + running_state = JobState.RUNNING.value + pending_state = JobState.PENDING.value + + if job_state in (pending_state, running_state): + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Job is not finished yet. Current state: {job_state}", + ) + + if job_state != successful_state: + job_error = ( + _try_convert_to_json_format(str(job.job_error or job.info)) + or "Job error unavailable." + if job_state == faulted_state + else f"Job {job.key} is {job_state}." + ) + raise UiPathFaultedTriggerError( + ErrorCategory.USER, + f"Process did not finish successfully. Error: {job_error}", + ) + + output_data = await uipath.jobs.extract_output_async(job) + return _try_convert_to_json_format(output_data) case UiPathResumeTriggerType.API: if trigger.api_resume and trigger.api_resume.inbox_id: @@ -121,25 +163,20 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: trigger.api_resume.inbox_id ) except Exception as e: - raise UiPathRuntimeError( - UiPathErrorCode.RETRIEVE_RESUME_TRIGGER_ERROR, - "Failed to get trigger payload", + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Failed to get trigger payload" f"Error fetching API trigger payload for inbox {trigger.api_resume.inbox_id}: {str(e)}", - UiPathErrorCategory.SYSTEM, ) from e case _: - raise UiPathRuntimeError( - UiPathErrorCode.UNKNOWN_TRIGGER_TYPE, - "Unexpected trigger type received", + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Unexpected trigger type received" f"Trigger type :{type(trigger.trigger_type)} is invalid", - UiPathErrorCategory.USER, ) - raise UiPathRuntimeError( - UiPathErrorCode.RETRIEVE_RESUME_TRIGGER_ERROR, - "Failed to receive payload from HITL action", - detail="Failed to receive payload from HITL action", - category=UiPathErrorCategory.SYSTEM, + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, "Failed to receive payload from HITL action" ) @@ -198,20 +235,16 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: self._handle_api_trigger(suspend_value, resume_trigger) case _: - raise UiPathRuntimeError( - UiPathErrorCode.UNKNOWN_HITL_MODEL, - "Unexpected model received", + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Unexpected model received" f"{type(suspend_value)} is not a valid Human-In-The-Loop model", - UiPathErrorCategory.USER, ) - except UiPathRuntimeError: - raise except Exception as e: - raise UiPathRuntimeError( - UiPathErrorCode.CREATE_RESUME_TRIGGER_ERROR, + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, "Failed to create HITL action", f"{str(e)}", - UiPathErrorCategory.SYSTEM, ) from e return resume_trigger diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index f73c09d78..f5b1579d7 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -3,13 +3,13 @@ import pytest from pytest_httpx import HTTPXMock +from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError from uipath.runtime import ( UiPathApiTrigger, UiPathResumeTrigger, UiPathResumeTriggerType, UiPathRuntimeStatus, ) -from uipath.runtime.errors import UiPathRuntimeError from uipath.platform.action_center import Task from uipath.platform.common import CreateTask, InvokeProcess, WaitJob, WaitTask @@ -137,13 +137,10 @@ async def test_read_job_trigger_failed( payload={"name": "process_name"}, ) - with pytest.raises(UiPathRuntimeError) as exc_info: + with pytest.raises(UiPathFaultedTriggerError) as exc_info: reader = UiPathResumeTriggerReader() await reader.read_trigger(resume_trigger) - error_dict = exc_info.value.as_dict - assert error_dict["code"] == "Python.INVOKED_PROCESS_FAILURE" - assert error_dict["title"] == "Invoked process did not finish successfully." - assert job_error_info.code in error_dict["detail"] + assert exc_info.value.args[0] == ErrorCategory.USER mock_retrieve_async.assert_called_once_with( job_key, folder_key="test-folder", @@ -197,13 +194,10 @@ async def test_read_api_trigger_failure( api_resume=UiPathApiTrigger(inbox_id=inbox_id, request="test"), ) - with pytest.raises(UiPathRuntimeError) as exc_info: + with pytest.raises(UiPathFaultedTriggerError) as exc_info: reader = UiPathResumeTriggerReader() await reader.read_trigger(resume_trigger) - error_dict = exc_info.value.as_dict - assert error_dict["code"] == "Python.RETRIEVE_PAYLOAD_ERROR" - assert error_dict["title"] == "Failed to get trigger payload" - assert "Server error '500 Internal Server Error'" in error_dict["detail"] + assert exc_info.value.args[0] == ErrorCategory.SYSTEM class TestHitlProcessor: diff --git a/uv.lock b/uv.lock index 2c8672b98..5b0f9a5b8 100644 --- a/uv.lock +++ b/uv.lock @@ -2464,7 +2464,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.12" +version = "2.2.13" source = { editable = "." } dependencies = [ { name = "click" }, @@ -2528,7 +2528,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, - { name = "uipath-core", specifier = ">=0.0.6,<0.1.0" }, + { name = "uipath-core", specifier = ">=0.0.9,<0.1.0" }, { name = "uipath-runtime", specifier = ">=0.1.1,<0.2.0" }, ] @@ -2561,16 +2561,16 @@ dev = [ [[package]] name = "uipath-core" -version = "0.0.6" +version = "0.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/79/b7be77e8dda5e5f74b353ed42e5e3fdea37e40f43bb5b06c11b4e9ea2a48/uipath_core-0.0.6.tar.gz", hash = "sha256:a097307b2101d49b1cb554d5808b6ca573640d6a0fe2bada971eb4ff860a648d", size = 81867, upload-time = "2025-12-02T09:30:56.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/fa/d3b2f0ce3c81c051654431a9d05980b6308305bac23c6be2b59e395c1b16/uipath_core-0.0.9.tar.gz", hash = "sha256:362e3d649dc1f650d23d97da7f0afcd396a6a48f30abea9c956b6912198defef", size = 82156, upload-time = "2025-12-03T14:20:58.963Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/4d/796f6ed63ba64c3b38e6763446c0c8ae9896ef443318a4d554a5dfcf447c/uipath_core-0.0.6-py3-none-any.whl", hash = "sha256:5bdd42b752659779774998ceffaf2672365d40dc8e4290a77cfd50f4e3504113", size = 21392, upload-time = "2025-12-02T09:30:54.899Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ea/0ba722d6c05d1821586e43126a2049184dcb6406d6bfdbcbd002447f2753/uipath_core-0.0.9-py3-none-any.whl", hash = "sha256:0f4770ea3b69025edc76e85697afb6ff0d9bf657b47d71d845ba6c38c12385ae", size = 22151, upload-time = "2025-12-03T14:20:57.473Z" }, ] [[package]]