diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index b12e287e3..97528d9be 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.18" +version = "0.2.19" 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/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py index dea78f882..b56aa59a2 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -3,6 +3,7 @@ import uuid from typing import Any, Dict, List, Optional +from uipath.core.feature_flags import FeatureFlags from uipath.core.tracing import traced from uipath.platform.constants import ( @@ -19,6 +20,8 @@ from .task_schema import TaskSchema from .tasks import Task, TaskRecipient, TaskRecipientType +_JIT_ESCALATION_APPS_FEATURE_FLAG = "EnableJITEscalationApps" + def _ensure_string_value(value: Any) -> str: """Convert any value to a string for use in field Value.""" @@ -27,11 +30,24 @@ def _ensure_string_value(value: Any) -> str: return str(value) if value else "" +def _is_debug_app_task(app_name: Optional[str]) -> bool: + """Return whether this app task is created in debug mode. + + Gated on the ``EnableJITEscalationApps`` feature flag. + """ + if FeatureFlags.is_flag_enabled(_JIT_ESCALATION_APPS_FEATURE_FLAG, default=False): + if not app_name: + return False + return UiPathConfig.is_studio_project + return False + + def _create_spec( data: Optional[Dict[str, Any]], action_schema: Optional[TaskSchema], title: str, app_key: Optional[str] = None, + app_name: Optional[str] = None, app_folder_key: Optional[str] = None, app_folder_path: Optional[str] = None, priority: Optional[str] = None, @@ -39,6 +55,7 @@ def _create_spec( is_actionable_message_enabled: Optional[bool] = None, actionable_message_metadata: Optional[Dict[str, Any]] = None, source_name: str = "Agent", + is_debug: bool = False, ) -> RequestSpec: field_list = [] outcome_list = [] @@ -94,7 +111,6 @@ def _create_spec( ) json_payload: Dict[str, Any] = { - "appId": app_key, "title": title, "data": data if data is not None else {}, "actionableMessageMetaData": actionable_message_metadata @@ -119,10 +135,18 @@ def _create_spec( ), } + if is_debug: + json_payload["appName"] = app_name + else: + json_payload["appId"] = app_key + + if app_folder_path: + json_payload["folderPath"] = app_folder_path + _apply_priority_labels_and_actionable_toggle( json_payload, priority, labels, is_actionable_message_enabled ) - _apply_task_source(json_payload, source_name) + _apply_task_source(json_payload, source_name, is_debug=is_debug) return RequestSpec( method="POST", @@ -159,7 +183,9 @@ def _apply_priority_labels_and_actionable_toggle( payload["isActionableMessageEnabled"] = is_actionable_message_enabled -def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None: +def _apply_task_source( + payload: Dict[str, Any], source_name: str, is_debug: bool = False +) -> None: """Populate ``payload["taskSource"]`` when UiPathConfig has project_id + trace_id. Shared between AppTask and QuickForm spec builders — the taskSource block is @@ -178,7 +204,10 @@ def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None: "JobKey": UiPathConfig.job_key, "ProcessKey": UiPathConfig.process_uuid, }, + "jobId": UiPathConfig.job_key, } + if is_debug: + payload["taskSource"]["isDebug"] = True def _normalize_priority(priority: str | None) -> str | None: @@ -485,17 +514,24 @@ async def create_async( Raises: Exception: If neither app_name nor app_key is provided for app-specific actions """ - (key, action_schema) = ( - (app_key, None) - if app_key - else await self._get_app_key_and_schema_async( - app_name, app_folder_path, app_folder_key + key: Optional[str] + action_schema: Optional[TaskSchema] + is_debug = _is_debug_app_task(app_name) + if is_debug: + key, action_schema = None, None + else: + (key, action_schema) = ( + (app_key, None) + if app_key + else await self._get_app_key_and_schema_async( + app_name, app_folder_path, app_folder_key + ) ) - ) spec = _create_spec( title=title, data=data, app_key=key, + app_name=app_name, action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, @@ -504,6 +540,7 @@ async def create_async( is_actionable_message_enabled=is_actionable_message_enabled, actionable_message_metadata=actionable_message_metadata, source_name=source_name, + is_debug=is_debug, ) response = await self.request_async( @@ -571,15 +608,24 @@ def create( Raises: Exception: If neither app_name nor app_key is provided for app-specific actions """ - (key, action_schema) = ( - (app_key, None) - if app_key - else self._get_app_key_and_schema(app_name, app_folder_path, app_folder_key) - ) + key: Optional[str] + action_schema: Optional[TaskSchema] + is_debug = _is_debug_app_task(app_name) + if is_debug: + key, action_schema = None, None + else: + (key, action_schema) = ( + (app_key, None) + if app_key + else self._get_app_key_and_schema( + app_name, app_folder_path, app_folder_key + ) + ) spec = _create_spec( title=title, data=data, app_key=key, + app_name=app_name, action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, @@ -588,6 +634,7 @@ def create( is_actionable_message_enabled=is_actionable_message_enabled, actionable_message_metadata=actionable_message_metadata, source_name=source_name, + is_debug=is_debug, ) response = self.request( diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index 28180dbbb..de9c97b46 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -862,3 +862,165 @@ async def test_create_quickform_async_with_assignee_triggers_assign_call( await qf_runner_async(assignee="user@example.com") body = _posted_body(httpx_mock, qf_assign_url) assert body["taskAssignments"][0]["UserNameOrEmail"] == "user@example.com" + + +# --------------------------------------------------------------------------- +# JIT (debug) app task tests +# --------------------------------------------------------------------------- + +_JIT_FLAG_ENV = "UIPATH_FEATURE_EnableJITEscalationApps" +_APP_SCHEMAS_PATH = "deployed-action-apps-schemas" + + +@pytest.fixture +def create_task_url(base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask" + + +@pytest.fixture +def jit_debug_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Enable the JIT flag and place the process in a Studio debug run.""" + monkeypatch.setenv(_JIT_FLAG_ENV, "true") + monkeypatch.setenv("UIPATH_PROJECT_ID", "project-1") + monkeypatch.setenv("UIPATH_TRACE_ID", "trace-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + + +def _mock_create_task(httpx_mock: HTTPXMock, create_task_url: str) -> None: + httpx_mock.add_response( + url=create_task_url, status_code=200, json={"id": 1, "title": "Test Action"} + ) + + +def _requested_app_schemas(httpx_mock: HTTPXMock) -> bool: + return any(_APP_SCHEMAS_PATH in str(r.url) for r in httpx_mock.get_requests()) + + +def test_create_jit_sends_app_name_and_folder_path_without_resolving( + httpx_mock: HTTPXMock, + service: TasksService, + create_task_url: str, + jit_debug_env: None, +) -> None: + _mock_create_task(httpx_mock, create_task_url) + + task = service.create( + title="Test Action", + app_name="my-inline-app", + app_folder_path="Shared/Apps", + data={"test": "data"}, + ) + + assert isinstance(task, Task) + body = _posted_body(httpx_mock, create_task_url) + # The app may not be deployed yet: the name is sent instead of an app id, which + # Action Center fills in once it resolves the app. No deployed-apps lookup happens. + assert body["appName"] == "my-inline-app" + assert "appId" not in body + assert body["folderPath"] == "Shared/Apps" + assert body["taskSource"]["isDebug"] is True + assert not _requested_app_schemas(httpx_mock) + + +async def test_create_async_jit_sends_app_name_and_folder_path_without_resolving( + httpx_mock: HTTPXMock, + service: TasksService, + create_task_url: str, + jit_debug_env: None, +) -> None: + _mock_create_task(httpx_mock, create_task_url) + + task = await service.create_async( + title="Test Action", + app_name="my-inline-app", + app_folder_path="Shared/Apps", + ) + + assert isinstance(task, Task) + body = _posted_body(httpx_mock, create_task_url) + assert body["appName"] == "my-inline-app" + assert "appId" not in body + assert body["folderPath"] == "Shared/Apps" + assert body["taskSource"]["isDebug"] is True + assert not _requested_app_schemas(httpx_mock) + + +def test_create_jit_carries_no_action_schema( + httpx_mock: HTTPXMock, + service: TasksService, + create_task_url: str, + jit_debug_env: None, +) -> None: + _mock_create_task(httpx_mock, create_task_url) + + service.create( + title="Test Action", + app_name="my-inline-app", + app_folder_path="Shared/Apps", + data={"test": "data"}, + ) + + # Action Center builds the fields from the app it resolves, so nothing is + # derived from a schema here. + body = _posted_body(httpx_mock, create_task_url) + assert body["actionableMessageMetaData"] == {} + assert body["data"] == {"test": "data"} + + +def test_create_skips_jit_when_flag_disabled( + httpx_mock: HTTPXMock, + service: TasksService, + create_task_url: str, + jit_debug_env: None, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, +) -> None: + monkeypatch.setenv(_JIT_FLAG_ENV, "false") + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/{_APP_SCHEMAS_PATH}?search=my-app&filterByDeploymentTitle=true", + status_code=200, + json={"deployed": [_make_deployed_app("my-app", "Shared/Apps", "folder-key")]}, + ) + _mock_create_task(httpx_mock, create_task_url) + + service.create( + title="Test Action", + app_name="my-app", + app_folder_path="Shared/Apps", + ) + + body = _posted_body(httpx_mock, create_task_url) + assert body["appId"] == "my-app" # resolved systemName, not the JIT passthrough + # Action Center rejects a name it is not allowed to resolve, so none is sent. + assert "appName" not in body + assert body["folderPath"] == "Shared/Apps" + assert "isDebug" not in body["taskSource"] + assert _requested_app_schemas(httpx_mock) + + +def test_create_skips_jit_when_not_a_studio_project( + httpx_mock: HTTPXMock, + service: TasksService, + create_task_url: str, + jit_debug_env: None, + monkeypatch: pytest.MonkeyPatch, + base_url: str, + org: str, +) -> None: + monkeypatch.delenv("UIPATH_PROJECT_ID") + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/{_APP_SCHEMAS_PATH}?search=my-app&filterByDeploymentTitle=true", + status_code=200, + json={"deployed": [_make_deployed_app("my-app", "Shared/Apps", "folder-key")]}, + ) + _mock_create_task(httpx_mock, create_task_url) + + service.create( + title="Test Action", + app_name="my-app", + app_folder_path="Shared/Apps", + ) + + assert _requested_app_schemas(httpx_mock) + assert _posted_body(httpx_mock, create_task_url)["folderPath"] == "Shared/Apps" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 99067a7a6..68123d499 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.18" +version = "0.2.19" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 2ac4f3d35..e2f622cd2 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.14.5" +version = "2.14.6" 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.5.30, <0.6.0", "uipath-runtime>=0.13.1, <0.14.0", - "uipath-platform>=0.2.14, <0.3.0", + "uipath-platform>=0.2.19, <0.3.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 106ad76c8..e8f7ef7c6 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.5" +version = "2.14.6" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.18" +version = "0.2.19" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },