diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index bbf16c8ca..d973884de 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" 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..64862047c 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,30 @@ def _ensure_string_value(value: Any) -> str: return str(value) if value else "" +def _is_jit_debug_app_task(app_name: Optional[str], app_key: Optional[str]) -> bool: + """Return whether this app task must be created just-in-time (JIT). + + During a debug run an app task may target an app that is not deployed yet, + so neither an app key nor an action schema can be resolved from the + deployed-apps endpoint. Such a task is instead created with the app *name* + and folder path, and Action Center resolves the app itself. + + Gated on the ``EnableJITEscalationApps`` feature flag. An explicit + ``app_key`` always wins, since the caller already knows the deployed app. + """ + if FeatureFlags.is_flag_enabled(_JIT_ESCALATION_APPS_FEATURE_FLAG, default=False): + if app_key or 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 +61,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 +117,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 +141,20 @@ def _create_spec( ), } + if is_debug: + # The app may not be deployed yet, so there is no system name to send as the + # app id: Action Center resolves the app from its name and fills the id in. + 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,11 +191,14 @@ 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 - identical for both task types. + identical for both task types. ``is_debug`` marks a JIT task so Action Center + resolves the app from the name and folder path on the payload. """ project_id = UiPathConfig.project_id trace_id = UiPathConfig.trace_id @@ -178,7 +213,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 +523,26 @@ 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_jit_debug_app_task(app_name, app_key) + if is_debug: + # The app may not be deployed yet, so there is nothing to resolve: + # send the name and let Action Center resolve the app. + 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 +551,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 +619,26 @@ 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_jit_debug_app_task(app_name, app_key) + if is_debug: + # The app may not be deployed yet, so there is nothing to resolve: + # send the name and let Action Center resolve the app. + 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 +647,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..83b8b8d13 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -862,3 +862,188 @@ 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" + + +def test_create_skips_jit_when_app_key_is_given( + 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-app", + app_key="test-app-key", + app_folder_path="Shared/Apps", + ) + + # An explicit key means the caller already knows the deployed app. + body = _posted_body(httpx_mock, create_task_url) + assert body["appId"] == "test-app-key" + assert "appName" not in body + assert body["folderPath"] == "Shared/Apps" + assert "isDebug" not in body["taskSource"] diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 295f77671..80451fb6f 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.13" +version = "0.2.14" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9c5cf81ca..869eff75a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.16" +version = "2.13.17" 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.12.2, <0.13.0", - "uipath-platform>=0.2.4, <0.3.0", + "uipath-platform>=0.2.14, <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 7af888cf9..c7ddd2360 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2598,7 +2598,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.16" +version = "2.13.17" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.13" +version = "0.2.14" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },