From b22bac0308a2ead50d2515ac2ceb37283740ff34 Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Fri, 28 Aug 2026 10:28:41 +0200 Subject: [PATCH 1/2] feat(cli): add 'uipath run --server-mode' for the pooled coded-agent lane Keep the uipath run process alive and serve subsequent jobs over uipath-ipc (no HTTP channel, no ready-ACK), surfacing each job's real exit code. Gated behind --server-mode via a default-off surface_exit_code param, so `uipath server` and the Low-Code path stay unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_server_core.py | 13 ++- packages/uipath/src/uipath/_cli/cli_run.py | 26 +++++- packages/uipath/src/uipath/_cli/cli_server.py | 16 ++++ .../uipath/src/uipath/_cli/cli_server_ipc.py | 13 ++- packages/uipath/tests/cli/test_server_ipc.py | 91 ++++++++++++++++++- .../uipath/tests/cli/test_server_job_core.py | 32 +++++++ packages/uipath/uv.lock | 2 +- 8 files changed, 185 insertions(+), 10 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 9305fe497..f5ca8f457 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.10" +version = "2.14.11" 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" diff --git a/packages/uipath/src/uipath/_cli/_server_core.py b/packages/uipath/src/uipath/_cli/_server_core.py index c426126ff..4d0cd433f 100644 --- a/packages/uipath/src/uipath/_cli/_server_core.py +++ b/packages/uipath/src/uipath/_cli/_server_core.py @@ -45,11 +45,19 @@ def parse_args(args: str | list[str] | None) -> list[str]: return [] +def _surfaced_exit_code(result_value: Any, surface_exit_code: bool) -> int: + """The command's own int return code when surfacing is enabled; else 0.""" + if surface_exit_code and isinstance(result_value, int): + return result_value + return 0 + + async def _run_command_isolated( cmd: Any, args: list[str], env_vars: dict[str, str], working_dir: str | None, + surface_exit_code: bool = False, ) -> dict[str, Any]: """Run one command with per-job env/cwd isolation (the shared job core).""" if _state.lock is None or _state.baseline_env is None: @@ -82,9 +90,10 @@ async def _run_command_isolated( result_value = await asyncio.to_thread( cmd.main, args, standalone_mode=False ) + exit_code = _surfaced_exit_code(result_value, surface_exit_code) return { - "ExitCode": 0, - "Error": None, + "ExitCode": exit_code, + "Error": None if exit_code == 0 else f"Exit code: {exit_code}", "Result": result_value, "Unexpected": False, } diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9d12a86c3..552ce3bd8 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -115,6 +115,18 @@ def get_usage_help(self) -> list[str]: default=None, help="Simulation config as a JSON object (same schema as simulation.json)", ) +@click.option( + "--server-mode", + is_flag=True, + help="Serve jobs over uipath-ipc and stay alive (requires --ipc-pipe), " + "instead of running once and exiting.", +) +@click.option( + "--ipc-pipe", + type=str, + default=None, + help="Named pipe for the uipath-ipc channel (used with --server-mode).", +) @track_command("run") def run( entrypoint: str | None, @@ -129,8 +141,20 @@ def run( debug_port: int, keep_state_file: bool, simulation: str | None, + server_mode: bool, + ipc_pipe: str | None, ) -> None: - """Execute the project.""" + """Execute the project, or serve jobs over uipath-ipc with --server-mode.""" + if server_mode: + if not ipc_pipe: + console.error("--server-mode requires --ipc-pipe.") + # cli_server -> _server_core -> cli_run cycles at import time; defer it. + from .cli_server import preload_modules, run_ipc_server + + preload_modules() + run_ipc_server(ipc_pipe, surface_exit_code=True) + return + input_file = file or input_file # Setup debugging if requested diff --git a/packages/uipath/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py index dc5e31bae..f9ea96733 100644 --- a/packages/uipath/src/uipath/_cli/cli_server.py +++ b/packages/uipath/src/uipath/_cli/cli_server.py @@ -31,6 +31,7 @@ __all__ = [ "server", + "run_ipc_server", "IPythonRuntimeServer", "PythonRuntimeService", "PythonServerRunJobResult", @@ -399,3 +400,18 @@ def _run_server( asyncio.run(coro) except KeyboardInterrupt: console.info("Shutting down") + + +def run_ipc_server(ipc_pipe: str, surface_exit_code: bool = False) -> None: + """Serve only the uipath-ipc channel, staying alive until the pipe closes. + + Used by ``uipath run --server-mode``: no HTTP channel, no ready-ACK socket. + """ + try: + if sys.platform == "win32": # pragma: no cover + with asyncio.Runner(loop_factory=asyncio.ProactorEventLoop) as runner: + runner.run(start_ipc_server(ipc_pipe, surface_exit_code)) + else: # pragma: no cover + asyncio.run(start_ipc_server(ipc_pipe, surface_exit_code)) + except KeyboardInterrupt: # pragma: no cover + console.info("Shutting down") diff --git a/packages/uipath/src/uipath/_cli/cli_server_ipc.py b/packages/uipath/src/uipath/_cli/cli_server_ipc.py index d099f1e23..2d360c297 100644 --- a/packages/uipath/src/uipath/_cli/cli_server_ipc.py +++ b/packages/uipath/src/uipath/_cli/cli_server_ipc.py @@ -57,6 +57,9 @@ async def StopJob(self, request: PythonServerStopJobRequest) -> bool: class PythonRuntimeService(IPythonRuntimeServer): """``IPythonRuntimeServer`` implementation backed by run/debug/eval.""" + def __init__(self, surface_exit_code: bool = False) -> None: + self._surface_exit_code = surface_exit_code + async def Register(self) -> bool: console.info("Runtime client registered.") return True @@ -81,7 +84,11 @@ async def RunJob(self, request: PythonServerRunRequest) -> PythonServerRunJobRes ) result = await _run_command_isolated( - cmd, args, request.EnvironmentVariables, request.WorkingDirectory + cmd, + args, + request.EnvironmentVariables, + request.WorkingDirectory, + surface_exit_code=self._surface_exit_code, ) # IPC contract (PythonServerRunJobResult) carries only ExitCode + Error. return PythonServerRunJobResult( @@ -96,7 +103,7 @@ async def StopJob(self, request: PythonServerStopJobRequest) -> bool: return True -async def start_ipc_server(pipe_name: str) -> None: +async def start_ipc_server(pipe_name: str, surface_exit_code: bool = False) -> None: """Serve the Python runtime over a uipath-ipc named pipe until it is closed.""" try: from uipath_ipc import IpcServer, NamedPipeServerTransport @@ -110,7 +117,7 @@ async def start_ipc_server(pipe_name: str) -> None: _state.init() server = IpcServer( transport=NamedPipeServerTransport(pipe_name), - services={IPythonRuntimeServer: PythonRuntimeService()}, + services={IPythonRuntimeServer: PythonRuntimeService(surface_exit_code)}, request_timeout=None, # jobs are long-running; no server-side timeout ) console.success(f"IPC server listening on pipe '{pipe_name}'") diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py index 801920dd2..d6923956b 100644 --- a/packages/uipath/tests/cli/test_server_ipc.py +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -44,7 +44,7 @@ def _unique_pipe() -> str: return f"uipath-ipc-test-{os.getpid()}-{_pipe_counter}" -def _serve_in_background(pipe_name: str) -> None: +def _serve_in_background(pipe_name: str, surface_exit_code: bool = False) -> None: """Run the IPC server on its own event loop in a daemon thread. ``asyncio.new_event_loop()`` yields the per-OS default loop — Proactor on @@ -55,7 +55,7 @@ def run_server() -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - loop.run_until_complete(start_ipc_server(pipe_name)) + loop.run_until_complete(start_ipc_server(pipe_name, surface_exit_code)) except asyncio.CancelledError: pass finally: @@ -329,3 +329,90 @@ async def drive(proxy: Any) -> None: assert stop_request.JobKey == job_key assert stop_request.ResumeVersion == 5 assert stop_request.ForceStop is True + + +class TestIpcServerExitCode: + """surface_exit_code makes the IPC server report a command's non-zero exit + code; the default reports 0.""" + + @pytest.fixture + def fail_command(self): + @click.command() + def fail_cmd() -> None: + click.get_current_context().exit(7) + + original = _server_core.COMMANDS.copy() + _server_core.COMMANDS["fail"] = fail_cmd + try: + yield + finally: + _server_core.COMMANDS.clear() + _server_core.COMMANDS.update(original) + + def test_surface_exit_code_reports_nonzero(self, fail_command): + pipe_name = _unique_pipe() + _serve_in_background(pipe_name, surface_exit_code=True) + result = asyncio.run( + _with_proxy( + pipe_name, lambda p: p.RunJob({"JobKey": "j", "Command": "fail"}) + ) + ) + assert result.ExitCode == 7 + + def test_default_reports_zero(self, fail_command): + pipe_name = _unique_pipe() + _serve_in_background(pipe_name) + result = asyncio.run( + _with_proxy( + pipe_name, lambda p: p.RunJob({"JobKey": "j", "Command": "fail"}) + ) + ) + assert result.ExitCode == 0 + + +class TestRunServerModeGuard: + """`uipath run --server-mode` requires --ipc-pipe.""" + + def test_server_mode_requires_ipc_pipe(self): + from click.testing import CliRunner + + from uipath._cli.cli_run import run + + result = CliRunner().invoke(run, ["--server-mode"]) + assert result.exit_code == 1 + + +class TestServerModeWiring: + """`uipath run --server-mode` defers to run_ipc_server, which drives start_ipc_server.""" + + def test_run_ipc_server_drives_start_ipc_server(self, monkeypatch): + from uipath._cli import cli_server + + captured: dict[str, Any] = {} + + async def fake_start(pipe, surface_exit_code=False): + captured["pipe"] = pipe + captured["surface"] = surface_exit_code + + monkeypatch.setattr(cli_server, "start_ipc_server", fake_start) + cli_server.run_ipc_server("pipe-1", surface_exit_code=True) + assert captured == {"pipe": "pipe-1", "surface": True} + + def test_run_server_mode_defers_to_ipc_server(self, monkeypatch): + from click.testing import CliRunner + + from uipath._cli import cli_server + from uipath._cli.cli_run import run + + captured: dict[str, Any] = {} + monkeypatch.setattr(cli_server, "preload_modules", lambda: None) + monkeypatch.setattr( + cli_server, + "run_ipc_server", + lambda pipe, surface_exit_code=False: captured.update( + pipe=pipe, surface=surface_exit_code + ), + ) + result = CliRunner().invoke(run, ["--server-mode", "--ipc-pipe", "mypipe"]) + assert result.exit_code == 0 + assert captured == {"pipe": "mypipe", "surface": True} diff --git a/packages/uipath/tests/cli/test_server_job_core.py b/packages/uipath/tests/cli/test_server_job_core.py index 1722d0c5b..4a11fae75 100644 --- a/packages/uipath/tests/cli/test_server_job_core.py +++ b/packages/uipath/tests/cli/test_server_job_core.py @@ -68,6 +68,38 @@ async def test_reports_unexpected_exception(restore_state: Any) -> None: assert "boom" in result["Error"] +async def test_surface_exit_code_reports_nonzero_return(restore_state: Any) -> None: + _init(restore_state) + cmd = Mock() + cmd.main.return_value = 3 + result = await _server_core._run_command_isolated( + cmd, [], {}, None, surface_exit_code=True + ) + assert result["ExitCode"] == 3 + assert result["Error"] == "Exit code: 3" + assert result["Unexpected"] is False + + +async def test_surface_exit_code_zero_return_is_success(restore_state: Any) -> None: + _init(restore_state) + cmd = Mock() + cmd.main.return_value = None + result = await _server_core._run_command_isolated( + cmd, [], {}, None, surface_exit_code=True + ) + assert result["ExitCode"] == 0 + assert result["Error"] is None + + +async def test_default_does_not_surface_nonzero_return(restore_state: Any) -> None: + _init(restore_state) + cmd = Mock() + cmd.main.return_value = 3 + result = await _server_core._run_command_isolated(cmd, [], {}, None) + assert result["ExitCode"] == 0 + assert result["Error"] is None + + # parse_args accepts what every caller sends: the .NET peer sends a single # string (shlex-split), HTTP dicts / tests may send a pre-split list, or None. diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index cf3a541b4..08927fd89 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.10" +version = "2.14.11" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 6caed8091e765b8393535a3d67ca738f9983ca0f Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Mon, 31 Aug 2026 22:22:11 +0200 Subject: [PATCH 2/2] refactor(cli): drop the no-op preload from run --server-mode The pooled server never unloads dependency modules between jobs: one interpreter, shared sys.modules, and the only eviction is the entrypoint's own dynamic_module (functions/runtime.py). Warming the SDK set at boot therefore only ever shaved the first job, and the runtime bootstrap already imports what a job needs. Measured ~0.67s boot cost for ~0 benefit, and it pulled in LowCode-only modules (pysignalr, socketio) the coded IPC lane never uses. The LowCode `uipath server` path keeps its own preload, untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/uipath/src/uipath/_cli/cli_run.py | 3 +-- packages/uipath/tests/cli/test_server_ipc.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 552ce3bd8..d851b6784 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -149,9 +149,8 @@ def run( if not ipc_pipe: console.error("--server-mode requires --ipc-pipe.") # cli_server -> _server_core -> cli_run cycles at import time; defer it. - from .cli_server import preload_modules, run_ipc_server + from .cli_server import run_ipc_server - preload_modules() run_ipc_server(ipc_pipe, surface_exit_code=True) return diff --git a/packages/uipath/tests/cli/test_server_ipc.py b/packages/uipath/tests/cli/test_server_ipc.py index d6923956b..a75e6488f 100644 --- a/packages/uipath/tests/cli/test_server_ipc.py +++ b/packages/uipath/tests/cli/test_server_ipc.py @@ -405,7 +405,6 @@ def test_run_server_mode_defers_to_ipc_server(self, monkeypatch): from uipath._cli.cli_run import run captured: dict[str, Any] = {} - monkeypatch.setattr(cli_server, "preload_modules", lambda: None) monkeypatch.setattr( cli_server, "run_ipc_server",