Skip to content

Commit f6eb7e7

Browse files
authored
Merge pull request #84 from UiPath/feat/resume-runtime-on-fired-triggers
fix: assign fired_triggers var, avoid checking api triggers
2 parents 9d5eeed + 65477a9 commit f6eb7e7

File tree

4 files changed

+121
-8
lines changed

4 files changed

+121
-8
lines changed

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.8.3"
3+
version = "0.8.4"
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/runtime.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
UiPathRuntimeResult,
1717
UiPathRuntimeStatus,
1818
)
19+
from uipath.runtime.resumable import UiPathResumeTriggerType
1920
from uipath.runtime.resumable.protocols import (
2021
UiPathResumableStorageProtocol,
2122
UiPathResumeTriggerProtocol,
@@ -82,8 +83,11 @@ async def execute(
8283
suspension_result = await self._handle_suspension(result)
8384

8485
# check if any trigger may be resumed
86+
# api triggers cannot be completed before suspending the job, skip them
8587
if suspension_result.status != UiPathRuntimeStatus.SUSPENDED or not (
86-
fired_triggers := await self._restore_resume_input(None)
88+
fired_triggers := await self._restore_resume_input(
89+
None, skip_trigger_types=[UiPathResumeTriggerType.API]
90+
)
8791
):
8892
return suspension_result
8993

@@ -115,6 +119,7 @@ async def stream(
115119

116120
final_result: UiPathRuntimeResult | None = None
117121
execution_completed = False
122+
fired_triggers = None
118123

119124
while not execution_completed:
120125
async for event in self.delegate.stream(input, options=options):
@@ -127,8 +132,12 @@ async def stream(
127132
if final_result:
128133
suspension_result = await self._handle_suspension(final_result)
129134

135+
# check if any trigger may be resumed
136+
# api triggers cannot be completed before suspending the job, skip them
130137
if suspension_result.status != UiPathRuntimeStatus.SUSPENDED or not (
131-
fired_triggers := await self._restore_resume_input(None)
138+
fired_triggers := await self._restore_resume_input(
139+
None, skip_trigger_types=[UiPathResumeTriggerType.API]
140+
)
132141
):
133142
yield suspension_result
134143
execution_completed = True
@@ -143,7 +152,9 @@ async def stream(
143152
options.resume = True
144153

145154
async def _restore_resume_input(
146-
self, input: dict[str, Any] | None
155+
self,
156+
input: dict[str, Any] | None,
157+
skip_trigger_types: list[UiPathResumeTriggerType] | None = None,
147158
) -> dict[str, Any] | None:
148159
"""Restore resume input from storage if not provided.
149160
@@ -180,14 +191,18 @@ async def _restore_resume_input(
180191
if not triggers:
181192
return None
182193

183-
return await self._build_resume_map(triggers)
194+
return await self._build_resume_map(triggers, skip_trigger_types)
184195

185196
async def _build_resume_map(
186-
self, triggers: list[UiPathResumeTrigger]
197+
self,
198+
triggers: list[UiPathResumeTrigger],
199+
skip_trigger_types: list[UiPathResumeTriggerType] | None,
187200
) -> dict[str, Any]:
188201
# Build resume map: {interrupt_id: resume_data}
189202
resume_map: dict[str, Any] = {}
190203
for trigger in triggers:
204+
if skip_trigger_types and trigger.trigger_type in skip_trigger_types:
205+
continue
191206
try:
192207
data = await self.trigger_manager.read_trigger(trigger)
193208
assert trigger.interrupt_id is not None, (

tests/test_resumable.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def make_trigger_manager_mock() -> UiPathResumeTriggerProtocol:
127127
def create_trigger_impl(data: dict[str, Any]) -> UiPathResumeTrigger:
128128
return UiPathResumeTrigger(
129129
interrupt_id="", # Will be set by resumable runtime
130-
trigger_type=UiPathResumeTriggerType.API,
130+
trigger_type=UiPathResumeTriggerType.TASK,
131131
payload=data,
132132
)
133133

@@ -453,3 +453,101 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
453453

454454
# Delegate should have been executed only once)
455455
assert runtime_impl.execution_count == 1
456+
457+
@pytest.mark.asyncio
458+
async def test_resumable_skips_api_triggers_on_auto_resume_check(self) -> None:
459+
"""API triggers should be skipped when checking for auto-resume after suspension."""
460+
461+
runtime_impl = MultiTriggerMockRuntime()
462+
storage = StatefulStorageMock()
463+
trigger_manager = make_trigger_manager_mock()
464+
465+
# Create trigger manager that returns API trigger type
466+
def create_api_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
467+
return UiPathResumeTrigger(
468+
interrupt_id="", # Will be set by resumable runtime
469+
trigger_type=UiPathResumeTriggerType.API,
470+
payload=data,
471+
)
472+
473+
trigger_manager.create_trigger = AsyncMock(side_effect=create_api_trigger) # type: ignore
474+
475+
async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
476+
return {"approved": True}
477+
478+
trigger_manager.read_trigger = AsyncMock(side_effect=read_trigger_impl) # type: ignore
479+
480+
resumable = UiPathResumableRuntime(
481+
delegate=runtime_impl,
482+
storage=storage,
483+
trigger_manager=trigger_manager,
484+
runtime_id="runtime-1",
485+
)
486+
487+
# Execute - should suspend and NOT auto-resume because they are API triggers
488+
result = await resumable.execute({})
489+
490+
assert result.status == UiPathRuntimeStatus.SUSPENDED
491+
assert result.triggers is not None
492+
assert len(result.triggers) == 2
493+
assert {t.interrupt_id for t in result.triggers} == {"int-1", "int-2"}
494+
495+
# Verify all triggers are API type
496+
assert all(
497+
t.trigger_type == UiPathResumeTriggerType.API for t in result.triggers
498+
)
499+
500+
# Delegate should have been executed only once (no auto-resume)
501+
assert runtime_impl.execution_count == 1
502+
503+
@pytest.mark.asyncio
504+
async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers(
505+
self,
506+
) -> None:
507+
"""Mixed triggers: TASK triggers should trigger auto-resume, API triggers should not."""
508+
509+
runtime_impl = MultiTriggerMockRuntime()
510+
storage = StatefulStorageMock()
511+
trigger_manager = make_trigger_manager_mock()
512+
513+
# Create different trigger types: int-1 is TASK, int-2 is API
514+
def create_typed_trigger(data: dict[str, Any]) -> UiPathResumeTrigger:
515+
# Determine trigger type based on payload action
516+
if "approve_branch_1" in str(data):
517+
trigger_type = UiPathResumeTriggerType.TASK
518+
else:
519+
trigger_type = UiPathResumeTriggerType.API
520+
521+
return UiPathResumeTrigger(
522+
interrupt_id="", # Will be set by resumable runtime
523+
trigger_type=trigger_type,
524+
payload=data,
525+
)
526+
527+
trigger_manager.create_trigger = AsyncMock(side_effect=create_typed_trigger) # type: ignore
528+
529+
# only TASK should trigger auto-resume
530+
async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]:
531+
return {"approved": True}
532+
533+
trigger_manager.read_trigger = AsyncMock(side_effect=read_trigger_impl) # type: ignore
534+
535+
resumable = UiPathResumableRuntime(
536+
delegate=runtime_impl,
537+
storage=storage,
538+
trigger_manager=trigger_manager,
539+
runtime_id="runtime-1",
540+
)
541+
542+
# Execute - should auto-resume based on int-1 (TASK) but skip int-2 (API)
543+
result = await resumable.execute({})
544+
545+
# Should have auto-resumed once (because of TASK trigger)
546+
assert result.status == UiPathRuntimeStatus.SUSPENDED
547+
assert result.triggers is not None
548+
549+
# After auto-resume with int-1, should be at second suspension with int-2 and int-3
550+
assert {t.interrupt_id for t in result.triggers} == {"int-2", "int-3"}
551+
552+
# Delegate should have been executed twice (initial + auto-resume for TASK trigger)
553+
assert runtime_impl.execution_count == 2

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.

0 commit comments

Comments
 (0)