diff --git a/pyproject.toml b/pyproject.toml index be5fed7..c9a4cd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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 diff --git a/src/uipath/dev/__init__.py b/src/uipath/dev/__init__.py index be896e8..46bbe01 100644 --- a/src/uipath/dev/__init__.py +++ b/src/uipath/dev/__init__.py @@ -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.""" @@ -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) diff --git a/src/uipath/dev/models/execution.py b/src/uipath/dev/models/execution.py index 6d67200..74ddab3 100644 --- a/src/uipath/dev/models/execution.py +++ b/src/uipath/dev/models/execution.py @@ -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() diff --git a/src/uipath/dev/services/debug_bridge.py b/src/uipath/dev/services/debug_bridge.py new file mode 100644 index 0000000..16fe4e9 --- /dev/null +++ b/src/uipath/dev/services/debug_bridge.py @@ -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 diff --git a/src/uipath/dev/services/run_service.py b/src/uipath/dev/services/run_service.py index e084d30..cf54fc1 100644 --- a/src/uipath/dev/services/run_service.py +++ b/src/uipath/dev/services/run_service.py @@ -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] @@ -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 @@ -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, @@ -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: @@ -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) @@ -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 diff --git a/src/uipath/dev/ui/panels/new_run_panel.py b/src/uipath/dev/ui/panels/new_run_panel.py index 1a198a5..8d67ef6 100644 --- a/src/uipath/dev/ui/panels/new_run_panel.py +++ b/src/uipath/dev/ui/panels/new_run_panel.py @@ -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.""" diff --git a/src/uipath/dev/ui/panels/run_details_panel.py b/src/uipath/dev/ui/panels/run_details_panel.py index 4f27691..3bf9999 100644 --- a/src/uipath/dev/ui/panels/run_details_panel.py +++ b/src/uipath/dev/ui/panels/run_details_panel.py @@ -5,7 +5,13 @@ from textual.app import ComposeResult from textual.containers import Container, Horizontal, Vertical from textual.reactive import reactive -from textual.widgets import RichLog, TabbedContent, TabPane, Tree +from textual.widgets import ( + Button, + RichLog, + TabbedContent, + TabPane, + Tree, +) from textual.widgets.tree import TreeNode from uipath.dev.models.execution import ExecutionRun @@ -22,7 +28,7 @@ def compose(self) -> ComposeResult: max_lines=1000, highlight=True, markup=True, - classes="detail-log", + classes="span-detail-log", ) def show_span_details(self, trace_msg: TraceMessage): @@ -119,6 +125,24 @@ def compose(self) -> ComposeResult: markup=True, classes="detail-log", ) + # Global debug controls (hidden by default, shown when debug mode active) + with Container(id="debug-controls", classes="debug-controls hidden"): + with Horizontal(classes="debug-actions-row"): + yield Button( + "▶ Step", + id="debug-step-btn", + variant="primary", + classes="action-btn", + ) + yield Button( + "⏭ Continue", + id="debug-continue-btn", + variant="default", + classes="action-btn", + ) + yield Button( + "⏹ Stop", id="debug-stop-btn", variant="error", classes="action-btn" + ) def watch_current_run( self, old_value: Optional[ExecutionRun], new_value: Optional[ExecutionRun] @@ -135,6 +159,8 @@ def update_run(self, run: ExecutionRun): def show_run(self, run: ExecutionRun): """Display traces and logs for a specific run.""" + self.update_debug_controls_visibility(run.debug) + # Populate run details tab self._show_run_details(run) @@ -152,6 +178,14 @@ def switch_tab(self, tab_id: str) -> None: tabbed = self.query_one(TabbedContent) tabbed.active = tab_id + def update_debug_controls_visibility(self, show: bool): + """Show or hide debug controls based on whether run is in debug mode.""" + debug_controls = self.query_one("#debug-controls", Container) + if show: + debug_controls.remove_class("hidden") + else: + debug_controls.add_class("hidden") + def _flatten_values(self, value: object, prefix: str = "") -> list[str]: """Flatten nested dict/list structures into dot-notation paths.""" lines: list[str] = [] diff --git a/src/uipath/dev/ui/styles/terminal.tcss b/src/uipath/dev/ui/styles/terminal.tcss index 0f9ded6..4980c4a 100644 --- a/src/uipath/dev/ui/styles/terminal.tcss +++ b/src/uipath/dev/ui/styles/terminal.tcss @@ -103,6 +103,12 @@ Screen { height: 1fr; padding: 1; padding-top: 0; + margin-bottom: 1; +} +.span-detail-log { + height: 1fr; + padding: 1; + padding-top: 0; } .status-running { @@ -144,6 +150,7 @@ TabPane { .traces-content { height: 100%; + margin-bottom: 1; } .spans-tree-section { @@ -259,3 +266,16 @@ Response, Tool { Checkbox{ margin-top: 1; } + +#debug-controls { + height: auto; + dock: bottom; +} + +.debug-actions-row { + height: 2; + width: 100%; + align: left middle; +} + + diff --git a/uv.lock b/uv.lock index 017675d..6bc3438 100644 --- a/uv.lock +++ b/uv.lock @@ -875,7 +875,7 @@ wheels = [ [[package]] name = "textual" -version = "6.5.0" +version = "6.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -885,9 +885,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/90/59757aa887ddcea61428820274f1a2d1f986feb7880374a5420ab5d37132/textual-6.5.0.tar.gz", hash = "sha256:e5f152cdd47db48a635d23b839721bae4d0e8b6d855e3fede7285218289294e3", size = 1574116, upload-time = "2025-10-31T17:21:53.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/2f/f0b408f227edca21d1996c1cd0b65309f0cbff44264aa40aded3ff9ce2e1/textual-6.6.0.tar.gz", hash = "sha256:53345166d6b0f9fd028ed0217d73b8f47c3a26679a18ba3b67616dcacb470eec", size = 1579327, upload-time = "2025-11-10T17:50:00.038Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/37/1deba011782a49ea249c73adcf703a39b0249ac9b0e17d1a2e4074df8d57/textual-6.5.0-py3-none-any.whl", hash = "sha256:c5505be7fe606b8054fb88431279885f88352bddca64832f6acd293ef7d9b54f", size = 711848, upload-time = "2025-10-31T17:21:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/53/b3/95ab646b0c908823d71e49ab8b5949ec9f33346cee3897d1af6be28a8d91/textual-6.6.0-py3-none-any.whl", hash = "sha256:5a9484bd15ee8a6fd8ac4ed4849fb25ee56bed2cecc7b8a83c4cd7d5f19515e5", size = 712606, upload-time = "2025-11-10T17:49:58.391Z" }, ] [[package]] @@ -988,16 +988,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.0.3" +version = "0.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/1e/21f8280862ee24a05170873fb70d2e671301b624861c62896f42e6014628/uipath_core-0.0.3.tar.gz", hash = "sha256:d8afb8f3e4784c823a3bd109e822b11109919915f4cc21f7ef4c873c8320ee79", size = 77101, upload-time = "2025-11-08T01:34:38.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/77/b380db2902f1bf49d5f82eb8d7bc9d321b737a586b56eac35bb8460b91cb/uipath_core-0.0.4.tar.gz", hash = "sha256:e35949ae45e3752970add7160cc0f7ec8c523ec2535134b25b20eea17ec8fec7", size = 76950, upload-time = "2025-11-11T14:12:48.381Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/6c/bb5c7e6d182cd43da451301b8d6675d7f3f0680d5a23530ab1cf5a6a2da3/uipath_core-0.0.3-py3-none-any.whl", hash = "sha256:850232fb8b6a927e1563a537e31370d19b3e8250d7f611425dd2b6c8dcd808c7", size = 12759, upload-time = "2025-11-08T01:34:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/82/6c/1889fcf99063fbcaac4678de29696eb1d04f2f8e2c3bbbc20f8dbdfff0a9/uipath_core-0.0.4-py3-none-any.whl", hash = "sha256:05798f1a31f4569f0ae322591d815b6be26276d52869b5d94a58e1a84f590f9e", size = 12335, upload-time = "2025-11-11T14:12:46.808Z" }, ] [[package]] @@ -1027,9 +1027,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "pyperclip", specifier = ">=1.11.0" }, - { name = "textual", specifier = ">=6.5.0" }, - { name = "uipath-runtime", specifier = ">=0.0.7,<0.1.0" }, + { name = "pyperclip", specifier = "==1.11.0" }, + { name = "textual", specifier = "==6.6.0" }, + { name = "uipath-runtime", specifier = "==0.0.11" }, ] [package.metadata.requires-dev] @@ -1049,17 +1049,15 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.0.7" +version = "0.0.11" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, { name = "pydantic" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/69/a06b0dc0072ad8c8d40084899fd7c4ee25b1ee4ae5c6529a74d759625355/uipath_runtime-0.0.7.tar.gz", hash = "sha256:5569466d125be30b90982bf2396635b50450d8bcaee88bf5de3556eb4810b650", size = 81197, upload-time = "2025-11-10T09:32:26.619Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/9b/ea5235d7caaf3437bd0f58f1b53a638ab84bc4428e47ddac48877173cfd2/uipath_runtime-0.0.11.tar.gz", hash = "sha256:da50c2bc2cff25f70e46ce3e3e07ee02a286b7d0e0b4e469236c7367e0e6c249", size = 84699, upload-time = "2025-11-20T17:20:00.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/60/6af437fc8d7914f292300487252dffecdd6b79f69dda8b7f22e4c0526dd6/uipath_runtime-0.0.7-py3-none-any.whl", hash = "sha256:c19ae2e7283961d7a69319b70de7196132d0439722793bd8ccbe66684fe48736", size = 26808, upload-time = "2025-11-10T09:32:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/4a0697e7589587c1265e331dca231b9b24c65201ae27c3c1e77f485bbed1/uipath_runtime-0.0.11-py3-none-any.whl", hash = "sha256:30df893da9bed35d3c7c4792617cfeae2449e95a0a66385f9118c9468f14f429", size = 32204, upload-time = "2025-11-20T17:19:59.076Z" }, ] [[package]]