Skip to content

Commit bda3cab

Browse files
authored
feat: add wait until interrupt model (#1768)
1 parent 68e4e8a commit bda3cab

8 files changed

Lines changed: 133 additions & 33 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.81"
3+
version = "0.1.82"
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"
77
dependencies = [
88
"httpx>=0.28.1",
99
"tenacity>=9.0.0",
1010
"truststore>=0.10.1",
11-
"uipath-core>=0.5.25, <0.6.0",
11+
"uipath-core>=0.5.26, <0.6.0",
1212
"pydantic-function-models>=0.1.11",
1313
"sqlparse>=0.5.5",
1414
]

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
WaitJobRaw,
6161
WaitSystemAgent,
6262
WaitTask,
63+
WaitUntil,
6364
)
6465
from .paging import PagedResult
6566

@@ -99,6 +100,7 @@
99100
"DocumentExtractionValidation",
100101
"WaitDocumentExtractionValidation",
101102
"WaitIntegrationEvent",
103+
"WaitUntil",
102104
"RequestSpec",
103105
"Endpoint",
104106
"UiPathUrl",

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Models for interrupt operations in UiPath platform."""
22

3+
from datetime import datetime, timezone
34
from typing import Annotated, Any
45

5-
from pydantic import BaseModel, ConfigDict, Field, model_validator
6+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
67

78
from uipath.platform.context_grounding.context_grounding_index import (
89
ContextGroundingIndex,
@@ -279,3 +280,19 @@ class WaitIntegrationEvent(BaseModel):
279280
object_name: str
280281
filter_expression: str | None = None
281282
parameters: dict[str, str] | None = None
283+
284+
285+
class WaitUntil(BaseModel):
286+
"""Model representing a wait until an absolute point in time."""
287+
288+
resume_time: datetime = Field(alias="resumeTime")
289+
290+
model_config = ConfigDict(validate_by_name=True)
291+
292+
@field_validator("resume_time")
293+
@classmethod
294+
def validate_resume_time(cls, value: datetime) -> datetime:
295+
"""Validate and normalize resume_time to a UTC instant."""
296+
if value.tzinfo is None or value.utcoffset() is None:
297+
raise ValueError("resume_time must include timezone information")
298+
return value.astimezone(timezone.utc)

packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import os
55
import uuid
6+
from functools import cache
67
from typing import Any
78

89
from uipath.core.errors import (
@@ -49,6 +50,7 @@
4950
WaitJobRaw,
5051
WaitSystemAgent,
5152
WaitTask,
53+
WaitUntil,
5254
)
5355
from uipath.platform.connections import EventArguments
5456
from uipath.platform.context_grounding import DeepRagStatus, IndexStatus
@@ -129,12 +131,18 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
129131
UiPathRuntimeError: If reading fails, job failed, API connection failed,
130132
trigger type is unknown, or HITL feedback retrieval failed.
131133
"""
132-
uipath = UiPath()
134+
135+
@cache
136+
def get_uipath() -> UiPath:
137+
return UiPath()
133138

