Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ description = "UiPath Developer Console"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath-runtime>=0.0.7, <0.1.0",
"textual>=6.5.0",
"pyperclip>=1.11.0",
"uipath-runtime==0.0.11",
"textual==6.6.0",
"pyperclip==1.11.0",
]
classifiers = [
"Intended Audience :: Developers",
Expand Down Expand Up @@ -107,8 +107,3 @@ show_missing = true
[tool.coverage.run]
source = ["src"]

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
74 changes: 74 additions & 0 deletions src/uipath/dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,16 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
await self.action_new_run()
elif event.button.id == "execute-btn":
await self.action_execute_run()
elif event.button.id == "debug-btn":
await self.action_debug_run()
elif event.button.id == "cancel-btn":
await self.action_cancel()
elif event.button.id == "debug-step-btn":
await self.action_debug_step()
elif event.button.id == "debug-continue-btn":
await self.action_debug_continue()
elif event.button.id == "debug-stop-btn":
await self.action_debug_stop()

async def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle run selection from history."""
Expand Down Expand Up @@ -171,6 +179,72 @@ async def action_execute_run(self) -> None:
else:
self._focus_chat_input()

async def action_debug_run(self) -> None:
"""Execute a new run in debug mode (step-by-step)."""
new_run_panel = self.query_one("#new-run-panel", NewRunPanel)
entrypoint, input_data, conversational = new_run_panel.get_input_values()

if not entrypoint:
return

try:
input_payload: dict[str, Any] = json.loads(input_data)
except json.JSONDecodeError:
return

# Create run with debug=True
run = ExecutionRun(entrypoint, input_payload, conversational, debug=True)

history_panel = self.query_one("#history-panel", RunHistoryPanel)
history_panel.add_run(run)

self.run_service.register_run(run)

self._show_run_details(run)

# start execution in debug mode (it will pause immediately)
asyncio.create_task(self._execute_runtime(run))

async def action_debug_step(self) -> None:
"""Step to next breakpoint in debug mode."""
details_panel = self.query_one("#details-panel", RunDetailsPanel)
if details_panel and details_panel.current_run:
run = details_panel.current_run

# Get the debug bridge for this run
debug_bridge = self.run_service.get_debug_bridge(run.id)
if debug_bridge:
# Step mode = break on all nodes
debug_bridge.set_breakpoints("*")
# Resume execution (will pause at next node)
debug_bridge.resume()

async def action_debug_continue(self) -> None:
"""Continue execution without stopping at breakpoints."""
details_panel = self.query_one("#details-panel", RunDetailsPanel)
if details_panel and details_panel.current_run:
run = details_panel.current_run

# Get the debug bridge for this run
debug_bridge = self.run_service.get_debug_bridge(run.id)
if debug_bridge:
# Clear breakpoints = run to completion
debug_bridge.set_breakpoints([])
# Resume execution
debug_bridge.resume()

async def action_debug_stop(self) -> None:
"""Stop debug execution."""
details_panel = self.query_one("#details-panel", RunDetailsPanel)
if details_panel and details_panel.current_run:
run = details_panel.current_run

# Get the debug bridge for this run
debug_bridge = self.run_service.get_debug_bridge(run.id)
if debug_bridge:
# Signal quit
debug_bridge.quit()

async def action_clear_history(self) -> None:
"""Clear run history."""
history_panel = self.query_one("#history-panel", RunHistoryPanel)
Expand Down
2 changes: 2 additions & 0 deletions src/uipath/dev/models/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ def __init__(
entrypoint: str,
input_data: Union[dict[str, Any]],
conversational: bool = False,
debug: bool = False,
):
"""Initialize an ExecutionRun instance."""
self.id = str(uuid4())[:8]
self.entrypoint = entrypoint
self.input_data = input_data
self.conversational = conversational
self.debug = debug
self.resume_data: Optional[dict[str, Any]] = None
self.output_data: Optional[dict[str, Any]] = None
self.start_time = datetime.now()
Expand Down
108 changes: 108 additions & 0 deletions src/uipath/dev/services/debug_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Debug bridge implementation for Textual UI."""

import asyncio
import logging
from typing import Any, Callable, Literal, Optional

from uipath.runtime.debug import UiPathBreakpointResult, UiPathDebugQuitError
from uipath.runtime.events import UiPathRuntimeStateEvent
from uipath.runtime.result import UiPathRuntimeResult

logger = logging.getLogger(__name__)


class TextualDebugBridge:
"""Bridge between Textual UI and UiPathDebugRuntime."""

def __init__(self):
"""Initialize the debug bridge."""
self._connected = False
self._resume_event = asyncio.Event()
self._quit_requested = False
self._breakpoints: list[str] | Literal["*"] = "*" # Default: step mode

# Callbacks to UI
self.on_execution_started: Optional[Callable[[], None]] = None
self.on_state_update: Optional[Callable[[UiPathRuntimeStateEvent], None]] = None
self.on_breakpoint_hit: Optional[Callable[[UiPathBreakpointResult], None]] = (
None
)
self.on_execution_completed: Optional[Callable[[UiPathRuntimeResult], None]] = (
None
)
self.on_execution_error: Optional[Callable[[str], None]] = None

async def connect(self) -> None:
"""Establish connection to debugger."""
self._connected = True
self._quit_requested = False
logger.debug("Debug bridge connected")

async def disconnect(self) -> None:
"""Close connection to debugger."""
self._connected = False
self._resume_event.set() # Unblock any waiting tasks
logger.debug("Debug bridge disconnected")

async def emit_execution_started(self, **kwargs: Any) -> None:
"""Notify debugger that execution started."""
logger.debug("Execution started")
if self.on_execution_started:
self.on_execution_started()

async def emit_state_update(self, state_event: UiPathRuntimeStateEvent) -> None:
"""Notify debugger of runtime state update."""
logger.debug(f"State update: {state_event.node_name}")
if self.on_state_update:
self.on_state_update(state_event)

async def emit_breakpoint_hit(
self, breakpoint_result: UiPathBreakpointResult
) -> None:
"""Notify debugger that a breakpoint was hit."""
logger.debug(f"Breakpoint hit: {breakpoint_result}")
if self.on_breakpoint_hit:
self.on_breakpoint_hit(breakpoint_result)

async def emit_execution_completed(
self, runtime_result: UiPathRuntimeResult
) -> None:
"""Notify debugger that execution completed."""
logger.debug("Execution completed")
if self.on_execution_completed:
self.on_execution_completed(runtime_result)

async def emit_execution_error(self, error: str) -> None:
"""Notify debugger that an error occurred."""
logger.error(f"Execution error: {error}")
if self.on_execution_error:
self.on_execution_error(error)

async def wait_for_resume(self) -> Any:
"""Wait for resume command from debugger.

Raises:
UiPathDebugQuitError: If quit was requested
"""
self._resume_event.clear()
await self._resume_event.wait()

if self._quit_requested:
raise UiPathDebugQuitError("Debug session quit requested")

def resume(self) -> None:
"""Signal that execution should resume (called from UI buttons)."""
self._resume_event.set()

def quit(self) -> None:
"""Signal that execution should quit (called from UI stop button)."""
self._quit_requested = True
self._resume_event.set()

def get_breakpoints(self) -> list[str] | Literal["*"]:
"""Get nodes to suspend execution at."""
return self._breakpoints

def set_breakpoints(self, breakpoints: list[str] | Literal["*"]) -> None:
"""Set breakpoints (called from UI)."""
self._breakpoints = breakpoints
53 changes: 51 additions & 2 deletions src/uipath/dev/services/run_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@
UiPathExecuteOptions,
UiPathExecutionRuntime,
UiPathRuntimeFactoryProtocol,
UiPathRuntimeProtocol,
UiPathRuntimeStatus,
)
from uipath.runtime.debug import UiPathDebugRuntime
from uipath.runtime.errors import UiPathErrorContract, UiPathRuntimeError

