Skip to content

Commit fd7b080

Browse files
committed
feat: support multiple resume triggers per interrupt
1 parent ba64133 commit fd7b080

5 files changed

Lines changed: 193 additions & 22 deletions

File tree

‎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-runtime"
3-
version = "0.11.5"
3+
version = "0.12.0"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

‎src/uipath/runtime/resumable/protocols.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger:
6868
"""
6969
...
7070

71+
async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]:
72+
"""Create resume triggers from a suspend value.
73+
74+
Most suspend values produce one trigger. Composite values may produce
75+
multiple sibling triggers for the same interrupt.
76+
77+
Args:
78+
suspend_value: The value that caused the suspension.
79+
80+
Returns:
81+
UiPathResumeTrigger objects ready to be persisted.
82+
83+
Raises:
84+
UiPathRuntimeError: If trigger creation fails
85+
"""
86+
...
87+
7188

7289
class UiPathResumeTriggerReaderProtocol(Protocol):
7390
"""Protocol for reading resume triggers and converting them to runtime input."""

‎src/uipath/runtime/resumable/runtime.py‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Resumable runtime protocol and implementation."""
22

33
import logging
4+
from collections import Counter
5+
from enum import Enum
46
from typing import Any, AsyncGenerator
57

68
from uipath.core.errors import UiPathPendingTriggerError
@@ -25,6 +27,8 @@
2527

2628
logger = logging.getLogger(__name__)
2729

30+
UIPATH_METADATA_KEY = "__uipath"
31+
2832

