diff --git a/pyproject.toml b/pyproject.toml index 1b7040c7a..972d241ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath" -version = "2.2.6" +version = "2.2.7" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.0.5, <0.1.0", - "uipath-runtime>=0.0.23, <0.1.0", + "uipath-runtime>=0.1.0, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/src/uipath/_cli/_debug/_bridge.py b/src/uipath/_cli/_debug/_bridge.py index ad49e0d3b..651260c8f 100644 --- a/src/uipath/_cli/_debug/_bridge.py +++ b/src/uipath/_cli/_debug/_bridge.py @@ -2,6 +2,8 @@ import json import logging import os +import signal +from concurrent.futures import ThreadPoolExecutor from enum import Enum from typing import Any, Literal @@ -75,13 +77,20 @@ def __init__(self, verbose: bool = True): self.verbose = verbose self.state = DebuggerState() + self._stdin_executor = ThreadPoolExecutor(max_workers=1) + self._terminate_event: asyncio.Event | None = None + async def connect(self) -> None: - """Connect to console debugger.""" + """Initialize the console debugger.""" + self._terminate_event = asyncio.Event() + signal.signal( + signal.SIGINT, self._handle_sigint + ) # We need to catch CTRL+C during polling self.console.print() self._print_help() async def disconnect(self) -> None: - """Cleanup.""" + """Clean up console debugger.""" self.console.print() self.console.print("[dim]─" * 40) self.console.print("[green]Debug session completed") @@ -116,7 +125,6 @@ async def emit_breakpoint_hit( self.console.print("[red]─" * 40) - # Display current state if breakpoint_result.current_state: self._print_json(breakpoint_result.current_state, label="state") @@ -165,24 +173,31 @@ async def emit_execution_error( self.console.print(f"[red]{error_display}[/red]") self.console.print("[red]─" * 40) - async def wait_for_resume(self) -> Any: + async def wait_for_resume(self) -> dict[str, Any]: """Wait for user to press Enter or type commands.""" while True: # Keep looping until we get a resume command self.console.print() - # Run input() in executor to not block async loop loop = asyncio.get_running_loop() - user_input = await loop.run_in_executor(None, lambda: input("> ")) + future = loop.run_in_executor( + self._stdin_executor, self._read_input_blocking + ) + + try: + user_input = await future + except asyncio.CancelledError: + return {"command": DebugCommand.CONTINUE, "args": None} command_result = self._parse_command(user_input.strip()) # Handle commands that need another prompt - if command_result["command"] in [ + + if command_result["command"] in { DebugCommand.BREAKPOINT, DebugCommand.LIST_BREAKPOINTS, DebugCommand.CLEAR_BREAKPOINT, DebugCommand.HELP, - ]: + }: # These commands don't resume execution, loop again continue @@ -190,18 +205,40 @@ async def wait_for_resume(self) -> Any: self.console.print() return command_result + async def wait_for_terminate(self) -> None: + """Wait until user requests termination (Ctrl+C or 'q').""" + assert self._terminate_event is not None, "Debugger not connected" + await self._terminate_event.wait() + def get_breakpoints(self) -> list[str] | Literal["*"]: """Get nodes to suspend execution at.""" if self.state.step_mode: return "*" # Suspend at all nodes return list(self.state.breakpoints) # Only suspend at breakpoints + def _read_input_blocking(self) -> str: + assert self._terminate_event is not None, "Debugger not connected" + try: + return input("> ") + except KeyboardInterrupt as e: + self._terminate_event.set() + raise UiPathDebugQuitError("User pressed Ctrl+C") from e + except EOFError as e: + self._terminate_event.set() + raise UiPathDebugQuitError("STDIN closed by user") from e + + def _handle_sigint(self, signum: int, frame: Any) -> None: + assert self._terminate_event is not None, "Debugger not connected" + asyncio.get_running_loop().call_soon_threadsafe(self._terminate_event.set) + def _parse_command(self, user_input: str) -> dict[str, Any]: """Parse user command input. Returns: Dict with 'command' and optional 'args' """ + assert self._terminate_event is not None, "Debugger not connected" + if not user_input: return {"command": DebugCommand.CONTINUE, "args": None} @@ -246,6 +283,7 @@ def _parse_command(self, user_input: str) -> dict[str, Any]: } elif cmd in ["q", "quit", "exit"]: + self._terminate_event.set() raise UiPathDebugQuitError("User requested exit") elif cmd in ["h", "help", "?"]: @@ -382,7 +420,7 @@ def __init__( self._client: SignalRClient | None = None self._connected_event = asyncio.Event() self._resume_event: asyncio.Event | None = None - self._quit_event = asyncio.Event() + self._terminate_event = asyncio.Event() async def connect(self) -> None: """Establish SignalR connection.""" @@ -538,10 +576,10 @@ async def wait_for_resume(self) -> None: self._resume_event = asyncio.Event() resume_task = asyncio.create_task(self._resume_event.wait()) - quit_task = asyncio.create_task(self._quit_event.wait()) + terminate_task = asyncio.create_task(self._terminate_event.wait()) done, pending = await asyncio.wait( - {resume_task, quit_task}, return_when=asyncio.FIRST_COMPLETED + {resume_task, terminate_task}, return_when=asyncio.FIRST_COMPLETED ) for task in pending: @@ -551,12 +589,16 @@ async def wait_for_resume(self) -> None: except asyncio.CancelledError: pass - if quit_task in done: - logger.info("Quit command received during wait") - raise UiPathDebugQuitError("Quit command received from server") + if terminate_task in done: + logger.info("Terminate command received during wait") + raise UiPathDebugQuitError("Terminate command received from server") logger.info("Resume command received") + async def wait_for_terminate(self) -> None: + """Wait for terminate command from server.""" + await self._terminate_event.wait() + def get_breakpoints(self) -> list[str] | Literal["*"]: """Get nodes to suspend execution at.""" if self.state.step_mode: @@ -693,7 +735,7 @@ async def _handle_remove_breakpoints(self, args: list[Any]) -> None: async def _handle_quit(self, _args: list[Any]) -> None: """Handle Quit command from SignalR server.""" logger.info("Quit command received") - self._quit_event.set() + self._terminate_event.set() async def _handle_open(self) -> None: """Handle SignalR connection open.""" diff --git a/src/uipath/_cli/cli_debug.py b/src/uipath/_cli/cli_debug.py index 9fbb615d7..40402e1bb 100644 --- a/src/uipath/_cli/cli_debug.py +++ b/src/uipath/_cli/cli_debug.py @@ -112,8 +112,13 @@ async def execute_debug_runtime(): factory: UiPathRuntimeFactoryProtocol | None = None try: + trigger_poll_interval: float = 5.0 + if ctx.job_id: trace_manager.add_span_exporter(LlmOpsHttpExporter()) + trigger_poll_interval = ( + 0.0 # Polling disabled for production jobs + ) factory = UiPathRuntimeFactoryRegistry.get(context=ctx) @@ -126,6 +131,7 @@ async def execute_debug_runtime(): debug_runtime = UiPathDebugRuntime( delegate=runtime, debug_bridge=debug_bridge, + trigger_poll_interval=trigger_poll_interval, ) project_id = UiPathConfig.project_id diff --git a/src/uipath/platform/resume_triggers/_protocol.py b/src/uipath/platform/resume_triggers/_protocol.py index 058e9f4c2..303c16a96 100644 --- a/src/uipath/platform/resume_triggers/_protocol.py +++ b/src/uipath/platform/resume_triggers/_protocol.py @@ -97,19 +97,22 @@ async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: folder_key=trigger.folder_key, folder_path=trigger.folder_path, ) - if ( - job.state - and not job.state.lower() - == UiPathRuntimeStatus.SUCCESSFUL.value.lower() - ): - raise UiPathRuntimeError( - UiPathErrorCode.INVOKED_PROCESS_FAILURE, - "Invoked process did not finish successfully.", - _try_convert_to_json_format(str(job.job_error or job.info)) - or "Job error unavailable.", - ) - output_data = await uipath.jobs.extract_output_async(job) - return _try_convert_to_json_format(output_data) + job_state = (job.state or "").lower() + successful_state = UiPathRuntimeStatus.SUCCESSFUL.value.lower() + faulted_state = UiPathRuntimeStatus.FAULTED.value.lower() + + if job_state == successful_state: + output_data = await uipath.jobs.extract_output_async(job) + return _try_convert_to_json_format(output_data) + + raise UiPathRuntimeError( + UiPathErrorCode.INVOKED_PROCESS_FAILURE, + "Invoked process did not finish successfully.", + _try_convert_to_json_format(str(job.job_error or job.info)) + or "Job error unavailable." + if job_state == faulted_state + else f"Job {job.key} is {job_state}.", + ) case UiPathResumeTriggerType.API: if trigger.api_resume and trigger.api_resume.inbox_id: diff --git a/tests/cli/test_hitl.py b/tests/cli/test_hitl.py index 8272b06c0..a568b6ac2 100644 --- a/tests/cli/test_hitl.py +++ b/tests/cli/test_hitl.py @@ -114,7 +114,9 @@ async def test_read_job_trigger_failed( job_error_info = JobErrorInfo(code="error code") job_id = 1234 - mock_job = Job(id=job_id, key=job_key, state="Failed", job_error=job_error_info) + mock_job = Job( + id=job_id, key=job_key, state="Faulted", job_error=job_error_info + ) mock_retrieve_async = AsyncMock(return_value=mock_job) with patch( diff --git a/uv.lock b/uv.lock index 0f0e1e43c..b5214805b 100644 --- a/uv.lock +++ b/uv.lock @@ -2401,7 +2401,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.6" +version = "2.2.7" source = { editable = "." } dependencies = [ { name = "click" }, @@ -2466,7 +2466,7 @@ requires-dist = [ { name = "tenacity", specifier = ">=9.0.0" }, { name = "truststore", specifier = ">=0.10.1" }, { name = "uipath-core", specifier = ">=0.0.5,<0.1.0" }, - { name = "uipath-runtime", specifier = ">=0.0.23,<0.1.0" }, + { name = "uipath-runtime", specifier = ">=0.1.0,<0.2.0" }, ] [package.metadata.requires-dev] @@ -2512,14 +2512,14 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.0.23" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/fa/0813f32db1127b6a958f478b537dc864f5d16eadd640b21bef31081c0bca/uipath_runtime-0.0.23.tar.gz", hash = "sha256:59804772dadb5344fc12d785e04ed382db7205f1233dffa00c6ad34440f8477e", size = 88070, upload-time = "2025-11-28T14:58:30.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/5f/b48eaa87501ccffb067878ff99773fa89fbc1cafa8ab736d8fd5ae099b59/uipath_runtime-0.1.0.tar.gz", hash = "sha256:2a262eb29faeb1d62158ccaf1d1ec44752813625fdbcab5671a625c999c433ac", size = 87980, upload-time = "2025-11-29T13:10:32.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/6f/2da25d54fc2145376c4dff9ea62604392ee75222d3a1b87e30e261f83097/uipath_runtime-0.0.23-py3-none-any.whl", hash = "sha256:dcd65ca7a1ebe421f49213834361ec2ccdeb81b5c57326f4a7e1c27669c92e68", size = 34299, upload-time = "2025-11-28T14:58:29.385Z" }, + { url = "https://files.pythonhosted.org/packages/84/2a/39364e985269ac27b6b78d0323e6f516e25287bca87938a2088442b5cf06/uipath_runtime-0.1.0-py3-none-any.whl", hash = "sha256:997b53737fc6f22bb2e80700fd45c6b0a7912af6843fd4a103c58bdcf30f7fdd", size = 34207, upload-time = "2025-11-29T13:10:31.127Z" }, ] [[package]]