from uipath.dev.infrastructure import RunContextExporter, RunContextLogHandler
from uipath.dev.models import ExecutionRun, LogMessage, TraceMessage
from uipath.dev.services.debug_bridge import TextualDebugBridge

RunUpdatedCallback = Callable[[ExecutionRun], None]
LogCallback = Callable[[LogMessage], None]
Expand Down Expand Up @@ -58,6 +61,8 @@ def __init__(
batch=False,
)

self.debug_bridges: dict[str, TextualDebugBridge] = {}

def register_run(self, run: ExecutionRun) -> None:
"""Register a new run and emit an initial update."""
self.runs[run.id] = run
Expand Down Expand Up @@ -94,7 +99,32 @@ async def execute(self, run: ExecutionRun) -> None:
callback=self.handle_log,
)

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

runtime: UiPathRuntimeProtocol

if run.debug:
debug_bridge = TextualDebugBridge()

# Connect callbacks
debug_bridge.on_state_update = lambda event: self._handle_state_update(
run.id, event
)
debug_bridge.on_breakpoint_hit = lambda bp: self._handle_breakpoint_hit(
run.id, bp
)

# Store bridge so UI can access it
self.debug_bridges[run.id] = debug_bridge

runtime = UiPathDebugRuntime(
delegate=new_runtime,
debug_bridge=debug_bridge,
)
else:
runtime = new_runtime

execution_runtime = UiPathExecutionRuntime(
delegate=runtime,
Expand All @@ -108,7 +138,7 @@ async def execute(self, run: ExecutionRun) -> None:
if result is not None:
if (
result.status == UiPathRuntimeStatus.SUSPENDED.value
and result.resume
and result.trigger
):
run.status = "suspended"
else:
Expand Down Expand Up @@ -145,6 +175,9 @@ async def execute(self, run: ExecutionRun) -> None:
self.runs[run.id] = run
self._emit_run_updated(run)

if run.id in self.debug_bridges:
del self.debug_bridges[run.id]

def handle_log(self, log_msg: LogMessage) -> None:
"""Entry point for all logs (runtime, traces, stderr)."""
run = self.runs.get(log_msg.run_id)
Expand Down Expand Up @@ -172,6 +205,22 @@ def handle_trace(self, trace_msg: TraceMessage) -> None:
if self.on_trace is not None:
self.on_trace(trace_msg)

def get_debug_bridge(self, run_id: str) -> Optional[TextualDebugBridge]:
"""Get the debug bridge for a run."""
return self.debug_bridges.get(run_id)

def _handle_state_update(self, run_id: str, event) -> None:
"""Handle state update from debug runtime."""
# You can add more logic here later if needed
pass

def _handle_breakpoint_hit(self, run_id: str, bp) -> None:
"""Handle breakpoint hit from debug runtime."""
run = self.runs.get(run_id)
if run:
run.status = "suspended"
self._emit_run_updated(run)

def _emit_run_updated(self, run: ExecutionRun) -> None:
"""Notify observers that a run's state changed."""
self.runs[run.id] = run
Expand Down
6 changes: 6 additions & 0 deletions src/uipath/dev/ui/panels/new_run_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ def compose(self) -> ComposeResult:
variant="primary",
classes="action-btn",
)
yield Button(
"⏯ Debug",
id="debug-btn",
variant="primary",
classes="action-btn",
)

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