Skip to content

Commit dafe935

Browse files
feat: just-in-time debug escalation apps support
1 parent 59365ec commit dafe935

11 files changed

Lines changed: 288 additions & 40 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.2.13"
3+
version = "0.2.14"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py

Lines changed: 65 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ..common._folder_context import FolderContext, header_folder
1818
from ..common._models import Endpoint, RequestSpec
1919
from .task_schema import TaskSchema
20-
from .tasks import Task, TaskRecipient, TaskRecipientType
20+
from .tasks import Task, TaskRecipient, TaskRecipientType, is_low_code_app
2121

2222

2323
def _ensure_string_value(value: Any) -> str:
@@ -39,6 +39,8 @@ def _create_spec(
3939
is_actionable_message_enabled: Optional[bool] = None,
4040
actionable_message_metadata: Optional[Dict[str, Any]] = None,
4141
source_name: str = "Agent",
42+
app_project_key: Optional[str] = None,
43+
app_type: Optional[str] = None,
4244
) -> RequestSpec:
4345
field_list = []
4446
outcome_list = []
@@ -94,7 +96,7 @@ def _create_spec(
9496
)
9597

9698
json_payload: Dict[str, Any] = {
97-
"appId": app_key,
99+
"appId": app_key if app_key is not None else f"{uuid.UUID(int=0).hex}",
98100
"title": title,
99101
"data": data if data is not None else {},
100102
"actionableMessageMetaData": actionable_message_metadata
@@ -119,11 +121,14 @@ def _create_spec(
119121
),
120122
}
121123