134139
match trigger.trigger_type:
140+
case UiPathResumeTriggerType.TIMER:
141+
return {"resumeTime": serialize_object(trigger.resume_time)}
142+
135143
case UiPathResumeTriggerType.TASK:
136144
if trigger.item_key:
137-
task: Task = await uipath.tasks.retrieve_async(
145+
task: Task = await get_uipath().tasks.retrieve_async(
138146
trigger.item_key,
139147
app_folder_key=trigger.folder_key,
140148
app_folder_path=trigger.folder_path,
@@ -182,7 +190,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
182190

183191
case UiPathResumeTriggerType.JOB:
184192
if trigger.item_key:
185-
job = await uipath.jobs.retrieve_async(
193+
job = await get_uipath().jobs.retrieve_async(
186194
trigger.item_key,
187195
folder_key=trigger.folder_key,
188196
folder_path=trigger.folder_path,
@@ -223,7 +231,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
223231
f"Process did not finish successfully. Error: {job_error}",
224232
)
225233

226-
output_data = await uipath.jobs.extract_output_async(job)
234+
output_data = await get_uipath().jobs.extract_output_async(job)
227235
trigger_response = _try_convert_to_json_format(output_data)
228236

229237
# if response is an empty dictionary, use job state as placeholder value
@@ -239,9 +247,13 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
239247
return trigger_response
240248
case UiPathResumeTriggerType.DEEP_RAG:
241249
if trigger.item_key:
242-
deep_rag = await uipath.context_grounding.retrieve_deep_rag_async(
243-
trigger.item_key,
244-
index_name=self._extract_field("index_name", trigger.payload),
250+
deep_rag = (
251+
await get_uipath().context_grounding.retrieve_deep_rag_async(
252+
trigger.item_key,
253+
index_name=self._extract_field(
254+
"index_name", trigger.payload
255+
),
256+
)
245257
)
246258
deep_rag_status = deep_rag.last_deep_rag_status
247259

@@ -279,7 +291,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
279291

280292
case UiPathResumeTriggerType.INDEX_INGESTION:
281293
if trigger.item_key:
282-
index = await uipath.context_grounding.retrieve_by_id_async(
294+
index = await get_uipath().context_grounding.retrieve_by_id_async(
283295
trigger.item_key
284296
)
285297

@@ -319,7 +331,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
319331
)
320332
assert destination_path is not None
321333
try:
322-
await uipath.context_grounding.download_batch_transform_result_async(
334+
await get_uipath().context_grounding.download_batch_transform_result_async(
323335
trigger.item_key,
324336
destination_path,
325337
validate_status=True,
@@ -349,10 +361,8 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
349361
assert tag is not None
350362

351363
try:
352-
extraction_response = (
353-
await uipath.documents.retrieve_ixp_extraction_result_async(
354-
project_id, tag, trigger.item_key
355-
)
364+
extraction_response = await get_uipath().documents.retrieve_ixp_extraction_result_async(
365+
project_id, tag, trigger.item_key
356366
)
357367
except OperationNotCompleteException as e:
358368
raise UiPathPendingTriggerError(
@@ -370,7 +380,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
370380
assert project_id is not None
371381
assert tag is not None
372382
try:
373-
escalation_response = await uipath.documents.retrieve_ixp_extraction_validation_result_async(
383+
escalation_response = await get_uipath().documents.retrieve_ixp_extraction_validation_result_async(
374384
project_id, tag, trigger.item_key
375385
)
376386
except OperationNotCompleteException as e:
@@ -394,7 +404,7 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
394404
case UiPathResumeTriggerType.API:
395405
if trigger.api_resume and trigger.api_resume.inbox_id:
396406
try:
397-
return await uipath.jobs.retrieve_api_payload_async(
407+
return await get_uipath().jobs.retrieve_api_payload_async(
398408
trigger.api_resume.inbox_id
399409
)
400410
except Exception as e:
@@ -407,12 +417,16 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None:
407417
case UiPathResumeTriggerType.INBOX:
408418
if trigger.integration_resume and trigger.integration_resume.inbox_id:
409419
try:
410-
inbox_payload = await uipath.jobs.retrieve_inbox_payload_async(
411-
trigger.integration_resume.inbox_id
420+
inbox_payload = (
421+
await get_uipath().jobs.retrieve_inbox_payload_async(
422+
trigger.integration_resume.inbox_id
423+
)
412424
)
413425
event_args = EventArguments.model_validate(inbox_payload)
414-
return await uipath.connections.retrieve_event_payload_async(
415-
event_args
426+
return (
427+
await get_uipath().connections.retrieve_event_payload_async(
428+
event_args
429+
)
416430
)
417431
except Exception as e:
418432
raise UiPathFaultedTriggerError(
@@ -484,6 +498,9 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
484498
case UiPathResumeTriggerType.INBOX:
485499
await self._handle_inbox_trigger(suspend_value, resume_trigger)
486500

501+
case UiPathResumeTriggerType.TIMER:
502+
self._handle_time_trigger(suspend_value, resume_trigger)
503+
487504
case UiPathResumeTriggerType.DEEP_RAG:
488505
await self._handle_deep_rag_job_trigger(
489506
suspend_value, resume_trigger
@@ -570,6 +587,8 @@ def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType:
570587
return UiPathResumeTriggerType.IXP_VS_ESCALATION
571588
if isinstance(value, WaitIntegrationEvent):
572589
return UiPathResumeTriggerType.INBOX
590+
if isinstance(value, WaitUntil):
591+
return UiPathResumeTriggerType.TIMER
573592
# default to API trigger
574593
return UiPathResumeTriggerType.API
575594

@@ -606,6 +625,8 @@ def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName:
606625
return UiPathResumeTriggerName.EXTRACTION
607626
if isinstance(value, WaitIntegrationEvent):
608627
return UiPathResumeTriggerName.INBOX
628+
if isinstance(value, WaitUntil):
629+
return UiPathResumeTriggerName.TIMER
609630
# default to API trigger
610631
return UiPathResumeTriggerName.API
611632

@@ -979,6 +1000,20 @@ async def _handle_inbox_trigger(
9791000
inbox_id=str(uuid.uuid4()),
9801001
)
9811002

1003+
def _handle_time_trigger(
1004+
self, value: WaitUntil, resume_trigger: UiPathResumeTrigger
1005+
) -> None:
1006+
"""Handle Timer-type resume triggers.
1007+
1008+
Orchestrator expects timer resume triggers as a top-level
1009+
`resumeTime` value on the resume trigger DTO.
1010+
1011+
Args:
1012+
value: The suspend value (WaitUntil)
1013+
resume_trigger: The resume trigger to populate
1014+
"""
1015+
resume_trigger.resume_time = value.resume_time
1016+
9821017

9831018
class UiPathResumeTriggerHandler:
9841019
"""Combined handler for creating and reading resume triggers.

packages/uipath-platform/tests/services/test_hitl.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import json
22
import uuid
3+
from datetime import datetime, timedelta, timezone
34
from typing import Any
45
from unittest.mock import AsyncMock, patch
56

67
import pytest
78
from pytest_httpx import HTTPXMock
89
from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError
10+
from uipath.core.serialization import serialize_object
911
from uipath.core.triggers import (
1012
UiPathApiTrigger,
1113
UiPathIntegrationTrigger,
@@ -39,6 +41,7 @@
3941
WaitJobRaw,
4042
WaitSystemAgent,
4143
WaitTask,
44+
WaitUntil,
4245
)
4346
from uipath.platform.connections import Connection
4447
from uipath.platform.context_grounding import (
@@ -1214,6 +1217,21 @@ async def test_read_ixp_vs_escalation_trigger_unassigned(
12141217
reader = UiPathResumeTriggerReader()
12151218
await reader.read_trigger(resume_trigger)
12161219

1220+
@pytest.mark.anyio
1221+
async def test_read_timer_trigger_serializes_resume_time(self) -> None:
1222+
"""Test reading a timer trigger returns JSON-safe resume time data."""
1223+
resume_time = datetime(2026, 6, 27, 20, 14, 49, tzinfo=timezone.utc)
1224+
resume_trigger = UiPathResumeTrigger(
1225+
trigger_type=UiPathResumeTriggerType.TIMER,
1226+
trigger_name=UiPathResumeTriggerName.TIMER,
1227+
resume_time=resume_time,
1228+
)
1229+
1230+
reader = UiPathResumeTriggerReader()
1231+
result = await reader.read_trigger(resume_trigger)
1232+
1233+
assert result == {"resumeTime": serialize_object(resume_time)}
1234+
12171235

12181236
class TestHitlProcessor:
12191237
"""Tests for the HitlProcessor class."""
@@ -2063,6 +2081,34 @@ async def test_create_resume_trigger_wait_document_extraction_validation(
20632081
assert resume_trigger.trigger_type == UiPathResumeTriggerType.IXP_VS_ESCALATION
20642082
assert resume_trigger.item_key == operation_id
20652083

2084+
@pytest.mark.anyio
2085+
async def test_create_resume_trigger_wait_until_normalizes_to_utc(self) -> None:
2086+
"""Test creating a timer resume trigger for WaitUntil."""
2087+
local_resume_time = datetime(
2088+
2026,
2089+
6,
2090+
27,
2091+
23,
2092+
14,
2093+
49,
2094+
tzinfo=timezone(timedelta(hours=3)),
2095+
)
2096+
wait_until = WaitUntil(resume_time=local_resume_time)
2097+
2098+
processor = UiPathResumeTriggerCreator()
2099+
resume_trigger = await processor.create_trigger(wait_until)
2100+
2101+
assert resume_trigger.trigger_type == UiPathResumeTriggerType.TIMER
2102+
assert resume_trigger.trigger_name == UiPathResumeTriggerName.TIMER
2103+
assert resume_trigger.resume_time == datetime(
2104+
2026, 6, 27, 20, 14, 49, tzinfo=timezone.utc
2105+
)
2106+
2107+
def test_wait_until_requires_timezone_aware_resume_time(self) -> None:
2108+
"""Test WaitUntil rejects timezone-naive resume times."""
2109+
with pytest.raises(ValueError, match="resume_time must include timezone"):
2110+
WaitUntil(resume_time=datetime(2026, 6, 27, 20, 14, 49))
2111+
20662112

20672113
class TestDocumentExtractionModels:
20682114
"""Tests for document extraction models."""

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath"
3-
version = "2.11.15"
3+
version = "2.11.16"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-core>=0.5.21, <0.6.0",
9-
"uipath-runtime>=0.11.4, <0.12.0",
10-
"uipath-platform>=0.1.81, <0.2.0",
8+
"uipath-core>=0.5.26, <0.6.0",
9+
"uipath-runtime>=0.11.5, <0.12.0",
10+
"uipath-platform>=0.1.82, <0.2.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",

0 commit comments

Comments
 (0)