2933
class UiPathResumableRuntime:
3034
"""Generic runtime wrapper that adds resume trigger management to any runtime.
@@ -168,7 +172,7 @@ async def _get_fired_triggers(self) -> dict[str, Any] | None:
168172
UiPathResumeTriggerType.TIMER,
169173
)
170174
]
171-
return await self._build_resume_map(pollable_triggers)
175+
return await self._build_resume_map(pollable_triggers, all_triggers=triggers)
172176

173177
async def _restore_resume_input(
174178
self,
@@ -214,22 +218,32 @@ async def _restore_resume_input(
214218
async def _build_resume_map(
215219
self,
216220
triggers: list[UiPathResumeTrigger],
221+
all_triggers: list[UiPathResumeTrigger] | None = None,
217222
) -> dict[str, Any]:
218223
"""Build resume map from triggers: {interrupt_id: resume_data}.
219224
220225
Args:
221226
triggers: List of triggers to read and map
227+
all_triggers: Full trigger set to use when detecting sibling triggers.
222228
223229
Returns:
224230
A dict mapping interrupt_id to the trigger's resume data.
225231
"""
226232
resume_map: dict[str, Any] = {}
233+
trigger_count_by_interrupt_id = Counter(
234+
trigger.interrupt_id for trigger in all_triggers or triggers
235+
)
227236
for trigger in triggers:
237+
assert trigger.interrupt_id is not None, (
238+
"Trigger interrupt_id cannot be None"
239+
)
240+
if trigger.interrupt_id in resume_map:
241+
continue
242+
228243
try:
229244
data = await self.trigger_manager.read_trigger(trigger)
230-
assert trigger.interrupt_id is not None, (
231-
"Trigger interrupt_id cannot be None"
232-
)
245+
if trigger_count_by_interrupt_id[trigger.interrupt_id] > 1:
246+
data = self._with_trigger_metadata(trigger, data)
233247
resume_map[trigger.interrupt_id] = data
234248
await self.storage.delete_trigger(self.runtime_id, trigger)
235249
except UiPathPendingTriggerError:
@@ -238,6 +252,26 @@ async def _build_resume_map(
238252

239253
return resume_map
240254

255+
def _with_trigger_metadata(
256+
self, trigger: UiPathResumeTrigger, data: Any
257+
) -> dict[str, Any]:
258+
metadata = {
259+
"triggerType": self._metadata_value(trigger.trigger_type),
260+
"triggerName": self._metadata_value(trigger.trigger_name),
261+
}
262+
263+
if isinstance(data, dict):
264+
return {**data, UIPATH_METADATA_KEY: metadata}
265+
266+
return {UIPATH_METADATA_KEY: metadata, "value": data}
267+
268+
@staticmethod
269+
def _metadata_value(value: Any) -> Any:
270+
if isinstance(value, Enum):
271+
return value.value
272+
273+
return value
274+
241275
async def _handle_suspension(
242276
self, result: UiPathRuntimeResult
243277
) -> UiPathRuntimeResult:
@@ -273,11 +307,12 @@ async def _handle_suspension(
273307

274308
# Create triggers only for new interrupts
275309
for interrupt_id in new_ids:
276-
trigger = await self.trigger_manager.create_trigger(
310+
triggers = await self.trigger_manager.create_triggers(
277311
current_interrupts[interrupt_id]
278312
)
279-
trigger.interrupt_id = interrupt_id
280-
suspended_result.triggers.append(trigger)
313+
for trigger in triggers:
314+
trigger.interrupt_id = interrupt_id
315+
suspended_result.triggers.append(trigger)
281316

282317
if suspended_result.triggers:
283318
await self.storage.save_triggers(self.runtime_id, suspended_result.triggers)

‎tests/test_resumable.py‎

Lines changed: 132 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from uipath.runtime.schema import UiPathRuntimeSchema
2424

2525

26-
class MultiTriggerMockRuntime:
26+
class SiblingTriggerMockRuntime:
2727
"""Mock runtime that simulates parallel branching with multiple interrupts."""
2828

2929
def __init__(self) -> None:
@@ -89,6 +89,56 @@ async def get_schema(self) -> UiPathRuntimeSchema:
8989
raise NotImplementedError()
9090

9191

92+
class MultiTriggerMockRuntime:
93+
"""Mock runtime that suspends on a multi-trigger interrupt, then suspends again."""
94+
95+
def __init__(self) -> None:
96+
self.execution_count = 0
97+
98+
async def dispose(self) -> None:
99+
pass
100+
101+
async def execute(
102+
self,
103+
input: dict[str, Any] | None = None,
104+
options: UiPathExecuteOptions | None = None,
105+
) -> UiPathRuntimeResult:
106+
self.execution_count += 1
107+
is_resume = options and options.resume
108+
109+
if self.execution_count == 1:
110+
return UiPathRuntimeResult(
111+
status=UiPathRuntimeStatus.SUSPENDED,
112+
output={"child-1": {"process": "first-child"}},
113+
)
114+
115+
assert is_resume
116+
assert input == {
117+
"child-1": {
118+
"completed": True,
119+
"__uipath": {
120+
"triggerType": UiPathResumeTriggerType.JOB.value,
121+
"triggerName": "Unknown",
122+
},
123+
}
124+
}
125+
return UiPathRuntimeResult(
126+
status=UiPathRuntimeStatus.SUSPENDED,
127+
output={"child-2": {"process": "second-child"}},
128+
)
129+
130+
async def stream(
131+
self,
132+
input: dict[str, Any] | None = None,
133+
options: UiPathStreamOptions | None = None,
134+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
135+
result = await self.execute(input, options)
136+
yield result
137+
138+
async def get_schema(self) -> UiPathRuntimeSchema:
139+
raise NotImplementedError()
140+
141+
92142
class StatefulStorageMock:
93143
"""Stateful storage mock that tracks triggers."""
94144

@@ -134,6 +184,11 @@ async def read_trigger_default(trigger: UiPathResumeTrigger) -> dict[str, Any]:
134184
raise UiPathPendingTriggerError(ErrorCategory.USER, "Trigger not fired yet")
135185

136186
manager.create_trigger = AsyncMock(side_effect=create_trigger_impl)
187+
188+
async def create_triggers_impl(data: dict[str, Any]) -> list[UiPathResumeTrigger]:
189+
return [await manager.create_trigger(data)]
190+
191+
manager.create_triggers = AsyncMock(side_effect=create_triggers_impl)
137192
manager.read_trigger = AsyncMock(side_effect=read_trigger_default)
138193

139194
return cast(UiPathResumeTriggerProtocol, manager)
@@ -146,7 +201,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension(
146201
) -> None:
147202
"""First suspension with parallel branches should create multiple triggers."""
148203

149-
runtime_impl = MultiTriggerMockRuntime()
204+
runtime_impl = SiblingTriggerMockRuntime()
150205
storage = StatefulStorageMock()
151206
trigger_manager = make_trigger_manager_mock()
152207

@@ -179,7 +234,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension(
179234
async def test_resumable_adds_only_new_triggers_on_partial_resume(self) -> None:
180235
"""Partial resume should keep pending trigger and add only new ones."""
181236

182-
runtime_impl = MultiTriggerMockRuntime()
237+
runtime_impl = SiblingTriggerMockRuntime()
183238
storage = StatefulStorageMock()
184239
trigger_manager = make_trigger_manager_mock()
185240

@@ -222,7 +277,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
222277
async def test_resumable_completes_after_all_triggers_resolved(self) -> None:
223278
"""After all triggers resolved, execution should complete successfully."""
224279

225-
runtime_impl = MultiTriggerMockRuntime()
280+
runtime_impl = SiblingTriggerMockRuntime()
226281
storage = StatefulStorageMock()
227282
trigger_manager = make_trigger_manager_mock()
228283

@@ -269,7 +324,7 @@ async def read_trigger_impl_3(trigger: UiPathResumeTrigger) -> dict[str, Any]:
269324
async def test_resumable_auto_resumes_when_triggers_already_fired(self) -> None:
270325
"""When triggers are already fired during suspension, runtime should auto-resume."""
271326

272-
runtime_impl = MultiTriggerMockRuntime()
327+
runtime_impl = SiblingTriggerMockRuntime()
273328
storage = StatefulStorageMock()
274329
trigger_manager = make_trigger_manager_mock()
275330

@@ -308,7 +363,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
308363
async def test_resumable_auto_resumes_partial_fired_triggers(self) -> None:
309364
"""When only some triggers are fired during suspension, auto-resume with those."""
310365

311-
runtime_impl = MultiTriggerMockRuntime()
366+
runtime_impl = SiblingTriggerMockRuntime()
312367
storage = StatefulStorageMock()
313368
trigger_manager = make_trigger_manager_mock()
314369

@@ -344,7 +399,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
344399
async def test_resumable_auto_resumes_multiple_times(self) -> None:
345400
"""When triggers keep being fired immediately, keep auto-resuming until complete."""
346401

347-
runtime_impl = MultiTriggerMockRuntime()
402+
runtime_impl = SiblingTriggerMockRuntime()
348403
storage = StatefulStorageMock()
349404
trigger_manager = make_trigger_manager_mock()
350405

@@ -389,7 +444,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
389444
async def test_resumable_stream_auto_resumes_when_triggers_fired(self) -> None:
390445
"""Stream should auto-resume when triggers are already fired."""
391446

392-
runtime_impl = MultiTriggerMockRuntime()
447+
runtime_impl = SiblingTriggerMockRuntime()
393448
storage = StatefulStorageMock()
394449
trigger_manager = make_trigger_manager_mock()
395450

@@ -426,7 +481,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
426481
async def test_resumable_no_auto_resume_when_all_triggers_pending(self) -> None:
427482
"""When all triggers are pending, should NOT auto-resume."""
428483

429-
runtime_impl = MultiTriggerMockRuntime()
484+
runtime_impl = SiblingTriggerMockRuntime()
430485
storage = StatefulStorageMock()
431486
trigger_manager = make_trigger_manager_mock()
432487

@@ -457,7 +512,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
457512
async def test_resumable_skips_api_triggers_on_auto_resume_check(self) -> None:
458513
"""API triggers should be skipped when checking for auto-resume after suspension."""
459514

460-
runtime_impl = MultiTriggerMockRuntime()
515+
runtime_impl = SiblingTriggerMockRuntime()
461516
storage = StatefulStorageMock()
462517
trigger_manager = make_trigger_manager_mock()
463518

@@ -508,7 +563,7 @@ async def test_resumable_skips_inbox_triggers_on_auto_resume_check(self) -> None
508563
404 and fault the run. They should behave like API triggers here.
509564
"""
510565

