From de581a502df3e9448034f7a7980d01652c7c0e24 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Wed, 5 Aug 2026 12:37:08 +0300 Subject: [PATCH] feat: hydrate managed workspaces in cli  Conflicts:  packages/uipath/pyproject.toml  packages/uipath/src/uipath/_cli/cli_debug.py  packages/uipath/uv.lock --- packages/uipath/pyproject.toml | 2 +- .../src/uipath/_cli/_managed_workspace.py | 66 ++++ packages/uipath/src/uipath/_cli/cli_debug.py | 146 +++++--- packages/uipath/src/uipath/_cli/cli_run.py | 51 ++- .../uipath/tests/cli/test_debug_simulation.py | 198 ++++++++++ .../tests/cli/test_managed_workspace.py | 123 +++++++ packages/uipath/tests/cli/test_run.py | 343 ++++++++++++++++++ packages/uipath/uv.lock | 2 +- 8 files changed, 858 insertions(+), 73 deletions(-) create mode 100644 packages/uipath/src/uipath/_cli/_managed_workspace.py create mode 100644 packages/uipath/tests/cli/test_managed_workspace.py diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 599b65f33..f052cb0fd 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.14.1" +version = "2.15.0" 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/_managed_workspace.py b/packages/uipath/src/uipath/_cli/_managed_workspace.py new file mode 100644 index 000000000..e30471bad --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_managed_workspace.py @@ -0,0 +1,66 @@ +from contextlib import AsyncExitStack + +from uipath.platform import UiPath +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathRuntimeContext, + UiPathRuntimeFactoryProtocol, + UiPathRuntimeProtocol, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, +) + + +async def wrap_with_managed_workspace( + delegate: UiPathRuntimeProtocol, + *, + context: UiPathRuntimeContext, + factory: UiPathRuntimeFactoryProtocol, + enabled: bool, + cleanup: AsyncExitStack, +) -> UiPathRuntimeProtocol: + if context.job_id is None or not enabled: + return delegate + + storage = await factory.get_storage() + if storage is None: + raise RuntimeError( + "Runtime factory advertises managed workspace support but provides no storage" + ) + + client = UiPath() + workspace = Workspace.create() + try: + workspace.path = workspace.path.resolve() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=client.attachments, + jobs=client.jobs, + current_job_key=context.job_id, + folder_key=context.folder_key, + ) + registry_store = WorkspaceRegistryStore(storage, context.job_id) + hydration_runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=hydrator, + registry_store=registry_store, + ) + + if context.conversation_id is None or context.exchange_id is None: + cleanup.push_async_callback(hydration_runtime.dispose) + return hydration_runtime + + conversational_runtime = ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + registry_store=registry_store, + ) + cleanup.push_async_callback(hydration_runtime.dispose) + cleanup.push_async_callback(conversational_runtime.dispose) + return conversational_runtime + except BaseException: + await workspace.dispose() + raise diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 7d7eceba2..08df3a106 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -1,5 +1,6 @@ import asyncio import logging +from contextlib import AsyncExitStack from typing import Any, cast, get_args import click @@ -29,6 +30,7 @@ from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter from ._governance_bootstrap import GovernanceBootstrap, resolve_governance +from ._managed_workspace import wrap_with_managed_workspace from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -196,82 +198,108 @@ async def execute_debug_runtime(): async def execute_debug_runtime(): chat_runtime: UiPathRuntimeProtocol | None = None - debug_bridge: UiPathDebugProtocol = get_debug_bridge( - ctx, attach=attach_mode - ) - new_runtime_kwargs: dict[str, Any] = {} - if governance_bootstrap is not None: - new_runtime_kwargs["evaluator"] = ( - governance_bootstrap.evaluator + managed_workspace_created = False + managed_workspace_cleanup = AsyncExitStack() + debug_runtime: UiPathRuntimeProtocol | None = None + execution_runtime: UiPathRuntimeProtocol | None = None + runtime: UiPathRuntimeProtocol | None = None + try: + debug_bridge: UiPathDebugProtocol = get_debug_bridge( + ctx, attach=attach_mode + ) + new_runtime_kwargs: dict[str, Any] = {} + if governance_bootstrap is not None: + new_runtime_kwargs["evaluator"] = ( + governance_bootstrap.evaluator + ) + runtime = await factory.new_runtime( + entrypoint, + governance_runtime_id, + **new_runtime_kwargs, ) - runtime = await factory.new_runtime( - entrypoint, - governance_runtime_id, - **new_runtime_kwargs, - ) - if governance_bootstrap is not None: - runtime = governance_bootstrap.wrap_runtime( + if governance_bootstrap is not None: + runtime = governance_bootstrap.wrap_runtime( + runtime, + agent_name=entrypoint, + runtime_id=governance_runtime_id, + ) + + delegate = runtime + delegate = await wrap_with_managed_workspace( runtime, - agent_name=entrypoint, - runtime_id=governance_runtime_id, + context=ctx, + factory=factory, + enabled=bool( + factory_settings + and factory_settings.managed_workspace + ), + cleanup=managed_workspace_cleanup, ) + managed_workspace_created = delegate is not runtime - delegate = runtime - if ctx.conversation_id and ctx.exchange_id: - chat_bridge: UiPathChatProtocol = get_chat_bridge( - context=ctx - ) - chat_runtime = UiPathChatRuntime( - delegate=delegate, chat_bridge=chat_bridge - ) - delegate = chat_runtime + if ctx.conversation_id and ctx.exchange_id: + chat_bridge: UiPathChatProtocol = get_chat_bridge( + context=ctx + ) + chat_runtime = UiPathChatRuntime( + delegate=delegate, chat_bridge=chat_bridge + ) + delegate = chat_runtime - debug_runtime = UiPathDebugRuntime( - delegate=delegate, - debug_bridge=debug_bridge, - trigger_poll_interval=trigger_poll_interval, - ) + debug_runtime = UiPathDebugRuntime( + delegate=delegate, + debug_bridge=debug_bridge, + trigger_poll_interval=trigger_poll_interval, + ) - # Build mocking context with agent model for simulations - schema = await runtime.get_schema() - agent_model = None - if schema.metadata and "settings" in schema.metadata: - agent_model = schema.metadata["settings"].get("model") + schema = await runtime.get_schema() + agent_model = None + if schema.metadata and "settings" in schema.metadata: + agent_model = schema.metadata["settings"].get( + "model" + ) - delegate_runtime: UiPathDebugRuntime | UiPathMockRuntime = ( - debug_runtime - ) - if simulation_config: - mocking_context = build_mocking_context( - simulation_config, agent_model - ) - if mocking_context: - delegate_runtime = UiPathMockRuntime( + if simulation_config: + mocking_context = build_mocking_context( + simulation_config, agent_model + ) + if mocking_context: + execution_runtime = UiPathMockRuntime( + delegate=debug_runtime, + mocking_context=mocking_context, + ) + else: + mocking_context = load_simulation_config( + agent_model=agent_model + ) + execution_runtime = UiPathMockRuntime( delegate=debug_runtime, mocking_context=mocking_context, ) - else: - mocking_context = load_simulation_config( - agent_model=agent_model - ) - delegate_runtime = UiPathMockRuntime( - delegate=debug_runtime, - mocking_context=mocking_context, - ) - try: - ctx.result = await delegate_runtime.execute( + awaitable_runtime = execution_runtime or debug_runtime + ctx.result = await awaitable_runtime.execute( ctx.get_input(), options=UiPathExecuteOptions(resume=resume), ) finally: - if delegate_runtime is not debug_runtime: - await delegate_runtime.dispose() - await debug_runtime.dispose() + cleanup = AsyncExitStack() + if not managed_workspace_created: + if runtime is not None: + cleanup.push_async_callback(runtime.dispose) + cleanup.push_async_callback( + managed_workspace_cleanup.aclose + ) if chat_runtime: - await chat_runtime.dispose() - await runtime.dispose() + cleanup.push_async_callback(chat_runtime.dispose) + if debug_runtime is not None: + cleanup.push_async_callback(debug_runtime.dispose) + if execution_runtime is not None: + cleanup.push_async_callback( + execution_runtime.dispose + ) + await cleanup.aclose() if project_id := UiPathConfig.project_id: studio_client = StudioClient(project_id) diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9d12a86c3..3a1a94ce7 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import AsyncExitStack from typing import Any import click @@ -36,6 +37,7 @@ from ._errors import EntrypointDiscoveryException from ._governance_bootstrap import GovernanceBootstrap, resolve_governance +from ._managed_workspace import wrap_with_managed_workspace from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -218,7 +220,10 @@ async def execute() -> None: with ExecutionSourceContext(ctx.execution_source), ctx: base_runtime: UiPathRuntimeProtocol | None = None runtime: UiPathRuntimeProtocol | None = None + workspace_delegate: UiPathRuntimeProtocol | None = None chat_runtime: UiPathRuntimeProtocol | None = None + managed_workspace_created = False + managed_workspace_cleanup = AsyncExitStack() factory: UiPathRuntimeFactoryProtocol | None = None governance_bootstrap: GovernanceBootstrap | None = None try: @@ -293,6 +298,21 @@ async def execute() -> None: mocking_context=mocking_context, ) + workspace_delegate = runtime + runtime = await wrap_with_managed_workspace( + workspace_delegate, + context=ctx, + factory=factory, + enabled=bool( + factory_settings + and factory_settings.managed_workspace + ), + cleanup=managed_workspace_cleanup, + ) + managed_workspace_created = ( + runtime is not workspace_delegate + ) + if ctx.job_id: if UiPathConfig.is_tracing_enabled: trace_manager.add_span_processor( @@ -316,19 +336,26 @@ async def execute() -> None: else: ctx.result = await debug_runtime(ctx, runtime) finally: - try: - if chat_runtime: - await chat_runtime.dispose() + cleanup = AsyncExitStack() + cleanup.callback(trace_manager.shutdown) + if factory: + cleanup.push_async_callback(factory.dispose) + if governance_bootstrap is not None: + cleanup.callback(governance_bootstrap.dispose) + if base_runtime is not None and ( + not managed_workspace_created + or workspace_delegate is not base_runtime + ): + cleanup.push_async_callback(base_runtime.dispose) + if not managed_workspace_created: if runtime is not None and runtime is not base_runtime: - await runtime.dispose() - if base_runtime is not None: - await base_runtime.dispose() - if governance_bootstrap is not None: - governance_bootstrap.dispose() - if factory: - await factory.dispose() - finally: - trace_manager.shutdown() + cleanup.push_async_callback(runtime.dispose) + cleanup.push_async_callback( + managed_workspace_cleanup.aclose + ) + if chat_runtime: + cleanup.push_async_callback(chat_runtime.dispose) + await cleanup.aclose() asyncio.run(execute()) diff --git a/packages/uipath/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py index 9185d98c6..af3f469f5 100644 --- a/packages/uipath/tests/cli/test_debug_simulation.py +++ b/packages/uipath/tests/cli/test_debug_simulation.py @@ -19,6 +19,11 @@ LLMMockingStrategy, MockingContext, ) +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathRuntimeFactorySettings, +) MOCK_RUNTIME_PATCH_PATH = "uipath.eval.mocks._mock_runtime" @@ -336,6 +341,199 @@ def test_debug_wraps_with_mock_runtime_on_error( # Verify UiPathMockRuntime was still instantiated assert mock_mock_runtime_class.called + def test_installs_workspace_runtimes_in_debug_chain( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock( + get_schema=AsyncMock(return_value=Mock(metadata=None)), + dispose=AsyncMock(), + ) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + client = Mock(attachments=Mock(), jobs=Mock()) + chat_runtime = Mock(dispose=AsyncMock()) + debug_runtime = Mock(dispose=AsyncMock()) + mock_runtime = Mock( + execute=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "00000000-0000-0000-0000-000000000001") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli._managed_workspace.UiPath", return_value=client), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch("uipath._cli.cli_debug.get_chat_bridge"), + patch("uipath._cli.cli_debug.UiPathChatRuntime") as chat_runtime_type, + patch("uipath._cli.cli_debug.UiPathDebugRuntime") as debug_runtime_type, + patch("uipath._cli.cli_debug.UiPathMockRuntime") as mock_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + debug_runtime_type.return_value = debug_runtime + mock_runtime_type.return_value = mock_runtime + result = runner.invoke(cli, ["debug", "main", "{}"]) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + workspace_runtime = chat_runtime_type.call_args.kwargs["delegate"] + assert isinstance(workspace_runtime, ConversationalWorkspaceRuntime) + assert isinstance(workspace_runtime.delegate, HydrationRuntime) + assert workspace_runtime.delegate.delegate is base_runtime + assert ( + workspace_runtime.registry_store + is workspace_runtime.delegate.registry_store + ) + assert ( + workspace_runtime.delegate.registry_store.runtime_id + == "00000000-0000-0000-0000-000000000001" + ) + assert debug_runtime_type.call_args.kwargs["delegate"] is chat_runtime + assert mock_runtime_type.call_args.kwargs["delegate"] is debug_runtime + assert not workspace_runtime.delegate.workspace.path.exists() + factory.get_storage.assert_awaited_once() + mock_runtime.dispose.assert_awaited_once() + debug_runtime.dispose.assert_awaited_once() + chat_runtime.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + + def test_disposes_runtime_when_managed_workspace_has_no_storage( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock(dispose=AsyncMock()) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=None), + dispose=AsyncMock(), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + ): + runner.invoke(cli, ["debug", "main", "{}"]) + + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + + def test_cleanup_continues_after_workspace_construction_failure( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + base_runtime = Mock(dispose=AsyncMock()) + factory = Mock( + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + workspace = Mock( + path=Path(temp_dir), + dispose=AsyncMock(side_effect=RuntimeError("cleanup failed")), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_debug.Middlewares.next", + return_value=MiddlewareResult( + should_continue=True, + info_message=None, + error_message=None, + should_include_stacktrace=False, + ), + ), + patch( + "uipath._cli.cli_debug.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch("uipath._cli.cli_debug.get_debug_bridge"), + patch("uipath._cli._managed_workspace.UiPath"), + patch( + "uipath._cli._managed_workspace.Workspace.create", + return_value=workspace, + ), + patch( + "uipath._cli._managed_workspace.WorkspaceHydrator", + side_effect=RuntimeError("construction failed"), + ), + ): + runner.invoke(cli, ["debug", "main", "{}"]) + + workspace.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + def test_simulation_config_enables_tool_mocking( self, temp_dir: str, valid_simulation_config: dict[str, Any] ): diff --git a/packages/uipath/tests/cli/test_managed_workspace.py b/packages/uipath/tests/cli/test_managed_workspace.py new file mode 100644 index 000000000..871b702fb --- /dev/null +++ b/packages/uipath/tests/cli/test_managed_workspace.py @@ -0,0 +1,123 @@ +from contextlib import AsyncExitStack +from pathlib import Path +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from uipath._cli._managed_workspace import wrap_with_managed_workspace +from uipath.runtime import HydrationRuntime + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("job_id", "enabled"), + [(None, True), ("job-id", False)], +) +async def test_skips_managed_workspace_when_not_applicable( + job_id: str | None, + enabled: bool, +) -> None: + delegate = Mock() + factory = Mock(get_storage=AsyncMock()) + context = Mock(job_id=job_id) + cleanup = AsyncExitStack() + + with patch("uipath._cli._managed_workspace.UiPath") as client_type: + runtime = await wrap_with_managed_workspace( + delegate, + context=context, + factory=factory, + enabled=enabled, + cleanup=cleanup, + ) + + assert runtime is delegate + factory.get_storage.assert_not_awaited() + client_type.assert_not_called() + + +@pytest.mark.asyncio +async def test_rejects_missing_managed_workspace_storage() -> None: + factory = Mock(get_storage=AsyncMock(return_value=None)) + context = Mock(job_id="job-id") + + with pytest.raises( + RuntimeError, + match="advertises managed workspace support but provides no storage", + ): + await wrap_with_managed_workspace( + Mock(), + context=context, + factory=factory, + enabled=True, + cleanup=AsyncExitStack(), + ) + + +@pytest.mark.asyncio +async def test_creates_hydration_runtime_for_non_conversational_job( + tmp_path: Path, +) -> None: + delegate = Mock(dispose=AsyncMock()) + storage = Mock() + factory = Mock(get_storage=AsyncMock(return_value=storage)) + context = Mock( + job_id="job-id", + folder_key="folder-key", + conversation_id=None, + exchange_id=None, + ) + workspace = Mock(path=tmp_path, dispose=AsyncMock()) + client = Mock(attachments=Mock(), jobs=Mock()) + cleanup = AsyncExitStack() + + with ( + patch("uipath._cli._managed_workspace.UiPath", return_value=client), + patch( + "uipath._cli._managed_workspace.Workspace.create", + return_value=workspace, + ), + ): + runtime = await wrap_with_managed_workspace( + delegate, + context=context, + factory=factory, + enabled=True, + cleanup=cleanup, + ) + + assert isinstance(runtime, HydrationRuntime) + await cleanup.aclose() + + +@pytest.mark.asyncio +async def test_disposes_workspace_when_runtime_construction_fails( + tmp_path: Path, +) -> None: + delegate = Mock(dispose=AsyncMock()) + factory = Mock(get_storage=AsyncMock(return_value=Mock())) + context = Mock(job_id="job-id", folder_key=None) + workspace = Mock(path=tmp_path, dispose=AsyncMock()) + cleanup = AsyncExitStack() + + with ( + patch("uipath._cli._managed_workspace.UiPath"), + patch( + "uipath._cli._managed_workspace.Workspace.create", + return_value=workspace, + ), + patch( + "uipath._cli._managed_workspace.WorkspaceHydrator", + side_effect=RuntimeError("construction failed"), + ), + pytest.raises(RuntimeError, match="construction failed"), + ): + await wrap_with_managed_workspace( + delegate, + context=context, + factory=factory, + enabled=True, + cleanup=cleanup, + ) + + workspace.dispose.assert_awaited_once() diff --git a/packages/uipath/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py index aa182c7c5..442af795e 100644 --- a/packages/uipath/tests/cli/test_run.py +++ b/packages/uipath/tests/cli/test_run.py @@ -1,14 +1,25 @@ # type: ignore +import hashlib import json import os from contextlib import asynccontextmanager +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch +from uuid import UUID import pytest from click.testing import CliRunner from uipath._cli import cli from uipath._cli.middlewares import MiddlewareResult +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathRuntimeFactorySettings, + UiPathRuntimeResult, + UiPathRuntimeStatus, + get_workspace_path, +) def _middleware_continue(): @@ -322,6 +333,338 @@ def test_successful_execution( output = f.read() assert output.count("Hello world") >= 2 + def test_installs_workspace_runtimes_after_simulation_before_chat_runtime( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + base_runtime.get_schema = AsyncMock(return_value=Mock(metadata=None)) + storage = Mock() + factory.get_storage = AsyncMock(return_value=storage) + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + chat_bridge = Mock() + chat_runtime = Mock( + execute=AsyncMock(return_value=Mock()), + dispose=AsyncMock(), + ) + mock_runtime = Mock(dispose=AsyncMock()) + client = Mock(attachments=Mock(), jobs=Mock()) + + monkeypatch.setenv("UIPATH_JOB_KEY", "00000000-0000-0000-0000-000000000001") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch( + "uipath._cli._managed_workspace.UiPath", + return_value=client, + ), + patch( + "uipath._cli.cli_run.get_chat_bridge", + return_value=chat_bridge, + ), + patch( + "uipath._cli.cli_run.UiPathMockRuntime", + return_value=mock_runtime, + ) as mock_runtime_type, + patch("uipath._cli.cli_run.UiPathChatRuntime") as chat_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + result = runner.invoke( + cli, + [ + "run", + "main", + "--simulation", + json.dumps(_SIMULATION_JSON), + ], + ) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + wrapped_runtime = chat_runtime_type.call_args.kwargs["delegate"] + assert isinstance(wrapped_runtime, ConversationalWorkspaceRuntime) + assert isinstance(wrapped_runtime.delegate, HydrationRuntime) + assert wrapped_runtime.delegate.delegate is mock_runtime + assert mock_runtime_type.call_args.kwargs["delegate"] is base_runtime + assert ( + wrapped_runtime.registry_store + is wrapped_runtime.delegate.registry_store + ) + assert ( + wrapped_runtime.delegate.registry_store.runtime_id + == "00000000-0000-0000-0000-000000000001" + ) + assert not wrapped_runtime.delegate.workspace.path.exists() + factory.get_storage.assert_awaited_once() + chat_runtime.dispose.assert_awaited_once() + mock_runtime.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + + def test_suspended_workspace_takes_precedence_over_conversation_snapshot( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + conversation_attachment_key = UUID(int=1) + suspended_attachment_key = UUID(int=2) + job_key = UUID(int=3) + deleted_attachment_key = UUID(int=5) + attachment_contents = { + conversation_attachment_key: b"conversation", + suspended_attachment_key: b"suspended", + deleted_attachment_key: b"deleted before suspension", + } + + async def download_attachment(key, destination_path, **_): + Path(destination_path).write_bytes(attachment_contents[key]) + + attachments = Mock( + download_async=AsyncMock(side_effect=download_attachment), + upload_async=AsyncMock(return_value=UUID(int=4)), + ) + client = Mock( + attachments=attachments, + jobs=Mock(link_attachment_async=AsyncMock()), + ) + storage = Mock( + get_value=AsyncMock( + return_value={ + "notes.txt": { + "attachment_key": str(suspended_attachment_key), + "sha256": hashlib.sha256(b"suspended").hexdigest(), + "size": len(b"suspended"), + "uploaded_at": "2026-01-01T00:00:00+00:00", + "attachment_name": ".uipath-workspace~1notes.txt", + } + } + ), + set_value=AsyncMock(), + ) + observed_contents = [] + + async def stream_runtime(*_, **__): + observed_contents.append( + ( + (get_workspace_path() / "notes.txt").read_text( + encoding="utf-8" + ), + (get_workspace_path() / "deleted.txt").exists(), + ) + ) + yield UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) + + base_runtime = Mock( + stream=Mock(side_effect=stream_runtime), + dispose=AsyncMock(), + ) + factory = Mock( + discover_entrypoints=Mock(return_value=["main"]), + new_runtime=AsyncMock(return_value=base_runtime), + get_settings=AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ), + get_storage=AsyncMock(return_value=storage), + dispose=AsyncMock(), + ) + chat_runtime = Mock(dispose=AsyncMock()) + + async def execute_chat(input, options): + workspace_runtime = chat_runtime_type.call_args.kwargs["delegate"] + return await workspace_runtime.execute(input, options=options) + + chat_runtime.execute = AsyncMock(side_effect=execute_chat) + + monkeypatch.setenv("UIPATH_JOB_KEY", str(job_key)) + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + input = { + "uipath__conversation_meta_events": [ + { + "metaEvent": { + "workspaceFiles": [ + { + "path": "notes.txt", + "attachmentKey": str(conversation_attachment_key), + }, + { + "path": "deleted.txt", + "attachmentKey": str(deleted_attachment_key), + }, + ] + } + } + ] + } + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump( + { + "fpsProperties": { + "conversationalService.conversationId": "conversation-id", + "conversationalService.exchangeId": "exchange-id", + } + }, + file, + ) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli._managed_workspace.UiPath", return_value=client), + patch("uipath._cli.cli_run.get_chat_bridge"), + patch("uipath._cli.cli_run.UiPathChatRuntime") as chat_runtime_type, + ): + chat_runtime_type.return_value = chat_runtime + result = runner.invoke( + cli, + ["run", "main", json.dumps(input)], + ) + + assert result.exit_code == 0, ( + f"output: {result.output!r}, exception: {result.exception}" + ) + assert observed_contents == [("suspended", False)] + downloaded_keys = [ + call.kwargs["key"] + for call in attachments.download_async.await_args_list + ] + assert downloaded_keys == [suspended_attachment_key] + base_runtime.dispose.assert_awaited_once() + + def test_disposes_runtime_when_managed_workspace_has_no_storage( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + factory.get_storage = AsyncMock(return_value=None) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + ): + runner.invoke(cli, ["run", "main"]) + + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + + def test_cleanup_continues_after_workspace_construction_failure( + self, + runner: CliRunner, + temp_dir: str, + monkeypatch: pytest.MonkeyPatch, + ): + factory = _make_mock_factory(["main"]) + base_runtime = factory.new_runtime.return_value + factory.get_settings = AsyncMock( + return_value=UiPathRuntimeFactorySettings(managed_workspace=True) + ) + factory.get_storage = AsyncMock(return_value=Mock()) + workspace = Mock( + path=Path(temp_dir), + dispose=AsyncMock(side_effect=RuntimeError("cleanup failed")), + ) + + monkeypatch.setenv("UIPATH_JOB_KEY", "job-id") + monkeypatch.setenv("UIPATH_TRACING_ENABLED", "false") + + with runner.isolated_filesystem(temp_dir=temp_dir): + with open("uipath.json", "w") as file: + json.dump({}, file) + + with ( + patch( + "uipath._cli.cli_run.Middlewares.next", + return_value=_middleware_continue(), + ), + patch( + "uipath._cli.cli_run.UiPathRuntimeFactoryRegistry.get", + return_value=factory, + ), + patch( + "uipath._cli.cli_run.ResourceOverwritesContext", + side_effect=_mock_resource_overwrites_context, + ), + patch("uipath._cli._managed_workspace.UiPath"), + patch( + "uipath._cli._managed_workspace.Workspace.create", + return_value=workspace, + ), + patch( + "uipath._cli._managed_workspace.WorkspaceHydrator", + side_effect=RuntimeError("construction failed"), + ), + ): + runner.invoke(cli, ["run", "main"]) + + workspace.dispose.assert_awaited_once() + base_runtime.dispose.assert_awaited_once() + factory.dispose.assert_awaited_once() + def test_no_main_function_found( self, runner: CliRunner, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index c905ec7c3..ddd6833ad 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.1" +version = "2.15.0" source = { editable = "." } dependencies = [ { name = "applicationinsights" },