Skip to content

Commit fdb4b30

Browse files
committed
feat: add debug mode
1 parent e86087f commit fdb4b30

File tree

9 files changed

+312
-27
lines changed

9 files changed

+312
-27
lines changed

pyproject.toml

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-runtime>=0.0.7, <0.1.0",
9-
"textual>=6.5.0",
10-
"pyperclip>=1.11.0",
8+
"uipath-runtime==0.0.10",
9+
"textual==6.6.0",
10+
"pyperclip==1.11.0",
1111
]
1212
classifiers = [
1313
"Intended Audience :: Developers",
@@ -106,9 +106,3 @@ show_missing = true
106106

107107
[tool.coverage.run]
108108
source = ["src"]
109-
110-
[[tool.uv.index]]
111-
name = "testpypi"
112-
url = "https://test.pypi.org/simple/"
113-
publish-url = "https://test.pypi.org/legacy/"
114-
explicit = true

src/uipath/dev/__init__.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
9797
await self.action_new_run()
9898
elif event.button.id == "execute-btn":
9999
await self.action_execute_run()
100+
elif event.button.id == "debug-btn":
101+
await self.action_debug_run()
100102
elif event.button.id == "cancel-btn":
101103
await self.action_cancel()
104+
elif event.button.id == "debug-step-btn":
105+
await self.action_debug_step()
106+
elif event.button.id == "debug-continue-btn":
107+
await self.action_debug_continue()
108+
elif event.button.id == "debug-stop-btn":
109+
await self.action_debug_stop()
102110

103111
async def on_list_view_selected(self, event: ListView.Selected) -> None:
104112
"""Handle run selection from history."""
@@ -171,6 +179,72 @@ async def action_execute_run(self) -> None:
171179
else:
172180
self._focus_chat_input()
173181

182+
async def action_debug_run(self) -> None:
183+
"""Execute a new run in debug mode (step-by-step)."""
184+
new_run_panel = self.query_one("#new-run-panel", NewRunPanel)
185+
entrypoint, input_data, conversational = new_run_panel.get_input_values()
186+
187+
if not entrypoint:
188+
return
189+
190+
try:
191+
input_payload: dict[str, Any] = json.loads(input_data)
192+
except json.JSONDecodeError:
193+
return
194+
195+
# Create run with debug=True
196+
run = ExecutionRun(entrypoint, input_payload, conversational, debug=True)
197+
198+
history_panel = self.query_one("#history-panel", RunHistoryPanel)
199+
history_panel.add_run(run)
200+
201+
self.run_service.register_run(run)
202+
203+
self._show_run_details(run)
204+
205+
# start execution in debug mode (it will pause immediately)
206+
asyncio.create_task(self._execute_runtime(run))
207+
208+
async def action_debug_step(self) -> None:
209+
"""Step to next breakpoint in debug mode."""
210+
details_panel = self.query_one("#details-panel", RunDetailsPanel)
211+
if details_panel and details_panel.current_run:
212+
run = details_panel.current_run
213+
214+
# Get the debug bridge for this run
215+
debug_bridge = self.run_service.get_debug_bridge(run.id)
216+
if debug_bridge:
217+
# Step mode = break on all nodes
218+
debug_bridge.set_breakpoints("*")
219+
# Resume execution (will pause at next node)
220+
debug_bridge.resume()
221+
222+
async def action_debug_continue(self) -> None:
223+
"""Continue execution without stopping at breakpoints."""
224+
details_panel = self.query_one("#details-panel", RunDetailsPanel)
225+
if details_panel and details_panel.current_run:
226+
run = details_panel.current_run
227+
228+
# Get the debug bridge for this run
229+
debug_bridge = self.run_service.get_debug_bridge(run.id)
230+
if debug_bridge:
231+
# Clear breakpoints = run to completion
232+
debug_bridge.set_breakpoints([])
233+
# Resume execution
234+
debug_bridge.resume()
235+
236+
async def action_debug_stop(self) -> None:
237+
"""Stop debug execution."""
238+
details_panel = self.query_one("#details-panel", RunDetailsPanel)
239+
if details_panel and details_panel.current_run:
240+
run = details_panel.current_run
241+
242+
# Get the debug bridge for this run
243+
debug_bridge = self.run_service.get_debug_bridge(run.id)
244+
if debug_bridge:
245+
# Signal quit
246+
debug_bridge.quit()
247+
174248
async def action_clear_history(self) -> None:
175249
"""Clear run history."""
176250
history_panel = self.query_one("#history-panel", RunHistoryPanel)

src/uipath/dev/models/execution.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ def __init__(
1919
entrypoint: str,
2020
input_data: Union[dict[str, Any]],
2121
conversational: bool = False,
22+
debug: bool = False,
2223
):
2324
"""Initialize an ExecutionRun instance."""
2425
self.id = str(uuid4())[:8]
2526
self.entrypoint = entrypoint
2627
self.input_data = input_data
2728
self.conversational = conversational
29+
self.debug = debug
2830
self.resume_data: Optional[dict[str, Any]] = None
2931
self.output_data: Optional[dict[str, Any]] = None
3032
self.start_time = datetime.now()
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Debug bridge implementation for Textual UI."""
2+
3+
import asyncio
4+
import logging
5+
from typing import Any, Callable, Literal, Optional
6+
7+
from uipath.runtime.debug import UiPathBreakpointResult, UiPathDebugQuitError
8+
from uipath.runtime.events import UiPathRuntimeStateEvent
9+
from uipath.runtime.result import UiPathRuntimeResult
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class TextualDebugBridge:
15+
"""Bridge between Textual UI and UiPathDebugRuntime."""
16+
17+
def __init__(self):
18+
"""Initialize the debug bridge."""
19+
self._connected = False
20+
self._resume_event = asyncio.Event()
21+
self._quit_requested = False
22+
self._breakpoints: list[str] | Literal["*"] = "*" # Default: step mode
23+
24+
# Callbacks to UI
25+
self.on_execution_started: Optional[Callable[[], None]] = None
26+
self.on_state_update: Optional[Callable[[UiPathRuntimeStateEvent], None]] = None
27+
self.on_breakpoint_hit: Optional[Callable[[UiPathBreakpointResult], None]] = (
28+
None
29+
)
30+
self.on_execution_completed: Optional[Callable[[UiPathRuntimeResult], None]] = (
31+
None
32+
)
33+
self.on_execution_error: Optional[Callable[[str], None]] = None
34+
35+
async def connect(self) -> None:
36+
"""Establish connection to debugger."""
37+
self._connected = True
38+
self._quit_requested = False
39+
logger.debug("Debug bridge connected")
40+
41+
async def disconnect(self) -> None:
42+
"""Close connection to debugger."""
43+
self._connected = False
44+
self._resume_event.set() # Unblock any waiting tasks
45+
logger.debug("Debug bridge disconnected")
46+
47+
async def emit_execution_started(self, **kwargs: Any) -> None:
48+
"""Notify debugger that execution started."""
49+
logger.debug("Execution started")
50+
if self.on_execution_started:
51+
self.on_execution_started()
52+
53+
async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None:
54+
"""Notify debugger of runtime state update."""
55+
logger.debug(f"State update: {state_event.node_name}")
56+
if self.on_state_update:
57+
self.on_state_update(state_event)
58+
59+
async def emit_breakpoint_hit(
60+
self, breakpoint_result: UiPathBreakpointResult
61+
) -> None:
62+
"""Notify debugger that a breakpoint was hit."""
63+
logger.debug(f"Breakpoint hit: {breakpoint_result}")
64+
if self.on_breakpoint_hit:
65+
self.on_breakpoint_hit(breakpoint_result)
66+
67+
async def emit_execution_completed(
68+
self, runtime_result: UiPathRuntimeResult
69+
) -> None:
70+
"""Notify debugger that execution completed."""
71+
logger.debug("Execution completed")
72+
if self.on_execution_completed:
73+
self.on_execution_completed(runtime_result)
74+
75+
async def emit_execution_error(self, error: str) -> None:
76+
"""Notify debugger that an error occurred."""
77+
logger.error(f"Execution error: {error}")
78+
if self.on_execution_error:
79+
self.on_execution_error(error)
80+
81+
async def wait_for_resume(self) -> Any:
82+
"""Wait for resume command from debugger.
83+
84+
Raises:
85+
UiPathDebugQuitError: If quit was requested
86+
"""
87+
self._resume_event.clear()
88+
await self._resume_event.wait()
89+
90+
if self._quit_requested:
91+
raise UiPathDebugQuitError("Debug session quit requested")
92+
93+
def resume(self) -> None:
94+
"""Signal that execution should resume (called from UI buttons)."""
95+
self._resume_event.set()
96+
97+
def quit(self) -> None:
98+
"""Signal that execution should quit (called from UI stop button)."""
99+
self._quit_requested = True
100+
self._resume_event.set()
101+
102+
def get_breakpoints(self) -> list[str] | Literal["*"]:
103+
"""Get nodes to suspend execution at."""
104+
return self._breakpoints
105+
106+
def set_breakpoints(self, breakpoints: list[str] | Literal["*"]) -> None:
107+
"""Set breakpoints (called from UI)."""
108+
self._breakpoints = breakpoints

src/uipath/dev/services/run_service.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@
1212
UiPathExecuteOptions,
1313
UiPathExecutionRuntime,
1414
UiPathRuntimeFactoryProtocol,
15+
UiPathRuntimeProtocol,
1516
UiPathRuntimeStatus,
1617
)
18+
from uipath.runtime.debug import UiPathDebugRuntime
1719
from uipath.runtime.errors import UiPathErrorContract, UiPathRuntimeError
1820

1921
from uipath.dev.infrastructure import RunContextExporter, RunContextLogHandler
2022
from uipath.dev.models import ExecutionRun, LogMessage, TraceMessage
23+
from uipath.dev.services.debug_bridge import TextualDebugBridge
2124

2225
RunUpdatedCallback = Callable[[ExecutionRun], None]
2326
LogCallback = Callable[[LogMessage], None]
@@ -58,6 +61,8 @@ def __init__(
5861
batch=False,
5962
)
6063

64+
self.debug_bridges: dict[str, TextualDebugBridge] = {}
65+
6166
def register_run(self, run: ExecutionRun) -> None:
6267
"""Register a new run and emit an initial update."""
6368
self.runs[run.id] = run
@@ -94,7 +99,32 @@ async def execute(self, run: ExecutionRun) -> None:
9499
callback=self.handle_log,
95100
)
96101

97-
runtime = await self.runtime_factory.new_runtime(entrypoint=run.entrypoint)
102+
new_runtime = await self.runtime_factory.new_runtime(
103+
entrypoint=run.entrypoint
104+
)
105+
106+
runtime: UiPathRuntimeProtocol
107+
108+
if run.debug:
109+
debug_bridge = TextualDebugBridge()
110+
111+
# Connect callbacks
112+
debug_bridge.on_state_update = lambda event: self._handle_state_update(
113+
run.id, event
114+
)
115+
debug_bridge.on_breakpoint_hit = lambda bp: self._handle_breakpoint_hit(
116+
run.id, bp
117+
)
118+
119+
# Store bridge so UI can access it
120+
self.debug_bridges[run.id] = debug_bridge
121+
122+
runtime = UiPathDebugRuntime(
123+
delegate=new_runtime,
124+
debug_bridge=debug_bridge,
125+
)
126+
else:
127+
runtime = new_runtime
98128

99129
execution_runtime = UiPathExecutionRuntime(
100130
delegate=runtime,
@@ -108,7 +138,7 @@ async def execute(self, run: ExecutionRun) -> None:
108138
if result is not None:
109139
if (
110140
result.status == UiPathRuntimeStatus.SUSPENDED.value
111-
and result.resume
141+
and result.trigger
112142
):
113143
run.status = "suspended"
114144
else:
@@ -145,6 +175,9 @@ async def execute(self, run: ExecutionRun) -> None:
145175
self.runs[run.id] = run
146176
self._emit_run_updated(run)
147177

178+
if run.id in self.debug_bridges:
179+
del self.debug_bridges[run.id]
180+
148181
def handle_log(self, log_msg: LogMessage) -> None:
149182
"""Entry point for all logs (runtime, traces, stderr)."""
150183
run = self.runs.get(log_msg.run_id)
@@ -172,6 +205,22 @@ def handle_trace(self, trace_msg: TraceMessage) -> None:
172205
if self.on_trace is not None:
173206
self.on_trace(trace_msg)
174207

208+
def get_debug_bridge(self, run_id: str) -> Optional[TextualDebugBridge]:
209+
"""Get the debug bridge for a run."""
210+
return self.debug_bridges.get(run_id)
211+
212+
def _handle_state_update(self, run_id: str, event) -> None:
213+
"""Handle state update from debug runtime."""
214+
# You can add more logic here later if needed
215+
pass
216+
217+
def _handle_breakpoint_hit(self, run_id: str, bp) -> None:
218+
"""Handle breakpoint hit from debug runtime."""
219+
run = self.runs.get(run_id)
220+
if run:
221+
run.status = "suspended"
222+
self._emit_run_updated(run)
223+
175224
def _emit_run_updated(self, run: ExecutionRun) -> None:
176225
"""Notify observers that a run's state changed."""
177226
self.runs[run.id] = run

src/uipath/dev/ui/panels/new_run_panel.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ def compose(self) -> ComposeResult:
126126
variant="primary",
127127
classes="action-btn",
128128
)
129+
yield Button(
130+
"⏯ Debug",
131+
id="debug-btn",
132+
variant="primary",
133+
classes="action-btn",
134+
)
129135

130136
async def on_mount(self) -> None:
131137
"""Discover entrypoints once, and set the first as default."""

0 commit comments

Comments
 (0)