511-
runtime_impl = MultiTriggerMockRuntime()
566+
runtime_impl = SiblingTriggerMockRuntime()
512567
storage = StatefulStorageMock()
513568
trigger_manager = make_trigger_manager_mock()
514569

@@ -552,7 +607,7 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
552607
async def test_resumable_skips_timer_triggers_on_auto_resume_check(self) -> None:
553608
"""Timer triggers should be skipped when checking for auto-resume."""
554609

555-
runtime_impl = MultiTriggerMockRuntime()
610+
runtime_impl = SiblingTriggerMockRuntime()
556611
storage = StatefulStorageMock()
557612
trigger_manager = make_trigger_manager_mock()
558613

@@ -595,7 +650,7 @@ async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers(
595650
) -> None:
596651
"""Mixed triggers: TASK triggers should trigger auto-resume, API triggers should not."""
597652

598-
runtime_impl = MultiTriggerMockRuntime()
653+
runtime_impl = SiblingTriggerMockRuntime()
599654
storage = StatefulStorageMock()
600655
trigger_manager = make_trigger_manager_mock()
601656

@@ -640,3 +695,67 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
640695

641696
# Delegate should have been executed twice (initial + auto-resume for TASK trigger)
642697
assert runtime_impl.execution_count == 2
698+
699+
@pytest.mark.asyncio
700+
async def test_resumable_removes_sibling_triggers_when_one_trigger_fires(
701+
self,
702+
) -> None:
703+
"""When one trigger fires, sibling triggers must not leak forward."""
704+
705+
runtime_impl = MultiTriggerMockRuntime()
706+
storage = StatefulStorageMock()
707+
trigger_manager = make_trigger_manager_mock()
708+
709+
async def create_sibling_triggers(
710+
data: dict[str, Any],
711+
) -> list[UiPathResumeTrigger]:
712+
return [
713+
UiPathResumeTrigger(
714+
interrupt_id="", # Will be set by resumable runtime
715+
trigger_type=UiPathResumeTriggerType.JOB,
716+
payload={"source": data["process"]},
717+
),
718+
UiPathResumeTrigger(
719+
interrupt_id="", # Will be set by resumable runtime
720+
trigger_type=UiPathResumeTriggerType.TIMER,
721+
payload={"source": data["process"]},
722+
),
723+
]
724+
725+
create_triggers_mock = AsyncMock(side_effect=create_sibling_triggers)
726+
cast(Any, trigger_manager).create_triggers = create_triggers_mock
727+
728+
async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
729+
if (
730+
trigger.interrupt_id == "child-1"
731+
and trigger.trigger_type == UiPathResumeTriggerType.JOB
732+
):
733+
return {"completed": True}
734+
raise UiPathPendingTriggerError(ErrorCategory.USER, "still pending")
735+
736+
read_trigger_mock = AsyncMock(side_effect=read_trigger_impl)
737+
cast(Any, trigger_manager).read_trigger = read_trigger_mock
738+
739+
resumable = UiPathResumableRuntime(
740+
delegate=runtime_impl,
741+
storage=storage,
742+
trigger_manager=trigger_manager,
743+
runtime_id="runtime-1",
744+
)
745+
746+
result = await resumable.execute({})
747+
748+
assert result.status == UiPathRuntimeStatus.SUSPENDED
749+
assert result.triggers is not None
750+
assert {t.interrupt_id for t in result.triggers} == {"child-2"}
751+
assert len(result.triggers) == 2
752+
assert {t.trigger_type for t in result.triggers} == {
753+
UiPathResumeTriggerType.JOB,
754+
UiPathResumeTriggerType.TIMER,
755+
}
756+
assert runtime_impl.execution_count == 2
757+
assert create_triggers_mock.await_count == 2
758+
assert read_trigger_mock.await_count == 2
759+
760+
saved_triggers = await storage.get_triggers("runtime-1")
761+
assert {t.interrupt_id for t in saved_triggers} == {"child-2"}

0 commit comments

Comments
 (0)