Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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."""
Expand All @@ -27,6 +30,24 @@ 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],
Expand All @@ -39,6 +60,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 = []
Expand Down Expand Up @@ -119,10 +141,13 @@ def _create_spec(
),
}

if is_debug:
json_payload["folderPath"] = app_folder_path

Comment on lines +144 to +146
_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",
Expand Down Expand Up @@ -159,11 +184,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
Expand All @@ -178,7 +206,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:
Expand Down Expand Up @@ -485,13 +516,19 @@ 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:
key, action_schema = app_name, 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,
Expand All @@ -504,6 +541,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(
Expand Down Expand Up @@ -571,11 +609,21 @@ 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 = app_name, 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,
Expand All @@ -588,6 +636,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(
Expand Down
180 changes: 180 additions & 0 deletions packages/uipath-platform/tests/services/test_actions_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -862,3 +862,183 @@ 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 in place of a key and no
# deployed-apps lookup happens.
assert body["appId"] == "my-inline-app"
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["appId"] == "my-inline-app"
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
assert "folderPath" not in body
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 "folderPath" not in _posted_body(httpx_mock, create_task_url)


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 "folderPath" not in body
assert "isDebug" not in body["taskSource"]
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.