124+
if app_project_key is not None:
125+
json_payload["appType"] = app_type
126+
json_payload["appProjectKey"] = app_project_key
127+
122128
_apply_priority_labels_and_actionable_toggle(
123129
json_payload, priority, labels, is_actionable_message_enabled
124130
)
125131
_apply_task_source(json_payload, source_name)
126-
127132
return RequestSpec(
128133
method="POST",
129134
endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"),
@@ -159,14 +164,18 @@ def _apply_priority_labels_and_actionable_toggle(
159164
payload["isActionableMessageEnabled"] = is_actionable_message_enabled
160165

161166

162-
def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None:
167+
def _apply_task_source(
168+
payload: Dict[str, Any],
169+
source_name: str,
170+
) -> None:
163171
"""Populate ``payload["taskSource"]`` when UiPathConfig has project_id + trace_id.
164172
165173
Shared between AppTask and QuickForm spec builders — the taskSource block is
166174
identical for both task types.
167175
"""
168176
project_id = UiPathConfig.project_id
169177
trace_id = UiPathConfig.trace_id
178+
solution_id = UiPathConfig.studio_solution_id
170179
if not (project_id and trace_id):
171180
return
172181
payload["taskSource"] = {
@@ -178,8 +187,12 @@ def _apply_task_source(payload: Dict[str, Any], source_name: str) -> None:
178187
"JobKey": UiPathConfig.job_key,
179188
"ProcessKey": UiPathConfig.process_uuid,
180189
},
190+
"jobId": UiPathConfig.job_key
181191
}
182192

193+
if UiPathConfig.is_rooted_to_debug_job:
194+
payload["taskSource"]["isDebug"] = True
195+
payload["taskSource"]["solutionId"] = solution_id
183196

184197
def _normalize_priority(priority: str | None) -> str | None:
185198
"""Normalize priority string to match API expectations.
@@ -459,6 +472,9 @@ async def create_async(
459472
is_actionable_message_enabled: Optional[bool] = None,
460473
actionable_message_metadata: Optional[Dict[str, Any]] = None,
461474
source_name: str = "Agent",
475+
app_project_key: Optional[str] = None,
476+
app_type: Optional[str] = None,
477+
action_schema: Optional[Dict[str, Any]] = None,
462478
) -> Task:
463479
"""Creates a new action asynchronously.
464480
@@ -478,34 +494,48 @@ async def create_async(
478494
is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task
479495
actionable_message_metadata: Optional metadata for the action
480496
source_name: The name of the source that created the task. Defaults to 'Agent'.
497+
app_project_key: Optional project key of the app. Used for JIT (debug) task creation so
498+
Orchestrator can resolve a not-yet-deployed app.
499+
app_type: Optional app type ("Custom" or "Coded"), forwarded for JIT (debug) task creation.
481500
482501
Returns:
483502
Action: The created action object
484503
485504
Raises:
486505
Exception: If neither app_name nor app_key is provided for app-specific actions
487506
"""
488-
(key, action_schema) = (
489-
(app_key, None)
490-
if app_key
491-
else await self._get_app_key_and_schema_async(
507+
key: Optional[str]
508+
schema: Optional[TaskSchema]
509+
if app_project_key and is_low_code_app(app_type) and action_schema is not None:
510+
key = app_key
511+
schema = TaskSchema(
512+
key=action_schema["key"],
513+
in_outs=action_schema["inOuts"],
514+
inputs=action_schema["inputs"],
515+
outputs=action_schema["outputs"],
516+
outcomes=action_schema["outcomes"],
517+
)
518+
elif app_key:
519+
key, schema = app_key, None
520+
else:
521+
key, schema = await self._get_app_key_and_schema_async(
492522
app_name, app_folder_path, app_folder_key
493523
)
494-
)
495524
spec = _create_spec(
496525
title=title,
497526
data=data,
498527
app_key=key,
499-
action_schema=action_schema,
528+
action_schema=schema,
500529
app_folder_key=app_folder_key,
501530
app_folder_path=app_folder_path,
502531
priority=priority,
503532
labels=labels,
504533
is_actionable_message_enabled=is_actionable_message_enabled,
505534
actionable_message_metadata=actionable_message_metadata,
506535
source_name=source_name,
536+
app_project_key=app_project_key,
537+
app_type=app_type,
507538
)
508-
509539
response = await self.request_async(
510540
spec.method,
511541
spec.endpoint,
@@ -545,49 +575,51 @@ def create(
545575
is_actionable_message_enabled: Optional[bool] = None,
546576
actionable_message_metadata: Optional[Dict[str, Any]] = None,
547577
source_name: str = "Agent",
578+
app_project_key: Optional[str] = None,
579+
app_type: Optional[str] = None,
580+
action_schema: Optional[Dict[str, Any]] = None,
548581
) -> Task:
549582
"""Creates a new task synchronously.
550583
551-
This method creates a new action task in UiPath Orchestrator. The action can be
552-
either app-specific (using app_name or app_key) or a generic action.
553-
554-
Args:
555-
title: The title of the action
556-
data: Optional dictionary containing input data for the action
557-
app_name: The name of the application (if creating an app-specific action)
558-
app_key: The key of the application (if creating an app-specific action)
559-
app_folder_path: Optional folder path for the action
560-
app_folder_key: Optional folder key for the action
561-
assignee: Optional username or email to assign the task to
562-
priority: Optional priority of the task
563-
labels: Optional list of labels for the task
564-
is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task
565-
actionable_message_metadata: Optional metadata for the action
566-
source_name: The name of the source that created the task. Defaults to 'Agent'.
584+
See :meth:`create_async` for parameter docs.
567585
568586
Returns:
569587
Action: The created action object
570588
571589
Raises:
572590
Exception: If neither app_name nor app_key is provided for app-specific actions
573591
"""
574-
(key, action_schema) = (
575-
(app_key, None)
576-
if app_key
577-
else self._get_app_key_and_schema(app_name, app_folder_path, app_folder_key)
578-
)
592+
key: Optional[str]
593+
schema: Optional[TaskSchema]
594+
if app_project_key and is_low_code_app(app_type) and action_schema is not None:
595+
key = app_key
596+
schema = TaskSchema(
597+
key=action_schema["key"],
598+
in_outs=action_schema["inOuts"],
599+
inputs=action_schema["inputs"],
600+
outputs=action_schema["outputs"],
601+
outcomes=action_schema["outcomes"],
602+
)
603+
elif app_key:
604+
key, schema = app_key, None
605+
else:
606+
key, schema = self._get_app_key_and_schema(
607+
app_name, app_folder_path, app_folder_key
608+
)
579609
spec = _create_spec(
580610
title=title,
581611
data=data,
582612
app_key=key,
583-
action_schema=action_schema,
613+
action_schema=schema,
584614
app_folder_key=app_folder_key,
585615
app_folder_path=app_folder_path,
586616
priority=priority,
587617
labels=labels,
588618
is_actionable_message_enabled=is_actionable_message_enabled,
589619
actionable_message_metadata=actionable_message_metadata,
590620
source_name=source_name,
621+
app_project_key=app_project_key,
622+
app_type=app_type,
591623
)
592624

593625
response = self.request(

packages/uipath-platform/src/uipath/platform/action_center/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66

77
from pydantic import BaseModel, ConfigDict, Field, field_serializer
88

9+
APP_TYPE_LOW_CODE = "Custom"
10+
11+
def is_low_code_app(app_type: str | None) -> bool:
12+
"""Return True when ``app_type`` denotes a low-code (custom) app."""
13+
return app_type == APP_TYPE_LOW_CODE
14+
915

1016
class TaskStatus(enum.IntEnum):
1117
"""Enum representing possible Task status."""

packages/uipath-platform/src/uipath/platform/common/_config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class UiPathApiConfig(BaseModel):
1414
class ConfigurationManager:
1515
_instance = None
1616
studio_solution_id: str | None = None
17+
_is_debug_run_override: bool | None = None
1718

1819
def __new__(cls):
1920
if cls._instance is None:
@@ -194,8 +195,15 @@ def licensing_context(self) -> str | None:
194195
@property
195196
def is_rooted_to_debug_job(self) -> bool:
196197
"""Whether this job, which may be a deployed process, is rooted to a debug session (e.g. Maestro solution debug)."""
198+
if self._is_debug_run_override is not None:
199+
return self._is_debug_run_override
197200
return self._read_internal_argument("isDebug") is True
198201

202+
@is_rooted_to_debug_job.setter
203+
def is_rooted_to_debug_job(self, value: bool) -> None:
204+
"""Override the debug-run status resolved at runtime."""
205+
self._is_debug_run_override = value
206+
199207
@property
200208
def is_tracing_enabled(self) -> bool:
201209
from uipath.platform.constants import ENV_TRACING_ENABLED
@@ -205,6 +213,7 @@ def is_tracing_enabled(self) -> bool:
205213
def reset(self) -> None:
206214
"""Reset mutable cached state to defaults."""
207215
self.studio_solution_id = None
216+
self._is_debug_run_override = None
208217
# Invalidate cached_property by removing from instance __dict__
209218
self.__dict__.pop("_internal_arguments", None)
210219

packages/uipath-platform/src/uipath/platform/orchestrator/job.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,5 @@ class Job(BaseModel):
8080
has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings")
8181
job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError")
8282
folder_key: Optional[str] = Field(default=None, alias="FolderKey")
83+
parent_context: Optional[str] = Field(default=None, alias="ParentContext")
8384
id: int = Field(alias="Id")

packages/uipath-platform/tests/common/test_config_env_vars.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,39 @@ def test_has_eval_folder(self, monkeypatch, tmp_path):
190190
assert UiPathConfig.has_eval_folder is False
191191
(tmp_path / EVALS_FOLDER).mkdir()
192192
assert UiPathConfig.has_eval_folder is True
193+
194+
195+
class TestIsRootedToDebugJob:
196+
@pytest.fixture(autouse=True)
197+
def _reset_singleton(self):
198+
# is_rooted_to_debug_job mutates the process-wide singleton; reset around
199+
# each test so nothing leaks between tests.
200+
UiPathConfig.reset()
201+
yield
202+
UiPathConfig.reset()
203+
204+
def test_defaults_to_false_when_unset(self):
205+
assert UiPathConfig.is_rooted_to_debug_job is False
206+
207+
def test_reads_is_debug_internal_argument(self):
208+
UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True}
209+
assert UiPathConfig.is_rooted_to_debug_job is True
210+
211+
def test_setter_override_wins_and_is_true(self):
212+
UiPathConfig.is_rooted_to_debug_job = True
213+
assert UiPathConfig.is_rooted_to_debug_job is True
214+
215+
def test_setter_override_beats_internal_argument(self):
216+
# Even when the internal argument says True, an explicit False override wins.
217+
UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True}
218+
UiPathConfig.is_rooted_to_debug_job = False
219+
assert UiPathConfig.is_rooted_to_debug_job is False
220+
221+
def test_reset_clears_override(self):
222+
UiPathConfig.__dict__["_internal_arguments"] = {"isDebug": True}
223+
UiPathConfig.is_rooted_to_debug_job = False
224+
assert UiPathConfig.is_rooted_to_debug_job is False
225+
UiPathConfig.reset()
226+
# Override cleared, so it falls back to the internal argument again — but
227+
# reset() also drops the cached internal arguments, so it re-reads (None).
228+
assert UiPathConfig.is_rooted_to_debug_job is False

0 commit comments

Comments
 (0)