Skip to content

Commit 6b216db

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

File tree

9 files changed

+304
-27
lines changed

9 files changed

+304
-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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Debug bridge implementation for Textual UI."""
2+
3+
import asyncio
4+
import logging
5+
from typing import Any, 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
26+
self.on_state_update: Optional[callable] = None
27+
self.on_breakpoint_hit: Optional[callable] = None
28+
self.on_execution_completed: Optional[callable] = None
29+
self.on_execution_error: Optional[callable] = None
30+
31+
async def connect(self) -> None:
32+
"""Establish connection to debugger."""
33+
self._connected = True
34+
self._quit_requested = False
35+
logger.debug("Debug bridge connected")
36+
37+
async def disconnect(self) -> None:
38+
"""Close connection to debugger."""
39+
self._connected = False
40+
self._resume_event.set() # Unblock any waiting tasks
41+
logger.debug("Debug bridge disconnected")
42+
43+
async def emit_execution_started(self, **kwargs) -> None:
44+
"""Notify debugger that execution started."""
45+
logger.debug("Execution started")
46+
if self.on_execution_started:
47+
self.on_execution_started()
48+
49+
async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None:
50+
"""Notify debugger of runtime state update."""
51+
logger.debug(f"State update: {state_event.node_name}")
52+
if self.on_state_update:
53+
self.on_state_update(state_event)
54+
55+
async def emit_breakpoint_hit(
56+
self, breakpoint_result: UiPathBreakpointResult
57+
) -> None:
58+
"""Notify debugger that a breakpoint was hit."""
59+
logger.debug(f"Breakpoint hit: {breakpoint_result}")
60+
if self.on_breakpoint_hit:
61+
self.on_breakpoint_hit(breakpoint_result)
62+
63+
async def emit_execution_completed(
64+
self, runtime_result: UiPathRuntimeResult
65+
) -> None:
66+
"""Notify debugger that execution completed."""
67+
logger.debug("Execution completed")
68+
if self.on_execution_completed:
69+
self.on_execution_completed(runtime_result)
70+
71+
async def emit_execution_error(self, error: str) -> None:
72+
"""Notify debugger that an error occurred."""
73+
logger.error(f"Execution error: {error}")
74+
if self.on_execution_error:
75+
self.on_execution_error(error)
76+
77+
async def wait_for_resume(self) -> Any:
78+
"""Wait for resume command from debugger.
79+
80+
Raises:
81+
UiPathDebugQuitError: If quit was requested
82+
"""
83+
self._resume_event.clear()
84+
await self._resume_event.wait()
85+
86+
if self._quit_requested:
87+
raise UiPathDebugQuitError("Debug session quit requested")
88+
89+
def resume(self) -> None:
90+
"""Signal that execution should resume (called from UI buttons)."""
91+
self._resume_event.set()
92+
93+
def quit(self) -> None:
94+
"""Signal that execution should quit (called from UI stop button)."""
95+
self._quit_requested = True
96+
self._resume_event.set()
97+
98+
def get_breakpoints(self) -> list[str] | Literal["*"]:
99+
"""Get nodes to suspend execution at."""
100+
return self._breakpoints
101+
102+
def set_breakpoints(self, breakpoints: list[str] | Literal["*"]) -> None:
103+
"""Set breakpoints (called from UI)."""
104+
self._breakpoints = breakpoints

src/uipath/dev/services/run_service.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
UiPathRuntimeFactoryProtocol,
1515
UiPathRuntimeStatus,
1616
)
17+
from uipath.runtime.debug import UiPathDebugRuntime
1718
from uipath.runtime.errors import UiPathErrorContract, UiPathRuntimeError
1819

1920
from uipath.dev.infrastructure import RunContextExporter, RunContextLogHandler
2021
from uipath.dev.models import ExecutionRun, LogMessage, TraceMessage
22+
from uipath.dev.services.debug_bridge import TextualDebugBridge
2123

2224
RunUpdatedCallback = Callable[[ExecutionRun], None]
2325
LogCallback = Callable[[LogMessage], None]
@@ -58,6 +60,8 @@ def __init__(
5860
batch=False,
5961
)
6062

63+
self.debug_bridges: dict[str, TextualDebugBridge] = {}
64+
6165
def register_run(self, run: ExecutionRun) -> None:
6266
"""Register a new run and emit an initial update."""
6367
self.runs[run.id] = run
@@ -96,8 +100,29 @@ async def execute(self, run: ExecutionRun) -> None:
96100

97101
runtime = await self.runtime_factory.new_runtime(entrypoint=run.entrypoint)
98102

103+
if run.debug:
104+
debug_bridge = TextualDebugBridge()
105+
106+
# Connect callbacks
107+
debug_bridge.on_state_update = lambda event: self._handle_state_update(
108+
run.id, event
109+
)
110+
debug_bridge.on_breakpoint_hit = lambda bp: self._handle_breakpoint_hit(
111+
run.id, bp
112+
)
113+
114+
# Store bridge so UI can access it
115+
self.debug_bridges[run.id] = debug_bridge
116+
117+
final_runtime = UiPathDebugRuntime(
118+
delegate=runtime,
119+
debug_bridge=debug_bridge,
120+
)
121+
else:
122+
final_runtime = runtime
123+
99124
execution_runtime = UiPathExecutionRuntime(
100-
delegate=runtime,
125+
delegate=final_runtime,
101126
trace_manager=self.trace_manager,
102127
log_handler=log_handler,
103128
execution_id=run.id,
@@ -108,7 +133,7 @@ async def execute(self, run: ExecutionRun) -> None:
108133
if result is not None:
109134
if (
110135
result.status == UiPathRuntimeStatus.SUSPENDED.value
111-
and result.resume
136+
and result.trigger
112137
):
113138
run.status = "suspended"
114139
else:
@@ -145,6 +170,9 @@ async def execute(self, run: ExecutionRun) -> None:
145170
self.runs[run.id] = run
146171
self._emit_run_updated(run)
147172

173+
if run.id in self.debug_bridges:
174+
del self.debug_bridges[run.id]
175+
148176
def handle_log(self, log_msg: LogMessage) -> None:
149177
"""Entry point for all logs (runtime, traces, stderr)."""
150178
run = self.runs.get(log_msg.run_id)
@@ -172,6 +200,22 @@ def handle_trace(self, trace_msg: TraceMessage) -> None:
172200
if self.on_trace is not None:
173201
self.on_trace(trace_msg)
174202

203+
def get_debug_bridge(self, run_id: str) -> Optional[TextualDebugBridge]:
204+
"""Get the debug bridge for a run."""
205+
return self.debug_bridges.get(run_id)
206+
207+
def _handle_state_update(self, run_id: str, event) -> None:
208+
"""Handle state update from debug runtime."""
209+
# You can add more logic here later if needed
210+
pass
211+
212+
def _handle_breakpoint_hit(self, run_id: str, bp) -> None:
213+
"""Handle breakpoint hit from debug runtime."""
214+
run = self.runs.get(run_id)
215+
if run:
216+
run.status = "suspended"
217+
self._emit_run_updated(run)
218+
175219
def _emit_run_updated(self, run: ExecutionRun) -> None:
176220
"""Notify observers that a run's state changed."""
177221
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)