From dd1b36bb08869bd2e025a4358d7ea1116df59503 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sat, 20 Dec 2025 19:52:32 +0200 Subject: [PATCH] fix: scope storage to runtime id --- pyproject.toml | 13 +- src/uipath_langchain/runtime/factory.py | 1 + src/uipath_langchain/runtime/storage.py | 187 ++++++++++++++++++------ tests/hitl/test_hitl_api_trigger.py | 2 +- uv.lock | 23 +-- 5 files changed, 169 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c550052c..79ddc80e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "uipath-langchain" -version = "0.1.37" +version = "0.2.0" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath>=2.2.35, <2.3.0", + "uipath==2.3.0.dev1010343497", + "uipath-runtime>=0.3.0, <0.4.0", "langgraph>=1.0.0, <2.0.0", "langchain-core>=1.0.0, <2.0.0", "aiosqlite==0.21.0", @@ -124,3 +125,11 @@ name = "testpypi" url = "https://test.pypi.org/simple/" publish-url = "https://test.pypi.org/legacy/" explicit = true + +[tool.uv.sources] +uipath = { index = "testpypi" } + +[tool.uv] +override-dependencies = [ + "uipath>=2.3.0.dev1010340000,<2.3.0.dev1010350000", +] diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index 3da42214..8a872ec0 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -275,6 +275,7 @@ async def _create_runtime_instance( delegate=base_runtime, storage=storage, trigger_manager=trigger_manager, + runtime_id=runtime_id, ) async def new_runtime( diff --git a/src/uipath_langchain/runtime/storage.py b/src/uipath_langchain/runtime/storage.py index e4eee386..e2c56de7 100644 --- a/src/uipath_langchain/runtime/storage.py +++ b/src/uipath_langchain/runtime/storage.py @@ -1,7 +1,7 @@ """SQLite implementation of UiPathResumableStorageProtocol.""" import json -from typing import cast +from typing import Any, cast from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from pydantic import BaseModel @@ -14,25 +14,31 @@ class SqliteResumableStorage: - """SQLite storage for resume triggers.""" + """SQLite storage for resume triggers and arbitrary kv pairs.""" def __init__( - self, memory: AsyncSqliteSaver, table_name: str = "__uipath_resume_triggers" + self, + memory: AsyncSqliteSaver, + rs_table_name: str = "__uipath_resume_triggers", + kv_table_name: str = "__uipath_runtime_kv", ): self.memory = memory - self.table_name = table_name + self.rs_table_name = rs_table_name + self.kv_table_name = kv_table_name self._initialized = False async def _ensure_table(self) -> None: - """Create table if needed.""" + """Create tables if needed.""" if self._initialized: return await self.memory.setup() async with self.memory.lock, self.memory.conn.cursor() as cur: - await cur.execute(f""" - CREATE TABLE IF NOT EXISTS {self.table_name} ( + await cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.rs_table_name} ( id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, type TEXT NOT NULL, name TEXT NOT NULL, key TEXT, @@ -41,75 +47,166 @@ async def _ensure_table(self) -> None: payload TEXT, timestamp DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')) ) - """) + """ + ) + + await cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.kv_table_name} ( + runtime_id TEXT NOT NULL, + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + timestamp DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')), + PRIMARY KEY (runtime_id, namespace, key) + ) + """ + ) + await self.memory.conn.commit() - self._initialized = True - async def save_trigger(self, trigger: UiPathResumeTrigger) -> None: - """Save resume trigger to database.""" + self._initialized = True + + async def save_trigger(self, runtime_id: str, trigger: UiPathResumeTrigger) -> None: + """Save resume trigger to database (scoped by runtime_id).""" await self._ensure_table() trigger_key = ( trigger.api_resume.inbox_id if trigger.api_resume else trigger.item_key ) - payload = trigger.payload - if payload: - payload = ( - ( - payload.model_dump() - if isinstance(payload, BaseModel) - else json.dumps(payload) - ) - if isinstance(payload, dict) - else str(payload) - ) + payload_text = self._dump_value(trigger.payload) async with self.memory.lock, self.memory.conn.cursor() as cur: await cur.execute( - f"INSERT INTO {self.table_name} (type, key, name, payload, folder_path, folder_key) VALUES (?, ?, ?, ?, ?, ?)", + f""" + INSERT INTO {self.rs_table_name} + (runtime_id, type, key, name, payload, folder_path, folder_key) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, ( + runtime_id, trigger.trigger_type.value, trigger_key, trigger.trigger_name.value, - payload, + payload_text, trigger.folder_path, trigger.folder_key, ), ) await self.memory.conn.commit() - async def get_latest_trigger(self) -> UiPathResumeTrigger | None: - """Get most recent trigger from database.""" + async def get_latest_trigger(self, runtime_id: str) -> UiPathResumeTrigger | None: + """Get most recent trigger for runtime_id from database.""" await self._ensure_table() async with self.memory.lock, self.memory.conn.cursor() as cur: - await cur.execute(f""" + await cur.execute( + f""" SELECT type, key, name, folder_path, folder_key, payload - FROM {self.table_name} + FROM {self.rs_table_name} + WHERE runtime_id = ? ORDER BY timestamp DESC LIMIT 1 - """) + """, + (runtime_id,), + ) result = await cur.fetchone() - if not result: - return None + if not result: + return None + + trigger_type, key, name, folder_path, folder_key, payload_text = cast( + tuple[str, str, str, str | None, str | None, str | None], tuple(result) + ) + + payload = self._load_value(payload_text) - trigger_type, key, name, folder_path, folder_key, payload = cast( - tuple[str, str, str, str, str, str], tuple(result) + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType(trigger_type), + trigger_name=UiPathResumeTriggerName(name), + item_key=key, + folder_path=folder_path, + folder_key=folder_key, + payload=payload, + ) + + if resume_trigger.trigger_type == UiPathResumeTriggerType.API: + resume_trigger.api_resume = UiPathApiTrigger( + inbox_id=resume_trigger.item_key, + request=resume_trigger.payload, ) - resume_trigger = UiPathResumeTrigger( - trigger_type=UiPathResumeTriggerType(trigger_type), - trigger_name=UiPathResumeTriggerName(name), - item_key=key, - folder_path=folder_path, - folder_key=folder_key, - payload=payload, + return resume_trigger + + async def set_value( + self, + runtime_id: str, + namespace: str, + key: str, + value: Any, + ) -> None: + """Save arbitrary key-value pair to database.""" + if not ( + isinstance(value, str) + or isinstance(value, dict) + or isinstance(value, BaseModel) + or value is None + ): + raise TypeError("Value must be str, dict, BaseModel or None.") + + await self._ensure_table() + + value_text = self._dump_value(value) + + async with self.memory.lock, self.memory.conn.cursor() as cur: + await cur.execute( + f""" + INSERT INTO {self.kv_table_name} (runtime_id, namespace, key, value) + VALUES (?, ?, ?, ?) + ON CONFLICT(runtime_id, namespace, key) + DO UPDATE SET + value = excluded.value, + timestamp = (strftime('%Y-%m-%d %H:%M:%S', 'now', 'utc')) + """, + (runtime_id, namespace, key, value_text), ) + await self.memory.conn.commit() - if resume_trigger.trigger_type == UiPathResumeTriggerType.API: - resume_trigger.api_resume = UiPathApiTrigger( - inbox_id=resume_trigger.item_key, request=resume_trigger.payload - ) + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + """Get arbitrary key-value pair from database (scoped by runtime_id + namespace).""" + await self._ensure_table() - return resume_trigger + async with self.memory.lock, self.memory.conn.cursor() as cur: + await cur.execute( + f""" + SELECT value + FROM {self.kv_table_name} + WHERE runtime_id = ? AND namespace = ? AND key = ? + LIMIT 1 + """, + (runtime_id, namespace, key), + ) + row = await cur.fetchone() + + if not row: + return None + + return self._load_value(cast(str | None, row[0])) + + def _dump_value(self, value: str | dict[str, Any] | BaseModel | None) -> str | None: + if value is None: + return None + if isinstance(value, BaseModel): + return "j:" + json.dumps(value.model_dump()) + if isinstance(value, dict): + return "j:" + json.dumps(value) + return "s:" + value + + def _load_value(self, raw: str | None) -> Any: + if raw is None: + return None + if raw.startswith("s:"): + return raw[2:] + if raw.startswith("j:"): + return json.loads(raw[2:]) + return raw diff --git a/tests/hitl/test_hitl_api_trigger.py b/tests/hitl/test_hitl_api_trigger.py index 73c45f15..b3c217f5 100644 --- a/tests/hitl/test_hitl_api_trigger.py +++ b/tests/hitl/test_hitl_api_trigger.py @@ -97,7 +97,7 @@ async def test_agent( assert type == "Api" assert name == "Api" assert folder_path == folder_key is None - assert payload == "interrupt message" + assert payload == "s:interrupt message" finally: if conn: conn.close() diff --git a/uv.lock b/uv.lock index fca70026..438cd7e7 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[manifest] +overrides = [{ name = "uipath", specifier = ">=2.3.0.dev1010340000,<2.3.0.dev1010350000", index = "https://test.pypi.org/simple/" }] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -3219,8 +3222,8 @@ wheels = [ [[package]] name = "uipath" -version = "2.2.35" -source = { registry = "https://pypi.org/simple" } +version = "2.3.0.dev1010343497" +source = { registry = "https://test.pypi.org/simple/" } dependencies = [ { name = "click" }, { name = "coverage" }, @@ -3239,9 +3242,9 @@ dependencies = [ { name = "uipath-core" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/f8/1e2e401c0c9deeb13aa92e920a71f0b9d6a42a1d7c27c35261e23683d9e5/uipath-2.2.35.tar.gz", hash = "sha256:2f061d286f62c97d971350cda12d0129969cb9ebde367b824db49ec0c3e0ae8d", size = 3423239, upload-time = "2025-12-17T07:10:03.674Z" } +sdist = { url = "https://test-files.pythonhosted.org/packages/f8/3b/061b30d62f4ff68da85ff886101f8f559d541b5db4994cd08ef749ef6d57/uipath-2.3.0.dev1010343497.tar.gz", hash = "sha256:5deae24e3869bc16fb18ae2bdfd9778cb5821f1fba83f9da60f17dbab2f0d8a0", size = 3427459, upload-time = "2025-12-20T17:22:54.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/8c/dc74826284fcc3b00f816b65fe4a904c36ac8ac0b486d7a4b18ac331f597/uipath-2.2.35-py3-none-any.whl", hash = "sha256:74b348aea42a96297953cfb096b4e21aad928494d5002567c89706e4eacc1971", size = 393670, upload-time = "2025-12-17T07:09:59.851Z" }, + { url = "https://test-files.pythonhosted.org/packages/3e/f5/b2ba664a01fd2e4abcb12a9036770342d2d9205e5c62cbb668c81b5678a9/uipath-2.3.0.dev1010343497-py3-none-any.whl", hash = "sha256:268b88299eac84dbc1af4fdae60e96e149d478f2cbe7ff2628d3f16b37e8b4a6", size = 395781, upload-time = "2025-12-20T17:22:53.448Z" }, ] [[package]] @@ -3260,7 +3263,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.1.37" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, @@ -3278,6 +3281,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "uipath" }, + { name = "uipath-runtime" }, ] [package.optional-dependencies] @@ -3323,7 +3327,8 @@ requires-dist = [ { name = "openinference-instrumentation-langchain", specifier = ">=0.1.56" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.2.35,<2.3.0" }, + { name = "uipath", specifier = "==2.3.0.dev1010343497", index = "https://test.pypi.org/simple/" }, + { name = "uipath-runtime", specifier = ">=0.3.0,<0.4.0" }, ] provides-extras = ["vertex", "bedrock"] @@ -3342,14 +3347,14 @@ dev = [ [[package]] name = "uipath-runtime" -version = "0.2.7" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/90/03a8f57d64c4b25ebf2e364f2012bf97ef625a7cef9a6e2f68dfdce29188/uipath_runtime-0.2.7.tar.gz", hash = "sha256:2718a98db995a70b92f5eaa84fb315e8edd324ec406be2face978ffeaa062223", size = 95944, upload-time = "2025-12-11T11:29:25.94Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/43/da9d5fef33d6a2423bc856a97a777efc1e0050db3ce649c44cd4a86f6b2c/uipath_runtime-0.3.0.tar.gz", hash = "sha256:0c68b583a56a84bdf75c911bdb9bfb9503836471be80e761a5ecf6cf4d8ead75", size = 97202, upload-time = "2025-12-18T09:01:53.557Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/1e/b884a6b80c3985391c2c4155498bf577839c6a85f038fcc8d019d95ed94e/uipath_runtime-0.2.7-py3-none-any.whl", hash = "sha256:c2e0176a0aebcd70d9ba8c323f14b587841fc15872176cebcd31ff61bd9e9e0d", size = 36954, upload-time = "2025-12-11T11:29:21.781Z" }, + { url = "https://files.pythonhosted.org/packages/1c/63/71f6f11478eaec4a016fa190d282634dc017bf54f69dd301e820bb74a157/uipath_runtime-0.3.0-py3-none-any.whl", hash = "sha256:1b3d13f9d7af41b691e9d29392f6e632df3221b5312adc0579c4d81cb5543dce", size = 37673, upload-time = "2025-12-18T09:01:49.983Z" }, ] [[package]]