Skip to content

Commit 6511779

Browse files
committed
feat: add debug command interfaces
1 parent ce0f654 commit 6511779

File tree

7 files changed

+1483
-1
lines changed

7 files changed

+1483
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
"mockito>=1.5.4",
2323
"hydra-core>=1.3.2",
2424
"pydantic-function-models>=0.1.10",
25+
"pysignalr==1.3.0",
2526
]
2627
classifiers = [
2728
"Development Status :: 3 - Alpha",

src/uipath/_cli/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from ._utils._common import add_cwd_to_path, load_environment_variables
77
from .cli_auth import auth as auth
8+
from .cli_debug import debug as debug # type: ignore
89
from .cli_deploy import deploy as deploy # type: ignore
910
from .cli_dev import dev as dev
1011
from .cli_eval import eval as eval # type: ignore
@@ -74,4 +75,4 @@ def cli(lv: bool, v: bool) -> None:
7475
cli.add_command(pull)
7576
cli.add_command(eval)
7677
cli.add_command(dev)
77-
cli.add_command(run, name="debug")
78+
cli.add_command(debug)

src/uipath/_cli/_debug/_bridge.py

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import asyncio
2+
import json
3+
import os
4+
from abc import ABC, abstractmethod
5+
from typing import Any, Dict, Optional
6+
7+
from pysignalr.client import SignalRClient
8+
9+
from uipath._cli._runtime._contracts import UiPathRuntimeContext
10+
11+
12+
class IDebugBridge(ABC):
13+
"""Abstract interface for debug communication.
14+
15+
Implementations: SignalR, Console, WebSocket, etc.
16+
"""
17+
18+
@abstractmethod
19+
async def connect(self) -> None:
20+
"""Establish connection to debugger."""
21+
pass
22+
23+
@abstractmethod
24+
async def disconnect(self) -> None:
25+
"""Close connection to debugger."""
26+
pass
27+
28+
@abstractmethod
29+
async def emit_execution_started(self, execution_id: str, **kwargs) -> None:
30+
"""Notify debugger that execution started."""
31+
pass
32+
33+
@abstractmethod
34+
async def emit_breakpoint_hit(
35+
self,
36+
execution_id: str,
37+
location: str,
38+
state: Dict[str, Any],
39+
resume_trigger: Any,
40+
) -> None:
41+
"""Notify debugger that a breakpoint was hit."""
42+
pass
43+
44+
@abstractmethod
45+
async def emit_execution_completed(
46+
self,
47+
execution_id: str,
48+
status: str,
49+
) -> None:
50+
"""Notify debugger that execution completed."""
51+
pass
52+
53+
@abstractmethod
54+
async def emit_execution_error(
55+
self,
56+
execution_id: str,
57+
error: str,
58+
) -> None:
59+
"""Notify debugger that an error occurred."""
60+
pass
61+
62+
@abstractmethod
63+
async def wait_for_resume(self) -> Any:
64+
"""Wait for resume command from debugger."""
65+
pass
66+
67+
68+
class ConsoleDebugBridge(IDebugBridge):
69+
"""Console-based debug bridge for local development.
70+
71+
User presses Enter to continue.
72+
"""
73+
74+
async def connect(self) -> None:
75+
"""Console is always "connected"."""
76+
self._connected = True
77+
print("\n" + "=" * 60)
78+
print(" Console Debugger Started")
79+
print("=" * 60)
80+
print("Commands:")
81+
print(" - Press ENTER to continue")
82+
print("=" * 60 + "\n")
83+
84+
async def disconnect(self) -> None:
85+
"""Cleanup."""
86+
self._connected = False
87+
print("\n" + "=" * 60)
88+
print(" Console Debugger Stopped")
89+
print("=" * 60 + "\n")
90+
91+
async def emit_execution_started(self, execution_id: str, **kwargs) -> None:
92+
"""Print execution started."""
93+
print(f"\n Execution Started: {execution_id}")
94+
95+
async def emit_breakpoint_hit(
96+
self,
97+
execution_id: str,
98+
location: str,
99+
state: Dict[str, Any],
100+
resume_trigger: Any,
101+
) -> None:
102+
"""Print breakpoint info and wait for user input."""
103+
print("\n" + "=" * 60)
104+
print(" BREAKPOINT HIT")
105+
print("=" * 60)
106+
print(f"Location: {location}")
107+
print(f"Execution: {execution_id}")
108+
print("\nState:")
109+
print(json.dumps(state, indent=2, default=str))
110+
print("=" * 60)
111+
112+
async def emit_execution_completed(
113+
self,
114+
execution_id: str,
115+
status: str,
116+
) -> None:
117+
"""Print completion."""
118+
print(f"\n Execution Completed: {execution_id} - Status: {status}")
119+
120+
async def emit_execution_error(
121+
self,
122+
execution_id: str,
123+
error: str,
124+
) -> None:
125+
"""Print error."""
126+
print(f"\n Execution Error: {execution_id}")
127+
print(f"Error: {error}")
128+
129+
async def wait_for_resume(self) -> Any:
130+
"""Wait for user to press Enter or type commands.
131+
132+
Runs in executor to avoid blocking async loop.
133+
"""
134+
print("\n Press ENTER to continue (or type command)...")
135+
136+
# Run input() in executor to not block async loop
137+
loop = asyncio.get_running_loop()
138+
user_input = await loop.run_in_executor(None, input, "> ")
139+
140+
return user_input
141+
142+
143+
class SignalRDebugBridge(IDebugBridge):
144+
"""SignalR-based debug bridge for remote debugging.
145+
146+
Communicates with a SignalR hub server.
147+
"""
148+
149+
def __init__(
150+
self,
151+
hub_url: str,
152+
access_token: Optional[str] = None,
153+
headers: Optional[Dict[str, str]] = None,
154+
):
155+
self.hub_url = hub_url
156+
self.access_token = access_token
157+
self.headers = headers or {}
158+
self._client: Optional[SignalRClient] = None
159+
self._connected_event = asyncio.Event()
160+
self._resume_event: Optional[asyncio.Event] = None
161+
self._resume_data: Any = None
162+
163+
async def connect(self) -> None:
164+
"""Establish SignalR connection."""
165+
all_headers = {**self.headers}
166+
if self.access_token:
167+
all_headers["Authorization"] = f"Bearer {self.access_token}"
168+
169+
self._client = SignalRClient(self.hub_url, headers=all_headers)
170+
171+
# Register event handlers
172+
self._client.on("ResumeExecution", self._handle_resume)
173+
self._client.on_open(self._handle_open)
174+
self._client.on_close(self._handle_close)
175+
self._client.on_error(self._handle_error)
176+
177+
# Start connection in background
178+
asyncio.create_task(self._client.run())
179+
180+
# Wait for connection to establish
181+
await asyncio.wait_for(self._connected_event.wait(), timeout=30.0)
182+
183+
async def disconnect(self) -> None:
184+
"""Close SignalR connection."""
185+
if self._client and hasattr(self._client, "_transport"):
186+
transport = self._client._transport
187+
if transport and hasattr(transport, "_ws") and transport._ws:
188+
try:
189+
await transport._ws.close()
190+
except Exception as e:
191+
print(f"Error closing SignalR WebSocket: {e}")
192+
193+
async def emit_execution_started(self, execution_id: str, **kwargs) -> None:
194+
"""Send execution started event."""
195+
await self._send("OnExecutionStarted", {"executionId": execution_id, **kwargs})
196+
197+
async def emit_breakpoint_hit(
198+
self,
199+
execution_id: str,
200+
location: str,
201+
state: Dict[str, Any],
202+
resume_trigger: Any,
203+
) -> None:
204+
"""Send breakpoint hit event."""
205+
await self._send(
206+
"OnBreakpointHit",
207+
{
208+
"executionId": execution_id,
209+
"location": location,
210+
"state": state,
211+
"resumeTrigger": resume_trigger,
212+
},
213+
)
214+
215+
async def emit_execution_completed(
216+
self,
217+
execution_id: str,
218+
status: str,
219+
) -> None:
220+
"""Send execution completed event."""
221+
await self._send(
222+
"OnExecutionCompleted",
223+
{
224+
"executionId": execution_id,
225+
"status": status,
226+
},
227+
)
228+
229+
async def emit_execution_error(
230+
self,
231+
execution_id: str,
232+
error: str,
233+
) -> None:
234+
"""Send execution error event."""
235+
await self._send(
236+
"OnExecutionError",
237+
{
238+
"executionId": execution_id,
239+
"error": error,
240+
},
241+
)
242+
243+
async def wait_for_resume(self) -> Any:
244+
"""Wait for resume command from server."""
245+
self._resume_event = asyncio.Event()
246+
await self._resume_event.wait()
247+
return self._resume_data
248+
249+
async def _send(self, method: str, data: Dict[str, Any]) -> None:
250+
"""Send message to SignalR hub."""
251+
if not self._client:
252+
raise RuntimeError("SignalR client not connected")
253+
254+
await self._client.send(method=method, arguments=[data])
255+
256+
async def _handle_resume(self, args: list[Any]) -> None:
257+
"""Handle resume command from SignalR server."""
258+
if self._resume_event and len(args) > 0:
259+
self._resume_data = args[0]
260+
self._resume_event.set()
261+
262+
async def _handle_open(self) -> None:
263+
"""Handle SignalR connection open."""
264+
print("SignalR connection established")
265+
self._connected_event.set()
266+
267+
async def _handle_close(self) -> None:
268+
"""Handle SignalR connection close."""
269+
print("SignalR connection closed")
270+
self._connected_event.clear()
271+
272+
async def _handle_error(self, error: Any) -> None:
273+
"""Handle SignalR error."""
274+
print(f"SignalR error: {error}")
275+
276+
277+
def get_remote_debug_bridge(context: UiPathRuntimeContext) -> IDebugBridge:
278+
"""Factory to get SignalR debug bridge for remote debugging."""
279+
uipath_url = os.environ.get("UIPATH_URL")
280+
if not uipath_url or not context.job_id:
281+
raise ValueError(
282+
"UIPATH_URL and UIPATH_JOB_KEY are required for remote debugging"
283+
)
284+
if not context.trace_context:
285+
raise ValueError("trace_context is required for remote debugging")
286+
287+
signalr_url = (
288+
os.environ.get("UIPATH_URL") + "/agenthub_/wsstunnel?jobId=" + context.job_id
289+
)
290+
return SignalRDebugBridge(
291+
hub_url=signalr_url,
292+
access_token=os.environ.get("UIPATH_ACCESS_TOKEN"),
293+
headers={
294+
"X-UiPath-Internal-TenantId": context.trace_context.tenant_id,
295+
"X-UiPath-Internal-AccountId": context.trace_context.org_id,
296+
"X-UiPath-FolderKey": context.trace_context.folder_key,
297+
},
298+
)
299+
300+
301+
def get_debug_bridge(context: UiPathRuntimeContext) -> IDebugBridge:
302+
"""Factory to get appropriate debug bridge based on context.
303+
304+
Args:
305+
context: The runtime context containing debug configuration.
306+
307+
Returns:
308+
An instance of IDebugBridge suitable for the context.
309+
"""
310+
if context.job_id:
311+
return get_remote_debug_bridge(context)
312+
else:
313+
return ConsoleDebugBridge()

0 commit comments

Comments
 (0)