From 1da4c4cf04c8947d210938b5912a8514678c07e7 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Tue, 21 Jul 2026 16:28:14 +0530 Subject: [PATCH] feat: just-in-time debug escalation apps support --- packages/uipath-platform/pyproject.toml | 2 +- .../platform/action_center/_tasks_service.py | 99 +++++++---- .../uipath/platform/action_center/tasks.py | 7 + .../src/uipath/platform/common/_config.py | 9 + .../src/uipath/platform/orchestrator/job.py | 1 + .../tests/common/test_config_env_vars.py | 36 ++++ .../tests/services/test_actions_service.py | 164 +++++++++++++++++- packages/uipath-platform/uv.lock | 2 +- packages/uipath/pyproject.toml | 4 +- .../uipath/src/uipath/agent/models/agent.py | 2 + packages/uipath/uv.lock | 4 +- 11 files changed, 290 insertions(+), 40 deletions(-) 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..c6d4fa981 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 @@ -17,7 +17,7 @@ from ..common._folder_context import FolderContext, header_folder from ..common._models import Endpoint, RequestSpec from .task_schema import TaskSchema -from .tasks import Task, TaskRecipient, TaskRecipientType +from .tasks import Task, TaskRecipient, TaskRecipientType, is_low_code_app def _ensure_string_value(value: Any) -> str: @@ -39,6 +39,8 @@ def _create_spec( is_actionable_message_enabled: Optional[bool] = None, actionable_message_metadata: Optional[Dict[str, Any]] = None, source_name: str = "Agent", + app_project_key: Optional[str] = None, + app_type: Optional[str] = None, ) -> RequestSpec: field_list = [] outcome_list = [] @@ -94,7 +96,7 @@ def _create_spec( ) json_payload: Dict[str, Any] = { - "appId": app_key, + "appId": app_key if app_key is not None else f"{uuid.UUID(int=0).hex}", "title": title, "data": data if data is not None else {}, "actionableMessageMetaData": actionable_message_metadata @@ -119,11 +121,14 @@ def _create_spec( ), } + if app_project_key is not None: + json_payload["appType"] = app_type + json_payload["appProjectKey"] = app_project_key + _apply_priority_labels_and_actionable_toggle( json_payload, priority, labels, is_actionable_message_enabled ) _apply_task_source(json_payload, source_name) - return RequestSpec( method="POST", endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), @@ -159,7 +164,10 @@ 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, +) -> None: """Populate ``payload["taskSource"]`` when UiPathConfig has project_id + trace_id. Shared between AppTask and QuickForm spec builders — the taskSource block is @@ -167,6 +175,7 @@ def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None: """ project_id = UiPathConfig.project_id trace_id = UiPathConfig.trace_id + solution_id = UiPathConfig.studio_solution_id if not (project_id and trace_id): return payload["taskSource"] = { @@ -178,8 +187,13 @@ 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 UiPathConfig.is_rooted_to_debug_job: + payload["taskSource"]["isDebug"] = True + payload["taskSource"]["solutionId"] = solution_id + def _normalize_priority(priority: str | None) -> str | None: """Normalize priority string to match API expectations. @@ -459,6 +473,9 @@ async def create_async( is_actionable_message_enabled: Optional[bool] = None, actionable_message_metadata: Optional[Dict[str, Any]] = None, source_name: str = "Agent", + app_project_key: Optional[str] = None, + app_type: Optional[str] = None, + action_schema: Optional[Dict[str, Any]] = None, ) -> Task: """Creates a new action asynchronously. @@ -478,6 +495,9 @@ async def create_async( is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task actionable_message_metadata: Optional metadata for the action source_name: The name of the source that created the task. Defaults to 'Agent'. + app_project_key: Optional project key of the app. Used for JIT (debug) task creation so + Orchestrator can resolve a not-yet-deployed app. + app_type: Optional app type ("Custom" or "Coded"), forwarded for JIT (debug) task creation. Returns: Action: The created action object @@ -485,18 +505,28 @@ 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( + key: Optional[str] + schema: Optional[TaskSchema] + if app_project_key and is_low_code_app(app_type) and action_schema is not None: + key = app_key + schema = TaskSchema( + key=action_schema["key"], + in_outs=action_schema["inOuts"], + inputs=action_schema["inputs"], + outputs=action_schema["outputs"], + outcomes=action_schema["outcomes"], + ) + elif app_key: + key, schema = app_key, None + else: + key, schema = 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, - action_schema=action_schema, + action_schema=schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, priority=priority, @@ -504,8 +534,9 @@ async def create_async( is_actionable_message_enabled=is_actionable_message_enabled, actionable_message_metadata=actionable_message_metadata, source_name=source_name, + app_project_key=app_project_key, + app_type=app_type, ) - response = await self.request_async( spec.method, spec.endpoint, @@ -545,25 +576,13 @@ def create( is_actionable_message_enabled: Optional[bool] = None, actionable_message_metadata: Optional[Dict[str, Any]] = None, source_name: str = "Agent", + app_project_key: Optional[str] = None, + app_type: Optional[str] = None, + action_schema: Optional[Dict[str, Any]] = None, ) -> Task: """Creates a new task synchronously. - This method creates a new action task in UiPath Orchestrator. The action can be - either app-specific (using app_name or app_key) or a generic action. - - Args: - title: The title of the action - data: Optional dictionary containing input data for the action - app_name: The name of the application (if creating an app-specific action) - app_key: The key of the application (if creating an app-specific action) - 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 - priority: Optional priority of the task - labels: Optional list of labels for the task - is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task - actionable_message_metadata: Optional metadata for the action - source_name: The name of the source that created the task. Defaults to 'Agent'. + See :meth:`create_async` for parameter docs. Returns: Action: The created action object @@ -571,16 +590,28 @@ 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] + schema: Optional[TaskSchema] + if app_project_key and is_low_code_app(app_type) and action_schema is not None: + key = app_key + schema = TaskSchema( + key=action_schema["key"], + in_outs=action_schema["inOuts"], + inputs=action_schema["inputs"], + outputs=action_schema["outputs"], + outcomes=action_schema["outcomes"], + ) + elif app_key: + key, schema = app_key, None + else: + key, schema = self._get_app_key_and_schema( + app_name, app_folder_path, app_folder_key + ) spec = _create_spec( title=title, data=data, app_key=key, - action_schema=action_schema, + action_schema=schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, priority=priority, @@ -588,6 +619,8 @@ def create( is_actionable_message_enabled=is_actionable_message_enabled, actionable_message_metadata=actionable_message_metadata, source_name=source_name, + app_project_key=app_project_key, + app_type=app_type, ) response = self.request( diff --git a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py index f1a932cb8..ca9a87816 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -6,6 +6,13 @@ from pydantic import BaseModel, ConfigDict, Field, field_serializer +APP_TYPE_LOW_CODE = "Custom" + + +def is_low_code_app(app_type: str | None) -> bool: + """Return True when ``app_type`` denotes a low-code (custom) app.""" + return app_type == APP_TYPE_LOW_CODE + class TaskStatus(enum.IntEnum): """Enum representing possible Task status.""" diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py index 549844db8..03ba02f25 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -14,6 +14,7 @@ class UiPathApiConfig(BaseModel): class ConfigurationManager: _instance = None studio_solution_id: str | None = None + _is_debug_run_override: bool | None = None def __new__(cls): if cls._instance is None: @@ -194,8 +195,15 @@ def licensing_context(self) -> str | None: @property def is_rooted_to_debug_job(self) -> bool: """Whether this job, which may be a deployed process, is rooted to a debug session (e.g. Maestro solution debug).""" + if self._is_debug_run_override is not None: + return self._is_debug_run_override return self._read_internal_argument("isDebug") is True + @is_rooted_to_debug_job.setter + def is_rooted_to_debug_job(self, value: bool) -> None: + """Override the debug-run status resolved at runtime.""" + self._is_debug_run_override = value + @property def is_tracing_enabled(self) -> bool: from uipath.platform.constants import ENV_TRACING_ENABLED @@ -205,6 +213,7 @@ def is_tracing_enabled(self) -> bool: def reset(self) -> None: """Reset mutable cached state to defaults.""" self.studio_solution_id = None + self._is_debug_run_override = None # Invalidate cached_property by removing from instance __dict__ self.__dict__.pop("_internal_arguments", None) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py index 6464405b4..2e960de54 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py @@ -80,4 +80,5 @@ class Job(BaseModel): has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings") job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError") folder_key: Optional[str] = Field(default=None, alias="FolderKey") + parent_context: Optional[str] = Field(default=None, alias="ParentContext") id: int = Field(alias="Id") diff --git a/packages/uipath-platform/tests/common/test_config_env_vars.py b/packages/uipath-platform/tests/common/test_config_env_vars.py index 1e48ac894..b0a0b5062 100644 --- a/packages/uipath-platform/tests/common/test_config_env_vars.py +++ b/packages/uipath-platform/tests/common/test_config_env_vars.py @@ -190,3 +190,39 @@ def test_has_eval_folder(self, monkeypatch, tmp_path): assert UiPathConfig.has_eval_folder is False (tmp_path / EVALS_FOLDER).mkdir() assert UiPathConfig.has_eval_folder is True + + +class TestIsRootedToDebugJob: + @pytest.fixture(autouse=True) + def _reset_singleton(self): + # is_rooted_to_debug_job mutates the process-wide singleton; reset around + # each test so nothing leaks between tests. + UiPathConfig.reset() + yield + UiPathConfig.reset() + + def test_defaults_to_false_when_unset(self): + assert UiPathConfig.is_rooted_to_debug_job is False + + def test_reads_is_debug_internal_argument(self): + UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True} + assert UiPathConfig.is_rooted_to_debug_job is True + + def test_setter_override_wins_and_is_true(self): + UiPathConfig.is_rooted_to_debug_job = True + assert UiPathConfig.is_rooted_to_debug_job is True + + def test_setter_override_beats_internal_argument(self): + # Even when the internal argument says True, an explicit False override wins. + UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True} + UiPathConfig.is_rooted_to_debug_job = False + assert UiPathConfig.is_rooted_to_debug_job is False + + def test_reset_clears_override(self): + UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True} + UiPathConfig.is_rooted_to_debug_job = False + assert UiPathConfig.is_rooted_to_debug_job is False + UiPathConfig.reset() + # Override cleared, so it falls back to the internal argument again — but + # reset() also drops the cached internal arguments, so it re-reads (None). + assert UiPathConfig.is_rooted_to_debug_job is False diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index 28180dbbb..c22dc7eca 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -7,7 +7,12 @@ from uipath.platform import UiPathApiConfig, UiPathExecutionContext from uipath.platform.action_center import Task from uipath.platform.action_center._tasks_service import TasksService -from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType +from uipath.platform.action_center.tasks import ( + TaskRecipient, + TaskRecipientType, + is_low_code_app, +) +from uipath.platform.common import UiPathConfig from uipath.platform.constants import HEADER_USER_AGENT @@ -22,6 +27,14 @@ def service( return TasksService(config=config, execution_context=execution_context) +@pytest.fixture +def reset_uipath_config(): + """Reset the ``UiPathConfig`` singleton around a test that mutates it.""" + UiPathConfig.reset() + yield UiPathConfig + UiPathConfig.reset() + + class TestTasksService: def test_retrieve( self, @@ -128,6 +141,141 @@ def test_create_with_app_key( assert action.id == 1 assert action.title == "Test Action" + def test_create_jit_custom_app_uses_provided_schema_and_sends_project_key( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # JIT: a not-yet-deployed Custom app supplies its action schema and project + # key, so no deployed-app schema lookup happens and the schema is built from + # the supplied action_schema. app type + project key are sent so Orchestrator + # can resolve the app. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + action = service.create( + title="Test Action", + data={"stringInput": "value"}, + app_project_key="proj-key-abc", + app_type="Custom", + action_schema={ + "key": "schema-key", + "inOuts": [], + "inputs": [{"name": "stringInput", "key": "field-1"}], + "outputs": [], + "outcomes": [{"name": "approve", "key": "outcome-1"}], + }, + ) + + assert isinstance(action, Task) + requests = httpx_mock.get_requests() + # No deployed-app schema resolution — the provided action_schema is used. + assert all("deployed-action-apps-schemas" not in str(r.url) for r in requests) + create_request = [r for r in requests if "CreateAppTask" in str(r.url)][0] + body = json.loads(create_request.content) + assert body["appType"] == "Custom" + assert body["appProjectKey"] == "proj-key-abc" + # Field set is derived from the supplied action_schema, not a lookup. + field_names = [ + f["Name"] for f in body["actionableMessageMetaData"]["fieldSet"]["fields"] + ] + assert "stringInput" in field_names + + def test_create_omits_project_key_when_not_provided( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Non-JIT (deployed) path: no app_project_key, so appProjectKey is not sent. + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + action = service.create( + title="Test Action", + app_key="test-app-key", + data={"test": "data"}, + ) + + assert isinstance(action, Task) + create_request = [ + r for r in httpx_mock.get_requests() if "CreateAppTask" in str(r.url) + ][0] + body = json.loads(create_request.content) + assert "appProjectKey" not in body + assert "appType" not in body + + def test_create_stamps_isdebug_task_source_from_config( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + reset_uipath_config, + ) -> None: + # isDebug + solutionId on taskSource are driven purely by UiPathConfig, not a + # per-call flag. taskSource requires project_id + trace_id to be present. + monkeypatch.setenv("UIPATH_PROJECT_ID", "proj-1") + monkeypatch.setenv("UIPATH_TRACE_ID", "trace-1") + reset_uipath_config.studio_solution_id = "sol-1" + reset_uipath_config.is_rooted_to_debug_job = True + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + service.create(title="Test Action", app_key="test-app-key", data={}) + + create_request = [ + r for r in httpx_mock.get_requests() if "CreateAppTask" in str(r.url) + ][0] + task_source = json.loads(create_request.content)["taskSource"] + assert task_source["isDebug"] is True + assert task_source["solutionId"] == "sol-1" + + def test_create_no_isdebug_task_source_when_not_debug( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + reset_uipath_config, + ) -> None: + monkeypatch.setenv("UIPATH_PROJECT_ID", "proj-1") + monkeypatch.setenv("UIPATH_TRACE_ID", "trace-1") + # is_rooted_to_debug_job defaults to False (no override, no internal arg). + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + service.create(title="Test Action", app_key="test-app-key", data={}) + + create_request = [ + r for r in httpx_mock.get_requests() if "CreateAppTask" in str(r.url) + ][0] + task_source = json.loads(create_request.content)["taskSource"] + assert "isDebug" not in task_source + def test_create_with_assignee( self, httpx_mock: HTTPXMock, @@ -862,3 +1010,17 @@ 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" + + +@pytest.mark.parametrize( + "app_type,expected", + [ + ("Custom", True), + ("Coded", False), + (None, False), + ("", False), + ("custom", False), # case-sensitive + ], +) +def test_is_low_code_app(app_type: Any, expected: bool) -> None: + assert is_low_code_app(app_type) is expected 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/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 86a0de739..0f1179434 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -830,6 +830,8 @@ class AgentEscalationChannelProperties(BaseEscalationChannelProperties): app_name: str | None = Field(default=None, alias="appName") app_version: int = Field(..., alias="appVersion") + app_type: str | None = Field(default=None, alias="appType") + action_schema: Optional[Any] = Field(default=None, alias="actionSchema") folder_name: Optional[str] = Field(None, alias="folderName") resource_key: str | None = Field(default=None, alias="resourceKey") 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" },