Skip to content

Commit 0b6df31

Browse files
authored
Merge pull request #87 from UiPath/feat/state-event-phase
feat: add phase lifecycle to UiPathRuntimeStateEvent
2 parents 8ff70cc + ebe845a commit 0b6df31

File tree

6 files changed

+174
-5
lines changed

6 files changed

+174
-5
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.5"
3+
version = "0.8.6"
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/events/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
All events inherit from UiPathRuntimeEvent and can be filtered by execution_id.
99
"""
1010

11-
from uipath.runtime.events.base import UiPathRuntimeEvent, UiPathRuntimeEventType
11+
from uipath.runtime.events.base import (
12+
UiPathRuntimeEvent,
13+
UiPathRuntimeEventType,
14+
UiPathRuntimeStatePhase,
15+
)
1216
from uipath.runtime.events.state import (
1317
UiPathRuntimeMessageEvent,
1418
UiPathRuntimeStateEvent,
@@ -18,6 +22,7 @@
1822
# Base
1923
"UiPathRuntimeEvent",
2024
"UiPathRuntimeEventType",
25+
"UiPathRuntimeStatePhase",
2126
# Runtime events
2227
"UiPathRuntimeStateEvent",
2328
"UiPathRuntimeMessageEvent",

src/uipath/runtime/events/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ class UiPathRuntimeEventType(str, Enum):
1515
RUNTIME_RESULT = "runtime_result"
1616

1717

18+
class UiPathRuntimeStatePhase(str, Enum):
19+
"""Lifecycle phase of a state event."""
20+
21+
STARTED = "started"
22+
UPDATED = "updated"
23+
COMPLETED = "completed"
24+
FAULTED = "faulted"
25+
26+
1827
class UiPathRuntimeEvent(BaseModel):
1928
"""Base class for all UiPath runtime events."""
2029

@@ -29,4 +38,4 @@ class UiPathRuntimeEvent(BaseModel):
2938
)
3039

3140

32-
__all__ = ["UiPathRuntimeEventType", "UiPathRuntimeEvent"]
41+
__all__ = ["UiPathRuntimeEventType", "UiPathRuntimeStatePhase", "UiPathRuntimeEvent"]

src/uipath/runtime/events/state.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
from pydantic import Field
66

7-
from uipath.runtime.events.base import UiPathRuntimeEvent, UiPathRuntimeEventType
7+
from uipath.runtime.events.base import (
8+
UiPathRuntimeEvent,
9+
UiPathRuntimeEventType,
10+
UiPathRuntimeStatePhase,
11+
)
812

913

1014
class UiPathRuntimeMessageEvent(UiPathRuntimeEvent):
@@ -69,6 +73,10 @@ class UiPathRuntimeStateEvent(UiPathRuntimeEvent):
6973
default=None,
7074
description="Fully qualified node name including subgraph hierarchy prefix",
7175
)
76+
phase: UiPathRuntimeStatePhase = Field(
77+
default=UiPathRuntimeStatePhase.UPDATED,
78+
description="Lifecycle phase: started, updated, or completed",
79+
)
7280
event_type: UiPathRuntimeEventType = Field(
7381
default=UiPathRuntimeEventType.RUNTIME_STATE, frozen=True
7482
)

tests/test_events.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from typing import Any, AsyncGenerator
2+
3+
import pytest
4+
5+
from uipath.runtime.base import UiPathStreamOptions
6+
from uipath.runtime.events import (
7+
UiPathRuntimeEvent,
8+
UiPathRuntimeStateEvent,
9+
UiPathRuntimeStatePhase,
10+
)
11+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
12+
13+
14+
def test_state_event_phase_defaults_to_updated() -> None:
15+
"""Phase should default to UPDATED for backward compatibility."""
16+
event = UiPathRuntimeStateEvent(payload={"messages": []})
17+
18+
assert event.phase == UiPathRuntimeStatePhase.UPDATED
19+
20+
21+
def test_state_event_phase_can_be_set_explicitly() -> None:
22+
"""Phase should accept any valid UiPathRuntimeStatePhase value."""
23+
for phase in UiPathRuntimeStatePhase:
24+
event = UiPathRuntimeStateEvent(payload={}, phase=phase)
25+
assert event.phase == phase
26+
27+
28+
class PhaseAwareMockRuntime:
29+
"""Mock runtime that emits started/updated/completed state events per node."""
30+
31+
def __init__(self, nodes: list[str], *, failing_node: str | None = None) -> None:
32+
self.nodes = nodes
33+
self.failing_node = failing_node
34+
35+
async def stream(
36+
self,
37+
input: dict[str, Any] | None = None,
38+
options: UiPathStreamOptions | None = None,
39+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
40+
for node in self.nodes:
41+
yield UiPathRuntimeStateEvent(
42+
node_name=node,
43+
payload={},
44+
phase=UiPathRuntimeStatePhase.STARTED,
45+
)
46+
47+
if node == self.failing_node:
48+
yield UiPathRuntimeStateEvent(
49+
node_name=node,
50+
payload={"error": f"{node} failed"},
51+
phase=UiPathRuntimeStatePhase.FAULTED,
52+
)
53+
yield UiPathRuntimeResult(
54+
status=UiPathRuntimeStatus.FAULTED,
55+
output={"error": f"{node} failed"},
56+
)
57+
return
58+
59+
yield UiPathRuntimeStateEvent(
60+
node_name=node,
61+
payload={"key": f"{node}_value"},
62+
phase=UiPathRuntimeStatePhase.UPDATED,
63+
)
64+
yield UiPathRuntimeStateEvent(
65+
node_name=node,
66+
payload={"key": f"{node}_value"},
67+
phase=UiPathRuntimeStatePhase.COMPLETED,
68+
)
69+
70+
yield UiPathRuntimeResult(
71+
status=UiPathRuntimeStatus.SUCCESSFUL,
72+
output={"done": True},
73+
)
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_runtime_stream_emits_phase_lifecycle() -> None:
78+
"""A runtime streaming started/updated/completed phases per node."""
79+
runtime = PhaseAwareMockRuntime(nodes=["planner", "executor"])
80+
81+
state_events: list[UiPathRuntimeStateEvent] = []
82+
result: UiPathRuntimeResult | None = None
83+
84+
async for event in runtime.stream({}):
85+
if isinstance(event, UiPathRuntimeResult):
86+
result = event
87+
elif isinstance(event, UiPathRuntimeStateEvent):
88+
state_events.append(event)
89+
90+
# 2 nodes x 3 phases = 6 state events
91+
assert len(state_events) == 6
92+
93+
# Verify per-node lifecycle ordering
94+
for i, node in enumerate(["planner", "executor"]):
95+
group = state_events[i * 3 : i * 3 + 3]
96+
assert group[0].node_name == node
97+
assert group[0].phase == UiPathRuntimeStatePhase.STARTED
98+
assert group[0].payload == {}
99+
100+
assert group[1].node_name == node
101+
assert group[1].phase == UiPathRuntimeStatePhase.UPDATED
102+
assert group[1].payload == {"key": f"{node}_value"}
103+
104+
assert group[2].node_name == node
105+
assert group[2].phase == UiPathRuntimeStatePhase.COMPLETED
106+
assert group[2].payload == {"key": f"{node}_value"}
107+
108+
# Final result
109+
assert result is not None
110+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
111+
assert result.output == {"done": True}
112+
113+
114+
@pytest.mark.asyncio
115+
async def test_runtime_stream_emits_faulted_phase_on_error() -> None:
116+
"""A node that fails should emit started then faulted, and stop the stream."""
117+
runtime = PhaseAwareMockRuntime(
118+
nodes=["planner", "executor"], failing_node="executor"
119+
)
120+
121+
state_events: list[UiPathRuntimeStateEvent] = []
122+
result: UiPathRuntimeResult | None = None
123+
124+
async for event in runtime.stream({}):
125+
if isinstance(event, UiPathRuntimeResult):
126+
result = event
127+
elif isinstance(event, UiPathRuntimeStateEvent):
128+
state_events.append(event)
129+
130+
# planner: started, updated, completed (3) + executor: started, faulted (2) = 5
131+
assert len(state_events) == 5
132+
133+
# planner completed normally
134+
assert state_events[0].phase == UiPathRuntimeStatePhase.STARTED
135+
assert state_events[1].phase == UiPathRuntimeStatePhase.UPDATED
136+
assert state_events[2].phase == UiPathRuntimeStatePhase.COMPLETED
137+
138+
# executor started then faulted
139+
assert state_events[3].node_name == "executor"
140+
assert state_events[3].phase == UiPathRuntimeStatePhase.STARTED
141+
assert state_events[4].node_name == "executor"
142+
assert state_events[4].phase == UiPathRuntimeStatePhase.FAULTED
143+
assert state_events[4].payload == {"error": "executor failed"}
144+
145+
# Result is faulted
146+
assert result is not None
147+
assert result.status == UiPathRuntimeStatus.FAULTED

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)