Skip to content

Commit 8dbc70f

Browse files
authored
Merge pull request #106 from UiPath/feat/detached-debug-bridge
feat: add detached debug bridge for non-interactive runs
2 parents b465907 + 564ce65 commit 8dbc70f

6 files changed

Lines changed: 276 additions & 2 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.10.0"
3+
version = "0.10.1"
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/debug/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Initialization module for the debug package."""
22

33
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
4+
from uipath.runtime.debug.detached import DetachedDebugBridge
45
from uipath.runtime.debug.exception import (
56
UiPathDebugQuitError,
67
)
78
from uipath.runtime.debug.protocol import UiPathDebugProtocol
89
from uipath.runtime.debug.runtime import UiPathDebugRuntime
910

1011
__all__ = [
12+
"DetachedDebugBridge",
1113
"UiPathDebugQuitError",
1214
"UiPathDebugProtocol",
1315
"UiPathDebugRuntime",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Detached debug bridge — satisfies `UiPathDebugProtocol` without attaching a debugger."""
2+
3+
import asyncio
4+
from typing import Any, Literal
5+
6+
from uipath.runtime.debug.breakpoint import UiPathBreakpointResult
7+
from uipath.runtime.events import UiPathRuntimeStateEvent
8+
from uipath.runtime.result import UiPathRuntimeResult
9+
10+
11+
class DetachedDebugBridge:
12+
"""Debug bridge used when no debugger is attached.
13+
14+
Implements `UiPathDebugProtocol` so the debug runtime stack keeps wrapping
15+
uniformly, but all hooks are no-ops. `wait_for_resume` returns immediately
16+
so the runtime's initial paused-state gate releases without blocking;
17+
`wait_for_terminate` blocks forever because termination can never arrive
18+
through a bridge that isn't connected to anything.
19+
"""
20+
21+
async def connect(self) -> None:
22+
"""No-op — nothing to connect to when detached."""
23+
pass
24+
25+
async def disconnect(self) -> None:
26+
"""No-op — no connection to tear down."""
27+
pass
28+
29+
async def emit_execution_started(self, **kwargs: Any) -> None:
30+
"""No-op — no debugger is listening."""
31+
pass
32+
33+
async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None:
34+
"""No-op — no debugger is listening."""
35+
pass
36+
37+
async def emit_breakpoint_hit(
38+
self, breakpoint_result: UiPathBreakpointResult
39+
) -> None:
40+
"""No-op — no debugger is listening."""
41+
pass
42+
43+
async def emit_execution_suspended(
44+
self, runtime_result: UiPathRuntimeResult
45+
) -> None:
46+
"""No-op — no debugger is listening."""
47+
pass
48+
49+
async def emit_execution_resumed(self, resume_data: Any) -> None:
50+
"""No-op — no debugger is listening."""
51+
pass
52+
53+
async def emit_execution_completed(
54+
self, runtime_result: UiPathRuntimeResult
55+
) -> None:
56+
"""No-op — no debugger is listening."""
57+
pass
58+
59+
async def emit_execution_error(self, error: str) -> None:
60+
"""No-op — no debugger is listening."""
61+
pass
62+
63+
async def wait_for_resume(self) -> Any:
64+
"""Return immediately — the runtime's initial paused gate releases without a debugger."""
65+
return None
66+
67+
async def wait_for_terminate(self) -> None:
68+
"""Block forever — termination cannot arrive when no debugger is attached."""
69+
await asyncio.Event().wait()
70+
71+
def get_breakpoints(self) -> list[str] | Literal["*"]:
72+
"""Return an empty breakpoint list so the runtime never suspends."""
73+
return []
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for DetachedDebugBridge."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
7+
import pytest
8+
9+
from uipath.runtime.debug import (
10+
DetachedDebugBridge,
11+
UiPathBreakpointResult,
12+
UiPathDebugProtocol,
13+
)
14+
from uipath.runtime.events import UiPathRuntimeStateEvent
15+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
16+
17+
18+
def test_detached_bridge_satisfies_debug_protocol():
19+
"""DetachedDebugBridge must be usable wherever UiPathDebugProtocol is expected."""
20+
bridge: UiPathDebugProtocol = DetachedDebugBridge()
21+
assert bridge is not None
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_connect_and_disconnect_are_noops():
26+
"""Lifecycle methods must complete without raising."""
27+
bridge = DetachedDebugBridge()
28+
await bridge.connect()
29+
await bridge.disconnect()
30+
31+
32+
@pytest.mark.asyncio
33+
async def test_all_emit_methods_are_noops():
34+
"""Emit methods must not raise and must not require any external state."""
35+
bridge = DetachedDebugBridge()
36+
37+
state_event = UiPathRuntimeStateEvent(node_name="node-x", payload={})
38+
breakpoint_result = UiPathBreakpointResult(
39+
breakpoint_node="node-x",
40+
breakpoint_type="before",
41+
next_nodes=[],
42+
current_state={},
43+
)
44+
runtime_result = UiPathRuntimeResult(
45+
status=UiPathRuntimeStatus.SUCCESSFUL,
46+
output={},
47+
)
48+
49+
await bridge.emit_execution_started()
50+
await bridge.emit_state_update(state_event)
51+
await bridge.emit_breakpoint_hit(breakpoint_result)
52+
await bridge.emit_execution_suspended(runtime_result)
53+
await bridge.emit_execution_resumed({"any": "data"})
54+
await bridge.emit_execution_completed(runtime_result)
55+
await bridge.emit_execution_error("boom")
56+
57+
58+
@pytest.mark.asyncio
59+
async def test_wait_for_resume_returns_immediately():
60+
"""The runtime's initial paused gate calls this — it must release without blocking."""
61+
bridge = DetachedDebugBridge()
62+
# Fails the test if the call hangs for any reason.
63+
await asyncio.wait_for(bridge.wait_for_resume(), timeout=1.0)
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_wait_for_terminate_blocks_forever():
68+
"""Termination can never arrive on a detached bridge — the coroutine must not complete."""
69+
bridge = DetachedDebugBridge()
70+
with pytest.raises(asyncio.TimeoutError):
71+
await asyncio.wait_for(bridge.wait_for_terminate(), timeout=0.1)
72+
73+
74+
def test_get_breakpoints_returns_empty_list():
75+
"""Empty list means 'no breakpoints' — the runtime's normal flow then skips suspension."""
76+
bridge = DetachedDebugBridge()
77+
assert bridge.get_breakpoints() == []
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Integration test: UiPathDebugRuntime must not block under DetachedDebugBridge.
2+
3+
If this ever hangs or times out, the detached path has regressed — the scenario
4+
this bridge exists to enable has broken.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
from typing import Any, AsyncGenerator
11+
12+
import pytest
13+
14+
from uipath.runtime import (
15+
UiPathExecuteOptions,
16+
UiPathRuntimeResult,
17+
UiPathRuntimeStatus,
18+
UiPathStreamNotSupportedError,
19+
UiPathStreamOptions,
20+
)
21+
from uipath.runtime.debug import DetachedDebugBridge, UiPathDebugRuntime
22+
from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent
23+
from uipath.runtime.schema import UiPathRuntimeSchema
24+
25+
26+
class TrivialStreamingRuntime:
27+
"""Streams one state event then a final successful result."""
28+
29+
async def dispose(self) -> None:
30+
pass
31+
32+
async def execute(
33+
self,
34+
input: dict[str, Any] | None = None,
35+
options: UiPathExecuteOptions | None = None,
36+
) -> UiPathRuntimeResult:
37+
return UiPathRuntimeResult(
38+
status=UiPathRuntimeStatus.SUCCESSFUL,
39+
output={"mode": "execute"},
40+
)
41+
42+
async def stream(
43+
self,
44+
input: dict[str, Any] | None = None,
45+
options: UiPathStreamOptions | None = None,
46+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
47+
yield UiPathRuntimeStateEvent(node_name="node-1", payload={"i": 0})
48+
yield UiPathRuntimeResult(
49+
status=UiPathRuntimeStatus.SUCCESSFUL,
50+
output={"done": True},
51+
)
52+
53+
async def get_schema(self) -> UiPathRuntimeSchema:
54+
raise NotImplementedError()
55+
56+
57+
class NonStreamingRuntime:
58+
"""Raises UiPathStreamNotSupportedError — forces the execute() fallback path."""
59+
60+
def __init__(self) -> None:
61+
self.execute_called = False
62+
63+
async def dispose(self) -> None:
64+
pass
65+
66+
async def execute(
67+
self,
68+
input: dict[str, Any] | None = None,
69+
options: UiPathExecuteOptions | None = None,
70+
) -> UiPathRuntimeResult:
71+
self.execute_called = True
72+
return UiPathRuntimeResult(
73+
status=UiPathRuntimeStatus.SUCCESSFUL,
74+
output={"mode": "execute"},
75+
)
76+
77+
async def stream(
78+
self,
79+
input: dict[str, Any] | None = None,
80+
options: UiPathStreamOptions | None = None,
81+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
82+
raise UiPathStreamNotSupportedError("nope")
83+
yield # pragma: no cover — makes this an async generator
84+
85+
async def get_schema(self) -> UiPathRuntimeSchema:
86+
raise NotImplementedError()
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_debug_runtime_streams_to_completion_under_detached_bridge():
91+
"""The detached bridge must not block the runtime's startup wait-for-resume gate."""
92+
debug_runtime = UiPathDebugRuntime(
93+
delegate=TrivialStreamingRuntime(),
94+
debug_bridge=DetachedDebugBridge(),
95+
)
96+
97+
try:
98+
result = await asyncio.wait_for(debug_runtime.execute({}), timeout=5.0)
99+
finally:
100+
await debug_runtime.dispose()
101+
102+
assert isinstance(result, UiPathRuntimeResult)
103+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
104+
assert result.output == {"done": True}
105+
106+
107+
@pytest.mark.asyncio
108+
async def test_debug_runtime_execute_fallback_completes_under_detached_bridge():
109+
"""Fallback path (stream-unsupported delegates) must also not block."""
110+
delegate = NonStreamingRuntime()
111+
debug_runtime = UiPathDebugRuntime(
112+
delegate=delegate,
113+
debug_bridge=DetachedDebugBridge(),
114+
)
115+
116+
try:
117+
result = await asyncio.wait_for(debug_runtime.execute({}), timeout=5.0)
118+
finally:
119+
await debug_runtime.dispose()
120+
121+
assert delegate.execute_called is True
122+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL

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)