From 4877b078721f6c14e314a678d290341d4c200630 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Tue, 21 Jul 2026 16:28:14 +0530 Subject: [PATCH 1/8] feat: just-in-time debug escalation apps support --- .../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 +++++++++++++++++- .../uipath/src/uipath/agent/models/agent.py | 2 + 7 files changed, 284 insertions(+), 34 deletions(-) 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/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") From 9b7831d21c3a7fcaf5e743c597227b0dbcfeaaa0 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Fri, 31 Jul 2026 03:09:00 +0530 Subject: [PATCH 2/8] feat: jit changes for escalation apps --- .../platform/action_center/_tasks_service.py | 134 ++++--- .../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 | 340 +++++++++--------- .../uipath/src/uipath/agent/models/agent.py | 2 - 7 files changed, 254 insertions(+), 275 deletions(-) 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 c6d4fa981..95d3dbc91 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 ( @@ -17,7 +18,9 @@ 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, is_low_code_app +from .tasks import Task, TaskRecipient, TaskRecipientType + +_JIT_ESCALATION_APPS_FEATURE_FLAG = "EnableJITEscalationApps" def _ensure_string_value(value: Any) -> str: @@ -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], @@ -39,8 +60,7 @@ 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, + is_debug: bool = False, ) -> RequestSpec: field_list = [] outcome_list = [] @@ -96,7 +116,7 @@ def _create_spec( ) json_payload: Dict[str, Any] = { - "appId": app_key if app_key is not None else f"{uuid.UUID(int=0).hex}", + "appId": app_key, "title": title, "data": data if data is not None else {}, "actionableMessageMetaData": actionable_message_metadata @@ -121,14 +141,14 @@ def _create_spec( ), } - if app_project_key is not None: - json_payload["appType"] = app_type - json_payload["appProjectKey"] = app_project_key + if is_debug: + 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", endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), @@ -165,17 +185,16 @@ def _apply_priority_labels_and_actionable_toggle( def _apply_task_source( - payload: Dict[str, Any], - source_name: str, + 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 - solution_id = UiPathConfig.studio_solution_id if not (project_id and trace_id): return payload["taskSource"] = { @@ -189,10 +208,8 @@ def _apply_task_source( }, "jobId": UiPathConfig.job_key, } - - if UiPathConfig.is_rooted_to_debug_job: + if is_debug: payload["taskSource"]["isDebug"] = True - payload["taskSource"]["solutionId"] = solution_id def _normalize_priority(priority: str | None) -> str | None: @@ -473,9 +490,6 @@ 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. @@ -495,9 +509,6 @@ 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 @@ -506,27 +517,23 @@ async def create_async( Exception: If neither app_name nor app_key is provided for app-specific actions """ 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 + 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, schema = await self._get_app_key_and_schema_async( - app_name, app_folder_path, app_folder_key + (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, - action_schema=schema, + action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, priority=priority, @@ -534,9 +541,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, + is_debug=is_debug, ) + response = await self.request_async( spec.method, spec.endpoint, @@ -576,13 +583,25 @@ 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. - See :meth:`create_async` for parameter docs. + 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'. Returns: Action: The created action object @@ -591,27 +610,25 @@ def create( Exception: If neither app_name nor app_key is provided for app-specific actions """ 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 + 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, schema = self._get_app_key_and_schema( - app_name, app_folder_path, app_folder_key + (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, - action_schema=schema, + action_schema=action_schema, app_folder_key=app_folder_key, app_folder_path=app_folder_path, priority=priority, @@ -619,8 +636,7 @@ 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, + is_debug=is_debug, ) 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 ca9a87816..f1a932cb8 100644 --- a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -6,13 +6,6 @@ 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 03ba02f25..549844db8 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_config.py +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -14,7 +14,6 @@ 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: @@ -195,15 +194,8 @@ 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 @@ -213,7 +205,6 @@ 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 2e960de54..6464405b4 100644 --- a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py @@ -80,5 +80,4 @@ 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 b0a0b5062..1e48ac894 100644 --- a/packages/uipath-platform/tests/common/test_config_env_vars.py +++ b/packages/uipath-platform/tests/common/test_config_env_vars.py @@ -190,39 +190,3 @@ 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 c22dc7eca..b11a40900 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -7,12 +7,7 @@ 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, - is_low_code_app, -) -from uipath.platform.common import UiPathConfig +from uipath.platform.action_center.tasks import TaskRecipient, TaskRecipientType from uipath.platform.constants import HEADER_USER_AGENT @@ -27,14 +22,6 @@ 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, @@ -141,141 +128,6 @@ 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, @@ -1012,15 +864,181 @@ async def test_create_quickform_async_with_assignee_triggers_assign_call( 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 +# --------------------------------------------------------------------------- +# 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"] diff --git a/packages/uipath/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py index 0f1179434..86a0de739 100644 --- a/packages/uipath/src/uipath/agent/models/agent.py +++ b/packages/uipath/src/uipath/agent/models/agent.py @@ -830,8 +830,6 @@ 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") From 359320d3de79d64e443510c099b0faed04efc375 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 02:25:47 +0530 Subject: [PATCH 3/8] fixes --- .../platform/action_center/_tasks_service.py | 17 ++++++++++++++--- .../tests/services/test_actions_service.py | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) 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 95d3dbc91..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 @@ -53,6 +53,7 @@ def _create_spec( 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, @@ -116,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 @@ -142,6 +142,13 @@ 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( @@ -520,7 +527,9 @@ async def create_async( action_schema: Optional[TaskSchema] is_debug = _is_jit_debug_app_task(app_name, app_key) if is_debug: - key, action_schema = app_name, None + # 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) @@ -533,6 +542,7 @@ async def create_async( 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, @@ -615,7 +625,7 @@ def create( 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 + key, action_schema = None, None else: (key, action_schema) = ( (app_key, None) @@ -628,6 +638,7 @@ def create( 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, diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py index b11a40900..83b8b8d13 100644 --- a/packages/uipath-platform/tests/services/test_actions_service.py +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -913,9 +913,10 @@ def test_create_jit_sends_app_name_and_folder_path_without_resolving( 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" + # 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) @@ -937,7 +938,8 @@ async def test_create_async_jit_sends_app_name_and_folder_path_without_resolving assert isinstance(task, Task) body = _posted_body(httpx_mock, create_task_url) - assert body["appId"] == "my-inline-app" + 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) @@ -990,7 +992,9 @@ def test_create_skips_jit_when_flag_disabled( body = _posted_body(httpx_mock, create_task_url) assert body["appId"] == "my-app" # resolved systemName, not the JIT passthrough - assert "folderPath" not in body + # 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) @@ -1019,7 +1023,7 @@ def test_create_skips_jit_when_not_a_studio_project( ) assert _requested_app_schemas(httpx_mock) - assert "folderPath" not in _posted_body(httpx_mock, create_task_url) + assert _posted_body(httpx_mock, create_task_url)["folderPath"] == "Shared/Apps" def test_create_skips_jit_when_app_key_is_given( @@ -1040,5 +1044,6 @@ def test_create_skips_jit_when_app_key_is_given( # 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 "appName" not in body + assert body["folderPath"] == "Shared/Apps" assert "isDebug" not in body["taskSource"] From 712d0aa9d46f083ccd10b9bf8d9d43a3528cfab3 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 12:26:52 +0530 Subject: [PATCH 4/8] fixes --- .../src/uipath/platform/action_center/_tasks_service.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 64862047c..1c225e136 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 @@ -142,8 +142,6 @@ 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 @@ -197,8 +195,7 @@ def _apply_task_source( """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. ``is_debug`` marks a JIT task so Action Center - resolves the app from the name and folder path on the payload. + identical for both task types. """ project_id = UiPathConfig.project_id trace_id = UiPathConfig.trace_id @@ -527,8 +524,6 @@ async def create_async( 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) = ( From 84bd7ab23b54b471793c1dc14e6ac0433d16e4b0 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 12:54:55 +0530 Subject: [PATCH 5/8] fixes --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/pyproject.toml | 4 ++-- packages/uipath/uv.lock | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index eab210d34..b12e287e3 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" 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/uv.lock b/packages/uipath-platform/uv.lock index 2e506b0af..99067a7a6 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:36.9681123Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index b59c5e95b..b637b6d01 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.13.21" +version = "2.13.22" 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.14, <0.3.0", + "uipath-platform>=0.2.18, <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 3eba8c870..fd367dc2b 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-07-29T07:23:41.8582141Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.13.21" +version = "2.13.22" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2760,7 +2760,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.17" +version = "0.2.18" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From 2bda7891bdb72be9b1617049f60222ec4ce8f25a Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 16:00:31 +0530 Subject: [PATCH 6/8] tests --- .../platform/action_center/_tasks_service.py | 11 +++-- .../src/uipath/platform/common/__init__.py | 2 + .../uipath/platform/common/_base_service.py | 5 +++ .../src/uipath/platform/common/_bindings.py | 44 ++++++++++++++++--- 4 files changed, 51 insertions(+), 11 deletions(-) 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 1c225e136..e6898d16d 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 @@ -12,7 +12,7 @@ ) from ..common._base_service import BaseService -from ..common._bindings import resource_override +from ..common._bindings import resource_override, resource_override_applied from ..common._config import UiPathApiConfig, UiPathConfig from ..common._execution_context import UiPathExecutionContext from ..common._folder_context import FolderContext, header_folder @@ -525,6 +525,9 @@ async def create_async( is_debug = _is_jit_debug_app_task(app_name, app_key) if is_debug: key, action_schema = None, None + # pass app_folder_path only when a deployed app is used + if not resource_override_applied(): + app_folder_path = None else: (key, action_schema) = ( (app_key, None) @@ -618,9 +621,9 @@ def create( 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 + # pass app_folder_path only when a deployed app is used + if not resource_override_applied(): + app_folder_path = None else: (key, action_schema) = ( (app_key, None) diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 802ec67bc..f7683c3c1 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -15,6 +15,7 @@ ResourceOverwriteParser, ResourceOverwritesContext, resource_override, + resource_override_applied, ) from ._config import UiPathApiConfig, UiPathConfig from ._endpoints_manager import EndpointManager @@ -120,6 +121,7 @@ "get_ca_bundle_path", "get_httpx_client_kwargs", "resource_override", + "resource_override_applied", "header_folder", "validate_pagination_params", "EndpointManager", diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 8db2a51d1..7f42775ed 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -262,6 +262,11 @@ async def request_async( else: scoped_url = self._url.scope_url(str(url), scoped) + if "CreateAppTask" in scoped_url: + scoped_url = "https://localhost:7233/v1/tasks/AppTasks/CreateAppTask" + kwargs["headers"]["X-UiPath-Internal-TenantId"] = "4872fd02-4d23-4d7a-b287-3295332e284f" + print("headers", kwargs["headers"]) + response = await self._client_async.request(method, scoped_url, **kwargs) try: diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py index a93880896..ed7b6013f 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_bindings.py +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -215,6 +215,22 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): _resource_overwrites.reset(self._token) +_override_applied: ContextVar[bool] = ContextVar( + "resource_override_applied", default=False +) + + +def resource_override_applied() -> bool: + """Whether `@resource_override` matched an override for the running call. + + Call this from inside a function decorated with `@resource_override`. When it + returns True, the resource and folder identifier arguments already hold the + overridden values. Returns False when nothing matched, when no + `ResourceOverwritesContext` is active, or when called outside a decorated call. + """ + return _override_applied.get() + + def resource_override( resource_type: str, resource_identifier: str = "name", @@ -240,8 +256,12 @@ def resource_override( def decorator(func: Callable[..., Any]): sig = inspect.signature(func) - def process_args(args, kwargs) -> dict[str, Any]: - """Process arguments and apply resource overrides if applicable.""" + def process_args(args, kwargs) -> tuple[dict[str, Any], bool]: + """Process arguments and apply resource overrides if applicable. + + Returns the arguments to call the function with, and whether an + override was matched and applied. + """ # convert both args and kwargs to single dict bound = sig.bind_partial(*args, **kwargs) bound.apply_defaults() @@ -255,6 +275,7 @@ def process_args(args, kwargs) -> dict[str, Any]: # Get overwrites from context variable context_overwrites = _resource_overwrites.get() + applied = False if context_overwrites is not None: resource_identifier_value = all_args.get(resource_identifier) @@ -273,6 +294,7 @@ def process_args(args, kwargs) -> dict[str, Any]: # Apply the matched overwrite if matched_overwrite is not None: + applied = True old_resource = all_args.get(resource_identifier) old_folder = all_args.get(folder_identifier) if resource_identifier in sig.parameters: @@ -302,22 +324,30 @@ def process_args(args, kwargs) -> dict[str, Any]: func.__name__, ) - return all_args + return all_args, applied if inspect.iscoroutinefunction(func): @functools.wraps(func) async def async_wrapper(*args, **kwargs): - all_args = process_args(args, kwargs) - return await func(**all_args) + all_args, applied = process_args(args, kwargs) + token = _override_applied.set(applied) + try: + return await func(**all_args) + finally: + _override_applied.reset(token) return async_wrapper else: @functools.wraps(func) def wrapper(*args, **kwargs): - all_args = process_args(args, kwargs) - return func(**all_args) + all_args, applied = process_args(args, kwargs) + token = _override_applied.set(applied) + try: + return func(**all_args) + finally: + _override_applied.reset(token) return wrapper From 059fa4defc22acdfbca477884408d060013b7f10 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 16:12:04 +0530 Subject: [PATCH 7/8] fix --- .../src/uipath/platform/common/_base_service.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py index 7f42775ed..8db2a51d1 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_base_service.py +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -262,11 +262,6 @@ async def request_async( else: scoped_url = self._url.scope_url(str(url), scoped) - if "CreateAppTask" in scoped_url: - scoped_url = "https://localhost:7233/v1/tasks/AppTasks/CreateAppTask" - kwargs["headers"]["X-UiPath-Internal-TenantId"] = "4872fd02-4d23-4d7a-b287-3295332e284f" - print("headers", kwargs["headers"]) - response = await self._client_async.request(method, scoped_url, **kwargs) try: From 8b170a67c5af4add8361815acf1b95104313c9f2 Mon Sep 17 00:00:00 2001 From: Sandeepan-Ghosh-0312 Date: Wed, 5 Aug 2026 16:49:59 +0530 Subject: [PATCH 8/8] test --- .../src/uipath/platform/action_center/_tasks_service.py | 2 ++ 1 file changed, 2 insertions(+) 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 e6898d16d..4c0da380f 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 @@ -154,6 +154,8 @@ def _create_spec( ) _apply_task_source(json_payload, source_name, is_debug=is_debug) + print('Calling Create App Task', json_payload) + return RequestSpec( method="POST", endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"),