Skip to content

Commit 81234fe

Browse files
committed
feat: add workspace hydration primitives
1 parent cb7b92a commit 81234fe

9 files changed

Lines changed: 932 additions & 8 deletions

File tree

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.11.2"
3+
version = "0.11.3"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-core>=0.5.17, <0.6.0"
8+
"uipath-core>=0.5.22,<0.6.0",
99
]
1010
classifiers = [
1111
"Intended Audience :: Developers",

‎src/uipath/runtime/__init__.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
)
4444
from uipath.runtime.schema import UiPathRuntimeSchema
4545
from uipath.runtime.storage import UiPathRuntimeStorageProtocol
46+
from uipath.runtime.workspace import (
47+
AttachmentRegistryEntry,
48+
HydrationPolicy,
49+
HydrationRuntime,
50+
Workspace,
51+
WorkspaceHydrator,
52+
WorkspaceRegistryStore,
53+
)
4654

4755
__all__ = [
4856
"UiPathExecuteOptions",
@@ -73,4 +81,10 @@
7381
"UiPathResumeTriggerName",
7482
"UiPathChatProtocol",
7583
"UiPathChatRuntime",
84+
"AttachmentRegistryEntry",
85+
"HydrationPolicy",
86+
"HydrationRuntime",
87+
"Workspace",
88+
"WorkspaceHydrator",
89+
"WorkspaceRegistryStore",
7690
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Workspace persistence primitives for runtime implementations."""
2+
3+
from uipath.runtime.workspace.hydration import (
4+
HydrationPolicy,
5+
HydrationRuntime,
6+
)
7+
from uipath.runtime.workspace.hydrator import (
8+
AttachmentRegistryEntry,
9+
WorkspaceHydrator,
10+
)
11+
from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore
12+
from uipath.runtime.workspace.workspace import Workspace
13+
14+
__all__ = [
15+
"AttachmentRegistryEntry",
16+
"HydrationPolicy",
17+
"HydrationRuntime",
18+
"Workspace",
19+
"WorkspaceHydrator",
20+
"WorkspaceRegistryStore",
21+
]
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Runtime wrapper for workspace hydration."""
2+
3+
from enum import Enum
4+
from typing import Any, AsyncGenerator
5+
6+
from uipath.runtime.base import (
7+
UiPathExecuteOptions,
8+
UiPathRuntimeProtocol,
9+
UiPathStreamOptions,
10+
)
11+
from uipath.runtime.events import UiPathRuntimeEvent
12+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
13+
from uipath.runtime.schema import UiPathRuntimeSchema
14+
from uipath.runtime.workspace.hydrator import WorkspaceHydrator
15+
from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore
16+
from uipath.runtime.workspace.workspace import Workspace
17+
18+
19+
class HydrationPolicy(str, Enum):
20+
"""Controls when workspace changes are persisted."""
21+
22+
SUSPEND_ONLY = "suspend_only"
23+
SUSPEND_OR_SUCCESS = "suspend_or_success"
24+
ALWAYS = "always"
25+
26+
27+
class HydrationRuntime:
28+
"""Wraps a runtime with hydrate-before and dehydrate-after behavior."""
29+
30+
def __init__(
31+
self,
32+
delegate: UiPathRuntimeProtocol,
33+
*,
34+
workspace: Workspace,
35+
hydrator: WorkspaceHydrator,
36+
registry_store: WorkspaceRegistryStore,
37+
policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY,
38+
):
39+
"""Initialize the hydration wrapper."""
40+
self.delegate = delegate
41+
self.workspace = workspace
42+
self.hydrator = hydrator
43+
self.registry_store = registry_store
44+
self.policy = policy
45+
46+
async def execute(
47+
self,
48+
input: dict[str, Any] | None = None,
49+
options: UiPathExecuteOptions | None = None,
50+
) -> UiPathRuntimeResult:
51+
"""Hydrate, execute, then persist files according to policy."""
52+
await self._hydrate()
53+
try:
54+
result = await self.delegate.execute(input, options=options)
55+
except Exception:
56+
if self.policy == HydrationPolicy.ALWAYS:
57+
await self._persist()
58+
raise
59+
await self._dehydrate(result)
60+
return result
61+
62+
async def stream(
63+
self,
64+
input: dict[str, Any] | None = None,
65+
options: UiPathStreamOptions | None = None,
66+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
67+
"""Hydrate, stream delegate events, then persist files according to policy."""
68+
await self._hydrate()
69+
final_result: UiPathRuntimeResult | None = None
70+
71+
try:
72+
async for event in self.delegate.stream(input, options=options):
73+
if isinstance(event, UiPathRuntimeResult):
74+
final_result = event
75+
else:
76+
yield event
77+
except Exception:
78+
if self.policy == HydrationPolicy.ALWAYS:
79+
await self._persist()
80+
raise
81+
82+
if final_result is not None:
83+
await self._dehydrate(final_result)
84+
yield final_result
85+
86+
async def get_schema(self) -> UiPathRuntimeSchema:
87+
"""Passthrough schema from delegate runtime."""
88+
return await self.delegate.get_schema()
89+
90+
async def dispose(self) -> None:
91+
"""Dispose delegate and workspace."""
92+
try:
93+
await self.delegate.dispose()
94+
finally:
95+
await self.workspace.dispose()
96+
97+
async def _hydrate(self) -> None:
98+
registry = await self.registry_store.load()
99+
hydrated = await self.hydrator.hydrate(registry)
100+
if hydrated != registry:
101+
await self.registry_store.save(hydrated)
102+
103+
async def _dehydrate(self, result: UiPathRuntimeResult) -> None:
104+
if self._should_dehydrate(result):
105+
await self._persist()
106+
107+
async def _persist(self) -> None:
108+
registry = await self.registry_store.load()
109+
dehydrated = await self.hydrator.dehydrate(registry)
110+
await self.registry_store.save(dehydrated)
111+
112+
def _should_dehydrate(self, result: UiPathRuntimeResult) -> bool:
113+
if self.policy == HydrationPolicy.ALWAYS:
114+
return True
115+
if result.status == UiPathRuntimeStatus.SUSPENDED:
116+
return True
117+
return (
118+
self.policy == HydrationPolicy.SUSPEND_OR_SUCCESS
119+
and result.status == UiPathRuntimeStatus.SUCCESSFUL
120+
)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Attachment-backed workspace hydration."""
2+
3+
import hashlib
4+
import os
5+
from datetime import datetime, timezone
6+
from pathlib import Path
7+
from typing import Any
8+
from uuid import UUID
9+
10+
from pydantic import BaseModel
11+
from uipath.core.workspace import AttachmentsProtocol, JobsProtocol
12+
13+
14+
class AttachmentRegistryEntry(BaseModel):
15+
"""Registry entry for one workspace file attachment."""
16+
17+
attachment_key: str
18+
sha256: str
19+
size: int
20+
uploaded_at: str
21+
attachment_name: str | None = None
22+
23+
24+
class WorkspaceHydrator:
25+
"""Hydrates/dehydrates a workspace directory through job attachments."""
26+
27+
DEFAULT_ATTACHMENT_PREFIX = ".uipath-workspace/"
28+
29+
def __init__(
30+
self,
31+
*,
32+
workspace_path: str | Path,
33+
attachments: AttachmentsProtocol,
34+
jobs: JobsProtocol | None = None,
35+
current_job_key: str | None = None,
36+
folder_key: str | None = None,
37+
folder_path: str | None = None,
38+
attachment_prefix: str = DEFAULT_ATTACHMENT_PREFIX,
39+
):
40+
"""Initialize the hydrator."""
41+
self.workspace_path = Path(workspace_path)
42+
self.attachments = attachments
43+
self.jobs = jobs
44+
self.current_job_key = current_job_key
45+
self.folder_key = folder_key
46+
self.folder_path = folder_path
47+
self.attachment_prefix = attachment_prefix.strip("/")
48+
49+
async def hydrate(
50+
self,
51+
registry: dict[str, dict[str, Any]],
52+
) -> dict[str, dict[str, Any]]:
53+
"""Download registry files into the workspace.
54+
55+
Files with matching SHA-256 are left untouched.
56+
"""
57+
normalized = self._normalize_registry(registry)
58+
for virtual_path, entry in normalized.items():
59+
target = self._resolve_workspace_path(virtual_path)
60+
if target.exists() and self._sha256(target) == entry.sha256:
61+
continue
62+
target.parent.mkdir(parents=True, exist_ok=True)
63+
await self.attachments.download_async(
64+
key=UUID(entry.attachment_key),
65+
destination_path=str(target),
66+
folder_key=self.folder_key,
67+
folder_path=self.folder_path,
68+
)
69+
return self._dump_registry(normalized)
70+
71+
async def dehydrate(
72+
self,
73+
registry: dict[str, dict[str, Any]],
74+
) -> dict[str, dict[str, Any]]:
75+
"""Upload new/changed workspace files and return the merged registry.
76+
77+
The result is rebuilt from the files currently present, so entries for
78+
files deleted locally are dropped and not restored on the next hydrate.
79+
"""
80+
previous = self._normalize_registry(registry)
81+
current: dict[str, AttachmentRegistryEntry] = {}
82+
for file_path in self._iter_files():
83+
virtual_path = self._virtual_path(file_path)
84+
digest = self._sha256(file_path)
85+
size = file_path.stat().st_size
86+
existing = previous.get(virtual_path)
87+
88+
if existing and existing.sha256 == digest and existing.size == size:
89+
current[virtual_path] = existing
90+
if self.current_job_key:
91+
await self.link_attachment(existing.attachment_key)
92+
continue
93+
94+
attachment_name = self._attachment_name_for_virtual_path(virtual_path)
95+
attachment_key = await self.attachments.upload_async(
96+
name=attachment_name,
97+
source_path=str(file_path),
98+
folder_key=self.folder_key,
99+
folder_path=self.folder_path,
100+
)
101+
entry = AttachmentRegistryEntry(
102+
attachment_key=str(attachment_key),
103+
sha256=digest,
104+
size=size,
105+
uploaded_at=datetime.now(timezone.utc).isoformat(),
106+
attachment_name=attachment_name,
107+
)
108+
current[virtual_path] = entry
109+
110+
if self.current_job_key:
111+
await self.link_attachment(entry.attachment_key)
112+
113+
return self._dump_registry(current)
114+
115+
async def link_attachment(self, attachment_key: str) -> None:
116+
"""Link an already uploaded attachment to the current job."""
117+
if self.jobs is None or not self.current_job_key:
118+
return
119+
120+
await self.jobs.link_attachment_async(
121+
job_key=UUID(self.current_job_key),
122+
attachment_key=UUID(attachment_key),
123+
folder_key=self.folder_key,
124+
folder_path=self.folder_path,
125+
)
126+
127+
def _iter_files(self) -> list[Path]:
128+
if not self.workspace_path.exists():
129+
return []
130+
131+
files: list[Path] = []
132+
for root, _, names in os.walk(self.workspace_path):
133+
for name in names:
134+
path = Path(root) / name
135+
if path.is_file():
136+
files.append(path)
137+
return sorted(files)
138+
139+
def _resolve_workspace_path(self, virtual_path: str) -> Path:
140+
path = (self.workspace_path / virtual_path).resolve()
141+
workspace = self.workspace_path.resolve()
142+
if workspace != path and workspace not in path.parents:
143+
raise ValueError(f"Workspace path escapes root: {virtual_path}")
144+
return path
145+
146+
def _virtual_path(self, path: Path) -> str:
147+
return path.relative_to(self.workspace_path).as_posix()
148+
149+
@staticmethod
150+
def _escape(value: str) -> str:
151+
# json-pointer escaping; escape ~ before / so they can't collide
152+
return value.replace("~", "~0").replace("/", "~1")
153+
154+
@staticmethod
155+
def _unescape(value: str) -> str:
156+
return value.replace("~1", "/").replace("~0", "~")
157+
158+
def _attachment_name_for_virtual_path(self, virtual_path: str) -> str:
159+
# the platform mishandles "/" in attachment names, so keep it slash-free
160+
return self._escape(f"{self.attachment_prefix}/{virtual_path}")
161+
162+
def _virtual_path_from_attachment_name(self, name: str) -> str | None:
163+
decoded = self._unescape(name)
164+
prefix = f"{self.attachment_prefix}/"
165+
if not decoded.startswith(prefix):
166+
return None
167+
virtual_path = decoded[len(prefix) :]
168+
self._resolve_workspace_path(virtual_path)
169+
return virtual_path
170+
171+
def _normalize_registry(
172+
self, registry: dict[str, dict[str, Any]]
173+
) -> dict[str, AttachmentRegistryEntry]:
174+
normalized: dict[str, AttachmentRegistryEntry] = {}
175+
for virtual_path, entry in registry.items():
176+
self._resolve_workspace_path(virtual_path)
177+
normalized[virtual_path] = AttachmentRegistryEntry.model_validate(entry)
178+
return normalized
179+
180+
def _dump_registry(
181+
self, registry: dict[str, AttachmentRegistryEntry]
182+
) -> dict[str, dict[str, Any]]:
183+
return {path: entry.model_dump() for path, entry in registry.items()}
184+
185+
def _sha256(self, path: Path) -> str:
186+
digest = hashlib.sha256()
187+
with path.open("rb") as file:
188+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
189+
digest.update(chunk)
190+
return digest.hexdigest()

0 commit comments

Comments
 (0)