mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 087669601a | |||
| 8b8ed5be96 | |||
| c1328f512d | |||
| e2805dea75 | |||
| 127e611706 | |||
| a176a135da | |||
| ab78d7d6e8 | |||
| 4eb6e4da09 | |||
| 7e66304746 | |||
| a8b12e8eb8 |
@@ -50,7 +50,6 @@ coverage.xml
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.pytest_pg_info.json
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+35
-91
@@ -2057,7 +2057,7 @@ version = "7.1.0"
|
||||
description = "A Python library for the Docker Engine API."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "test"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"},
|
||||
{file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"},
|
||||
@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
|
||||
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6214,7 +6214,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -6259,9 +6259,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.12"
|
||||
openhands-sdk = "1.12"
|
||||
openhands-tools = "1.12"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-sdk = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
pathspec = ">=0.12.1"
|
||||
@@ -6315,14 +6315,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
|
||||
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6345,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
|
||||
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -11599,14 +11599,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
|
||||
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
|
||||
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
|
||||
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -11777,7 +11777,7 @@ version = "1.2.1"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"},
|
||||
{file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"},
|
||||
@@ -11946,7 +11946,8 @@ version = "311"
|
||||
description = "Python for Window Extensions"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main", "test"]
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
|
||||
files = [
|
||||
{file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"},
|
||||
{file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"},
|
||||
@@ -11969,7 +11970,6 @@ files = [
|
||||
{file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"},
|
||||
{file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"},
|
||||
]
|
||||
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", test = "sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
@@ -12536,7 +12536,7 @@ version = "2.32.5"
|
||||
description = "Python HTTP for Humans."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||
@@ -13573,60 +13573,6 @@ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
|
||||
test = ["pre-commit", "pytest (>=7.0)", "pytest-timeout"]
|
||||
typing = ["mypy (>=1.6,<2.0)", "traitlets (>=5.11.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "testcontainers"
|
||||
version = "4.14.1"
|
||||
description = "Python library for throwaway instances of anything that can run in a Docker container"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891"},
|
||||
{file = "testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
docker = "*"
|
||||
python-dotenv = "*"
|
||||
typing-extensions = "*"
|
||||
urllib3 = "*"
|
||||
wrapt = "*"
|
||||
|
||||
[package.extras]
|
||||
arangodb = ["python-arango (>=8,<9)"]
|
||||
aws = ["boto3 (>=1,<2)", "httpx"]
|
||||
azurite = ["azure-storage-blob (>=12,<13)"]
|
||||
chroma = ["chromadb-client (>=1,<2)"]
|
||||
cosmosdb = ["azure-cosmos (>=4,<5)"]
|
||||
db2 = ["ibm_db_sa ; platform_machine != \"aarch64\" and platform_machine != \"arm64\"", "sqlalchemy (>=2,<3)"]
|
||||
generic = ["httpx", "redis (>=7,<8)"]
|
||||
google = ["google-cloud-datastore (>=2,<3)", "google-cloud-pubsub (>=2,<3)"]
|
||||
influxdb = ["influxdb (>=5,<6)", "influxdb-client (>=1,<2)"]
|
||||
k3s = ["kubernetes", "pyyaml (>=6.0.3)"]
|
||||
keycloak = ["python-keycloak (>=6,<7) ; python_version < \"4.0\""]
|
||||
localstack = ["boto3 (>=1,<2)"]
|
||||
mailpit = ["cryptography"]
|
||||
minio = ["minio (>=7,<8)"]
|
||||
mongodb = ["pymongo (>=4,<5)"]
|
||||
mssql = ["pymssql (>=2,<3)", "sqlalchemy (>=2,<3)"]
|
||||
mysql = ["pymysql[rsa] (>=1,<2)", "sqlalchemy (>=2,<3)"]
|
||||
nats = ["nats-py (>=2,<3)"]
|
||||
neo4j = ["neo4j (>=6,<7)"]
|
||||
openfga = ["openfga-sdk"]
|
||||
opensearch = ["opensearch-py (>=3,<4) ; python_version < \"4.0\""]
|
||||
oracle = ["oracledb (>=3,<4)", "sqlalchemy (>=2,<3)"]
|
||||
oracle-free = ["oracledb (>=3,<4)", "sqlalchemy (>=2,<3)"]
|
||||
qdrant = ["qdrant-client (>=1,<2)"]
|
||||
rabbitmq = ["pika (>=1,<2)"]
|
||||
redis = ["redis (>=7,<8)"]
|
||||
registry = ["bcrypt (>=5,<6)"]
|
||||
scylla = ["cassandra-driver (>=3,<4)"]
|
||||
selenium = ["selenium (>=4,<5)"]
|
||||
sftp = ["cryptography"]
|
||||
test-module-import = ["httpx"]
|
||||
trino = ["trino"]
|
||||
weaviate = ["weaviate-client (>=4,<5)"]
|
||||
|
||||
[[package]]
|
||||
name = "threadpoolctl"
|
||||
version = "3.6.0"
|
||||
@@ -13825,24 +13771,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.4"
|
||||
version = "6.5.5"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
|
||||
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
|
||||
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14154,7 +14098,7 @@ version = "2.6.3"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev", "test"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||
@@ -14502,7 +14446,7 @@ version = "1.17.3"
|
||||
description = "Module for decorators, wrappers and monkey patching."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "test"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
|
||||
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
|
||||
@@ -15065,4 +15009,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "736db5eecbeda2d24e8e66f9e1eae6594b98fbadc096923da65f1fa693253ce1"
|
||||
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
|
||||
|
||||
@@ -69,7 +69,6 @@ opencv-python = "*"
|
||||
pandas = "*"
|
||||
reportlab = "*"
|
||||
gevent = ">=24.2.1,<26.0.0"
|
||||
testcontainers = "*"
|
||||
|
||||
[tool.poetry-dynamic-versioning]
|
||||
enable = true
|
||||
|
||||
@@ -334,7 +334,10 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
await super().save_app_conversation_info(info)
|
||||
|
||||
# Get current user_id for SAAS metadata
|
||||
# Fall back to info.created_by_user_id for webhook callbacks (which use ADMIN context)
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str and info.created_by_user_id:
|
||||
user_id_str = info.created_by_user_id
|
||||
if user_id_str:
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
@@ -1,562 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Common Room Sync
|
||||
|
||||
This script queries the database to count conversations created by each user,
|
||||
then creates or updates a signal in Common Room for each user with their
|
||||
conversation count.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import requests
|
||||
from sqlalchemy import text
|
||||
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from server.auth.token_manager import get_keycloak_admin
|
||||
from storage.database import get_engine
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('common_room_sync')
|
||||
|
||||
# Common Room API configuration
|
||||
COMMON_ROOM_API_KEY = os.environ.get('COMMON_ROOM_API_KEY')
|
||||
COMMON_ROOM_DESTINATION_SOURCE_ID = os.environ.get('COMMON_ROOM_DESTINATION_SOURCE_ID')
|
||||
COMMON_ROOM_API_BASE_URL = 'https://api.commonroom.io/community/v1'
|
||||
|
||||
# Sync configuration
|
||||
BATCH_SIZE = int(os.environ.get('BATCH_SIZE', '100'))
|
||||
KEYCLOAK_BATCH_SIZE = int(os.environ.get('KEYCLOAK_BATCH_SIZE', '20'))
|
||||
MAX_RETRIES = int(os.environ.get('MAX_RETRIES', '3'))
|
||||
INITIAL_BACKOFF_SECONDS = float(os.environ.get('INITIAL_BACKOFF_SECONDS', '1'))
|
||||
MAX_BACKOFF_SECONDS = float(os.environ.get('MAX_BACKOFF_SECONDS', '60'))
|
||||
BACKOFF_FACTOR = float(os.environ.get('BACKOFF_FACTOR', '2'))
|
||||
RATE_LIMIT = float(os.environ.get('RATE_LIMIT', '2')) # Requests per second
|
||||
|
||||
|
||||
class CommonRoomSyncError(Exception):
|
||||
"""Base exception for Common Room sync errors."""
|
||||
|
||||
|
||||
class DatabaseError(CommonRoomSyncError):
|
||||
"""Exception for database errors."""
|
||||
|
||||
|
||||
class CommonRoomAPIError(CommonRoomSyncError):
|
||||
"""Exception for Common Room API errors."""
|
||||
|
||||
|
||||
class KeycloakClientError(CommonRoomSyncError):
|
||||
"""Exception for Keycloak client errors."""
|
||||
|
||||
|
||||
def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
|
||||
"""Get conversations created in the past N minutes.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries, each containing conversation details.
|
||||
|
||||
Raises:
|
||||
DatabaseError: If the database query fails.
|
||||
"""
|
||||
try:
|
||||
# Use a different syntax for the interval that works with pg8000
|
||||
query = text("""
|
||||
SELECT
|
||||
conversation_id, user_id, title, created_at
|
||||
FROM
|
||||
conversation_metadata
|
||||
WHERE
|
||||
created_at >= NOW() - (INTERVAL '1 minute' * :minutes)
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
""")
|
||||
|
||||
with get_engine().connect() as connection:
|
||||
result = connection.execute(query, {'minutes': minutes})
|
||||
conversations = [
|
||||
{
|
||||
'conversation_id': row[0],
|
||||
'user_id': row[1],
|
||||
'title': row[2],
|
||||
'created_at': row[3].isoformat() if row[3] else None,
|
||||
}
|
||||
for row in result
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f'Retrieved {len(conversations)} conversations created in the past {minutes} minutes'
|
||||
)
|
||||
return conversations
|
||||
except Exception as e:
|
||||
logger.exception(f'Error querying recent conversations: {e}')
|
||||
raise DatabaseError(f'Failed to query recent conversations: {e}')
|
||||
|
||||
|
||||
async def get_users_from_keycloak(user_ids: Set[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get user information from Keycloak for a set of user IDs.
|
||||
|
||||
Args:
|
||||
user_ids: A set of user IDs to look up.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping user IDs to user information dictionaries.
|
||||
|
||||
Raises:
|
||||
KeycloakClientError: If the Keycloak API call fails.
|
||||
"""
|
||||
try:
|
||||
# Get Keycloak admin client
|
||||
keycloak_admin = get_keycloak_admin()
|
||||
|
||||
# Create a dictionary to store user information
|
||||
user_info_dict = {}
|
||||
|
||||
# Convert set to list for easier batching
|
||||
user_id_list = list(user_ids)
|
||||
|
||||
# Process user IDs in batches
|
||||
for i in range(0, len(user_id_list), KEYCLOAK_BATCH_SIZE):
|
||||
batch = user_id_list[i : i + KEYCLOAK_BATCH_SIZE]
|
||||
batch_tasks = []
|
||||
|
||||
# Create tasks for each user ID in the batch
|
||||
for user_id in batch:
|
||||
# Use the Keycloak admin client to get user by ID
|
||||
batch_tasks.append(get_user_by_id(keycloak_admin, user_id))
|
||||
|
||||
# Run the batch of tasks concurrently
|
||||
batch_results = await asyncio.gather(*batch_tasks, return_exceptions=True)
|
||||
|
||||
# Process the results
|
||||
for user_id, result in zip(batch, batch_results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(f'Error getting user {user_id}: {result}')
|
||||
continue
|
||||
|
||||
if result and isinstance(result, dict):
|
||||
user_info_dict[user_id] = {
|
||||
'username': result.get('username'),
|
||||
'email': result.get('email'),
|
||||
'id': result.get('id'),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f'Retrieved information for {len(user_info_dict)} users from Keycloak'
|
||||
)
|
||||
return user_info_dict
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'Error getting users from Keycloak: {e}'
|
||||
logger.exception(error_msg)
|
||||
raise KeycloakClientError(error_msg)
|
||||
|
||||
|
||||
async def get_user_by_id(keycloak_admin, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get a user from Keycloak by ID.
|
||||
|
||||
Args:
|
||||
keycloak_admin: The Keycloak admin client.
|
||||
user_id: The user ID to look up.
|
||||
|
||||
Returns:
|
||||
A dictionary with the user's information, or None if not found.
|
||||
"""
|
||||
try:
|
||||
# Use the Keycloak admin client to get user by ID
|
||||
user = keycloak_admin.get_user(user_id)
|
||||
if user:
|
||||
logger.debug(
|
||||
f"Found user in Keycloak: {user.get('username')}, {user.get('email')}"
|
||||
)
|
||||
return user
|
||||
else:
|
||||
logger.warning(f'User {user_id} not found in Keycloak')
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f'Error getting user {user_id} from Keycloak: {e}')
|
||||
return None
|
||||
|
||||
|
||||
def get_user_info(
|
||||
user_id: str, user_info_cache: Dict[str, Dict[str, Any]]
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Get the email address and GitHub username for a user from the cache.
|
||||
|
||||
Args:
|
||||
user_id: The user ID to look up.
|
||||
user_info_cache: A dictionary mapping user IDs to user information.
|
||||
|
||||
Returns:
|
||||
A dictionary with the user's email and username, or None if not found.
|
||||
"""
|
||||
# Check if the user is in the cache
|
||||
if user_id in user_info_cache:
|
||||
user_info = user_info_cache[user_id]
|
||||
logger.debug(
|
||||
f"Found user info in cache: {user_info.get('username')}, {user_info.get('email')}"
|
||||
)
|
||||
return user_info
|
||||
else:
|
||||
logger.warning(f'User {user_id} not found in user info cache')
|
||||
return None
|
||||
|
||||
|
||||
def register_user_in_common_room(
|
||||
user_id: str, email: str, github_username: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Create or update a user in Common Room.
|
||||
|
||||
Args:
|
||||
user_id: The user ID.
|
||||
email: The user's email address.
|
||||
github_username: The user's GitHub username.
|
||||
|
||||
Returns:
|
||||
The API response from Common Room.
|
||||
|
||||
Raises:
|
||||
CommonRoomAPIError: If the Common Room API request fails.
|
||||
"""
|
||||
if not COMMON_ROOM_API_KEY:
|
||||
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
|
||||
|
||||
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
|
||||
raise CommonRoomAPIError(
|
||||
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
|
||||
)
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Create or update user in Common Room
|
||||
user_data = {
|
||||
'id': user_id,
|
||||
'email': email,
|
||||
'username': github_username,
|
||||
'github': {'type': 'handle', 'value': github_username},
|
||||
}
|
||||
|
||||
user_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/user'
|
||||
user_response = requests.post(user_url, headers=headers, json=user_data)
|
||||
|
||||
if user_response.status_code not in (200, 202):
|
||||
logger.error(
|
||||
f'Failed to create/update user in Common Room: {user_response.text}'
|
||||
)
|
||||
logger.error(f'Response status code: {user_response.status_code}')
|
||||
raise CommonRoomAPIError(
|
||||
f'Failed to create/update user: {user_response.text}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Registered/updated user {user_id} (GitHub: {github_username}) in Common Room'
|
||||
)
|
||||
return user_response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.exception(f'Error communicating with Common Room API: {e}')
|
||||
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
|
||||
|
||||
|
||||
def register_conversation_activity(
|
||||
user_id: str,
|
||||
conversation_id: str,
|
||||
conversation_title: str,
|
||||
created_at: datetime,
|
||||
email: str,
|
||||
github_username: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create an activity in Common Room for a new conversation.
|
||||
|
||||
Args:
|
||||
user_id: The user ID who created the conversation.
|
||||
conversation_id: The ID of the conversation.
|
||||
conversation_title: The title of the conversation.
|
||||
created_at: The datetime object when the conversation was created.
|
||||
email: The user's email address.
|
||||
github_username: The user's GitHub username.
|
||||
|
||||
Returns:
|
||||
The API response from Common Room.
|
||||
|
||||
Raises:
|
||||
CommonRoomAPIError: If the Common Room API request fails.
|
||||
"""
|
||||
if not COMMON_ROOM_API_KEY:
|
||||
raise CommonRoomAPIError('COMMON_ROOM_API_KEY environment variable not set')
|
||||
|
||||
if not COMMON_ROOM_DESTINATION_SOURCE_ID:
|
||||
raise CommonRoomAPIError(
|
||||
'COMMON_ROOM_DESTINATION_SOURCE_ID environment variable not set'
|
||||
)
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {COMMON_ROOM_API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
# Format the datetime object to the expected ISO format
|
||||
formatted_timestamp = (
|
||||
created_at.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
if created_at
|
||||
else time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
)
|
||||
|
||||
# Create activity for the conversation
|
||||
activity_data = {
|
||||
'id': f'conversation_{conversation_id}', # Use conversation ID to ensure uniqueness
|
||||
'activityType': 'started_session',
|
||||
'user': {
|
||||
'id': user_id,
|
||||
'email': email,
|
||||
'github': {'type': 'handle', 'value': github_username},
|
||||
'username': github_username,
|
||||
},
|
||||
'activityTitle': {
|
||||
'type': 'text',
|
||||
'value': conversation_title or 'New Conversation',
|
||||
},
|
||||
'content': {
|
||||
'type': 'text',
|
||||
'value': f'Started a new conversation: {conversation_title or "Untitled"}',
|
||||
},
|
||||
'timestamp': formatted_timestamp,
|
||||
'url': f'https://app.all-hands.dev/conversations/{conversation_id}',
|
||||
}
|
||||
|
||||
# Log the activity data for debugging
|
||||
logger.info(f'Activity data payload: {activity_data}')
|
||||
|
||||
activity_url = f'{COMMON_ROOM_API_BASE_URL}/source/{COMMON_ROOM_DESTINATION_SOURCE_ID}/activity'
|
||||
activity_response = requests.post(
|
||||
activity_url, headers=headers, json=activity_data
|
||||
)
|
||||
|
||||
if activity_response.status_code not in (200, 202):
|
||||
logger.error(
|
||||
f'Failed to create activity in Common Room: {activity_response.text}'
|
||||
)
|
||||
logger.error(f'Response status code: {activity_response.status_code}')
|
||||
raise CommonRoomAPIError(
|
||||
f'Failed to create activity: {activity_response.text}'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Registered conversation activity for user {user_id}, conversation {conversation_id}'
|
||||
)
|
||||
return activity_response.json()
|
||||
except requests.RequestException as e:
|
||||
logger.exception(f'Error communicating with Common Room API: {e}')
|
||||
raise CommonRoomAPIError(f'Failed to communicate with Common Room API: {e}')
|
||||
|
||||
|
||||
def retry_with_backoff(func, *args, **kwargs):
|
||||
"""Retry a function with exponential backoff.
|
||||
|
||||
Args:
|
||||
func: The function to retry.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
|
||||
Raises:
|
||||
The last exception raised by the function.
|
||||
"""
|
||||
backoff = INITIAL_BACKOFF_SECONDS
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
|
||||
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
|
||||
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
|
||||
time.sleep(sleep_time)
|
||||
backoff *= BACKOFF_FACTOR
|
||||
else:
|
||||
logger.exception(f'All {MAX_RETRIES} attempts failed')
|
||||
raise last_exception
|
||||
|
||||
|
||||
async def retry_with_backoff_async(func, *args, **kwargs):
|
||||
"""Retry an async function with exponential backoff.
|
||||
|
||||
Args:
|
||||
func: The async function to retry.
|
||||
*args: Positional arguments to pass to the function.
|
||||
**kwargs: Keyword arguments to pass to the function.
|
||||
|
||||
Returns:
|
||||
The result of the function call.
|
||||
|
||||
Raises:
|
||||
The last exception raised by the function.
|
||||
"""
|
||||
backoff = INITIAL_BACKOFF_SECONDS
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
logger.warning(f'Attempt {attempt + 1}/{MAX_RETRIES} failed: {e}')
|
||||
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
sleep_time = min(backoff, MAX_BACKOFF_SECONDS)
|
||||
logger.info(f'Retrying in {sleep_time:.2f} seconds...')
|
||||
await asyncio.sleep(sleep_time)
|
||||
backoff *= BACKOFF_FACTOR
|
||||
else:
|
||||
logger.exception(f'All {MAX_RETRIES} attempts failed')
|
||||
raise last_exception
|
||||
|
||||
|
||||
async def async_sync_recent_conversations_to_common_room(minutes: int = 60):
|
||||
"""Async main function to sync recent conversations to Common Room.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
"""
|
||||
logger.info(
|
||||
f'Starting Common Room recent conversations sync (past {minutes} minutes)'
|
||||
)
|
||||
|
||||
stats = {
|
||||
'total_conversations': 0,
|
||||
'registered_users': 0,
|
||||
'registered_activities': 0,
|
||||
'errors': 0,
|
||||
'missing_user_info': 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get conversations created in the past N minutes
|
||||
recent_conversations = retry_with_backoff(get_recent_conversations, minutes)
|
||||
stats['total_conversations'] = len(recent_conversations)
|
||||
|
||||
logger.info(f'Processing {len(recent_conversations)} recent conversations')
|
||||
|
||||
if not recent_conversations:
|
||||
logger.info('No recent conversations found, exiting')
|
||||
return
|
||||
|
||||
# Extract all unique user IDs
|
||||
user_ids = {conv['user_id'] for conv in recent_conversations if conv['user_id']}
|
||||
|
||||
# Get user information for all users in batches
|
||||
user_info_cache = await retry_with_backoff_async(
|
||||
get_users_from_keycloak, user_ids
|
||||
)
|
||||
|
||||
# Track registered users to avoid duplicate registrations
|
||||
registered_users = set()
|
||||
|
||||
# Process each conversation
|
||||
for conversation in recent_conversations:
|
||||
conversation_id = conversation['conversation_id']
|
||||
user_id = conversation['user_id']
|
||||
title = conversation['title']
|
||||
created_at = conversation[
|
||||
'created_at'
|
||||
] # This might be a string or datetime object
|
||||
|
||||
try:
|
||||
# Get user info from cache
|
||||
user_info = get_user_info(user_id, user_info_cache)
|
||||
if not user_info:
|
||||
logger.warning(
|
||||
f'Could not find user info for user {user_id}, skipping conversation {conversation_id}'
|
||||
)
|
||||
stats['missing_user_info'] += 1
|
||||
continue
|
||||
|
||||
email = user_info['email']
|
||||
github_username = user_info['username']
|
||||
|
||||
if not email:
|
||||
logger.warning(
|
||||
f'User {user_id} has no email, skipping conversation {conversation_id}'
|
||||
)
|
||||
stats['errors'] += 1
|
||||
continue
|
||||
|
||||
# Register user in Common Room if not already registered in this run
|
||||
if user_id not in registered_users:
|
||||
register_user_in_common_room(user_id, email, github_username)
|
||||
registered_users.add(user_id)
|
||||
stats['registered_users'] += 1
|
||||
|
||||
# If created_at is a string, parse it to a datetime object
|
||||
# If it's already a datetime object, use it as is
|
||||
# If it's None, use current time
|
||||
created_at_datetime = (
|
||||
created_at
|
||||
if isinstance(created_at, datetime)
|
||||
else datetime.fromisoformat(created_at.replace('Z', '+00:00'))
|
||||
if created_at
|
||||
else datetime.now(UTC)
|
||||
)
|
||||
|
||||
# Register conversation activity with email and github username
|
||||
register_conversation_activity(
|
||||
user_id,
|
||||
conversation_id,
|
||||
title,
|
||||
created_at_datetime,
|
||||
email,
|
||||
github_username,
|
||||
)
|
||||
stats['registered_activities'] += 1
|
||||
|
||||
# Sleep to respect rate limit
|
||||
await asyncio.sleep(1 / RATE_LIMIT)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'Error processing conversation {conversation_id} for user {user_id}: {e}'
|
||||
)
|
||||
stats['errors'] += 1
|
||||
except Exception as e:
|
||||
logger.exception(f'Sync failed: {e}')
|
||||
raise
|
||||
finally:
|
||||
logger.info(f'Sync completed. Stats: {stats}')
|
||||
|
||||
|
||||
def sync_recent_conversations_to_common_room(minutes: int = 60):
|
||||
"""Main function to sync recent conversations to Common Room.
|
||||
|
||||
Args:
|
||||
minutes: Number of minutes to look back for new conversations.
|
||||
"""
|
||||
# Run the async function in the event loop
|
||||
asyncio.run(async_sync_recent_conversations_to_common_room(minutes))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Default to looking back 60 minutes for new conversations
|
||||
minutes = int(os.environ.get('SYNC_MINUTES', '60'))
|
||||
sync_recent_conversations_to_common_room(minutes)
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for Common Room conversation count sync.
|
||||
|
||||
This script tests the functionality of the Common Room sync script
|
||||
without making any API calls to Common Room or database connections.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from sync.common_room_sync import (
|
||||
retry_with_backoff,
|
||||
)
|
||||
|
||||
|
||||
class TestCommonRoomSync(unittest.TestCase):
|
||||
"""Test cases for Common Room sync functionality."""
|
||||
|
||||
def test_retry_with_backoff(self):
|
||||
"""Test the retry_with_backoff function."""
|
||||
# Mock function that succeeds on the second attempt
|
||||
mock_func = MagicMock(
|
||||
side_effect=[Exception('First attempt failed'), 'success']
|
||||
)
|
||||
|
||||
# Set environment variables for testing
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'MAX_RETRIES': '3',
|
||||
'INITIAL_BACKOFF_SECONDS': '0.01',
|
||||
'BACKOFF_FACTOR': '2',
|
||||
'MAX_BACKOFF_SECONDS': '1',
|
||||
},
|
||||
):
|
||||
result = retry_with_backoff(mock_func, 'arg1', 'arg2', kwarg1='kwarg1')
|
||||
|
||||
# Check that the function was called twice
|
||||
self.assertEqual(mock_func.call_count, 2)
|
||||
# Check that the function was called with the correct arguments
|
||||
mock_func.assert_called_with('arg1', 'arg2', kwarg1='kwarg1')
|
||||
# Check that the function returned the expected result
|
||||
self.assertEqual(result, 'success')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify the conversation count query.
|
||||
|
||||
This script tests the database query to count conversations by user,
|
||||
without making any API calls to Common Room.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from storage.database import get_engine
|
||||
|
||||
|
||||
def test_conversation_count_query():
|
||||
"""Test the query to count conversations by user."""
|
||||
try:
|
||||
# Query to count conversations by user
|
||||
count_query = text("""
|
||||
SELECT
|
||||
user_id, COUNT(*) as conversation_count
|
||||
FROM
|
||||
conversation_metadata
|
||||
GROUP BY
|
||||
user_id
|
||||
""")
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
with engine.connect() as connection:
|
||||
count_result = connection.execute(count_query)
|
||||
user_counts = [
|
||||
{'user_id': row[0], 'conversation_count': row[1]}
|
||||
for row in count_result
|
||||
]
|
||||
|
||||
print(f'Found {len(user_counts)} users with conversations')
|
||||
|
||||
# Print the first 5 results
|
||||
for i, user_data in enumerate(user_counts[:5]):
|
||||
print(
|
||||
f"User {i+1}: {user_data['user_id']} - {user_data['conversation_count']} conversations"
|
||||
)
|
||||
|
||||
# Test the user_entity query for the first user (if any)
|
||||
if user_counts:
|
||||
first_user_id = user_counts[0]['user_id']
|
||||
|
||||
user_query = text("""
|
||||
SELECT username, email, id
|
||||
FROM user_entity
|
||||
WHERE id = :user_id
|
||||
""")
|
||||
|
||||
with engine.connect() as connection:
|
||||
user_result = connection.execute(user_query, {'user_id': first_user_id})
|
||||
user_row = user_result.fetchone()
|
||||
|
||||
if user_row:
|
||||
print(f'\nUser details for {first_user_id}:')
|
||||
print(f' GitHub Username: {user_row[0]}')
|
||||
print(f' Email: {user_row[1]}')
|
||||
print(f' ID: {user_row[2]}')
|
||||
else:
|
||||
print(
|
||||
f'\nNo user details found for {first_user_id} in user_entity table'
|
||||
)
|
||||
|
||||
print('\nTest completed successfully')
|
||||
except Exception as e:
|
||||
print(f'Error: {str(e)}')
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_conversation_count_query()
|
||||
@@ -1,5 +1,3 @@
|
||||
import json
|
||||
import pathlib
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
@@ -10,13 +8,15 @@ from server.constants import ORG_SETTINGS_VERSION
|
||||
from server.verified_models.verified_model_service import (
|
||||
StoredVerifiedModel, # noqa: F401
|
||||
)
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Anything not loaded here may not have a table created for it.
|
||||
from storage.api_key import ApiKey # noqa: F401
|
||||
from storage.base import Base
|
||||
from storage.billing_session import BillingSession
|
||||
@@ -35,112 +35,6 @@ from storage.stored_conversation_metadata_saas import (
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user import User
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
from xdist import is_xdist_controller
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL container lifecycle — managed by the xdist controller
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PG_INFO_FILE = '.pytest_pg_info.json'
|
||||
|
||||
|
||||
def _pg_info_path(session):
|
||||
"""Return the path to the shared PG connection info file."""
|
||||
return pathlib.Path(session.config.rootpath) / _PG_INFO_FILE
|
||||
|
||||
|
||||
def _extract_pg_info(pg):
|
||||
"""Extract connection info dict from a running PostgresContainer."""
|
||||
return {
|
||||
'host': pg.get_container_host_ip(),
|
||||
'port': pg.get_exposed_port(5432),
|
||||
'user': pg.username,
|
||||
'password': pg.password,
|
||||
'default_dbname': pg.dbname,
|
||||
}
|
||||
|
||||
|
||||
def _import_all_models():
|
||||
"""Import every Base subclass so Base.metadata knows about all tables.
|
||||
|
||||
These imports are deliberately kept out of module scope because some
|
||||
transitively load C-extension modules that spawn threads, which makes
|
||||
the process unsafe to fork() (used by pytest --forked). Importing
|
||||
them here — inside the controller only — avoids that problem.
|
||||
"""
|
||||
import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
import storage as _storage_pkg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
for _importer, modname, _ispkg in pkgutil.walk_packages(
|
||||
_storage_pkg.__path__, prefix='storage.'
|
||||
):
|
||||
try:
|
||||
importlib.import_module(modname)
|
||||
except ImportError as e:
|
||||
log.warning('Failed to import %s: %s', modname, e)
|
||||
|
||||
|
||||
def _create_template_db(info):
|
||||
"""Create a template database with all tables via Base.metadata.create_all()."""
|
||||
_import_all_models()
|
||||
|
||||
default_url = (
|
||||
f"postgresql+psycopg2://{info['user']}:{info['password']}"
|
||||
f"@{info['host']}:{info['port']}/{info['default_dbname']}"
|
||||
)
|
||||
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
|
||||
with default_engine.connect() as conn:
|
||||
conn.execute(text('CREATE DATABASE template_test'))
|
||||
default_engine.dispose()
|
||||
|
||||
template_url = (
|
||||
f"postgresql+psycopg2://{info['user']}:{info['password']}"
|
||||
f"@{info['host']}:{info['port']}/template_test"
|
||||
)
|
||||
template_engine = create_engine(template_url)
|
||||
Base.metadata.create_all(template_engine)
|
||||
template_engine.dispose()
|
||||
|
||||
|
||||
def pytest_sessionstart(session):
|
||||
"""Start the PostgreSQL container on the controller and create the template DB.
|
||||
|
||||
The controller (or non-xdist process) starts the container, creates the
|
||||
template database, and writes connection info to a file. Workers read the
|
||||
file to discover the shared container.
|
||||
"""
|
||||
if is_xdist_controller(session) or not hasattr(session.config, 'workerinput'):
|
||||
# Controller or non-xdist: start container and create template DB
|
||||
pg = PostgresContainer('postgres:16-alpine')
|
||||
pg.start()
|
||||
session.config._pg_container = pg
|
||||
info = _extract_pg_info(pg)
|
||||
_create_template_db(info)
|
||||
_pg_info_path(session).write_text(json.dumps(info))
|
||||
session.config._pg_info = info
|
||||
else:
|
||||
# xdist worker: read connection info written by the controller
|
||||
session.config._pg_info = json.loads(_pg_info_path(session).read_text())
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Stop the PostgreSQL container and clean up the info file."""
|
||||
pg = getattr(session.config, '_pg_container', None)
|
||||
if pg is not None:
|
||||
pg.stop()
|
||||
info_path = _pg_info_path(session)
|
||||
if info_path.exists():
|
||||
info_path.unlink()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -163,71 +57,20 @@ def create_keycloak_user_info():
|
||||
return _create
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def _pg_template_db(request):
|
||||
"""Return the shared PostgreSQL connection info from config."""
|
||||
return request.config._pg_info
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Function-scoped: one cloned database per test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def _test_db(_pg_template_db):
|
||||
"""Clone the template database for a single test."""
|
||||
info = _pg_template_db
|
||||
db_name = f'test_{uuid.uuid4().hex[:12]}'
|
||||
default_url = (
|
||||
f"postgresql+psycopg2://{info['user']}:{info['password']}"
|
||||
f"@{info['host']}:{info['port']}/{info['default_dbname']}"
|
||||
)
|
||||
|
||||
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
|
||||
with default_engine.connect() as conn:
|
||||
conn.execute(text(f'CREATE DATABASE "{db_name}" TEMPLATE template_test'))
|
||||
default_engine.dispose()
|
||||
|
||||
yield {**info, 'dbname': db_name}
|
||||
|
||||
# Teardown: terminate connections then drop the test database
|
||||
default_engine = create_engine(default_url, isolation_level='AUTOCOMMIT')
|
||||
with default_engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f'SELECT pg_terminate_backend(pid) FROM pg_stat_activity '
|
||||
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid()"
|
||||
)
|
||||
)
|
||||
conn.execute(text(f'DROP DATABASE IF EXISTS "{db_name}"'))
|
||||
default_engine.dispose()
|
||||
def db_path(tmp_path):
|
||||
"""Create a unique temp file path for each test."""
|
||||
return str(tmp_path / 'test.db')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(_test_db):
|
||||
"""Create a sync engine pointing at the per-test PostgreSQL database."""
|
||||
info = _test_db
|
||||
url = (
|
||||
f"postgresql+psycopg2://{info['user']}:{info['password']}"
|
||||
f"@{info['host']}:{info['port']}/{info['dbname']}"
|
||||
def engine(db_path):
|
||||
"""Create a sync engine with tables using file-based DB."""
|
||||
engine = create_engine(
|
||||
f'sqlite:///{db_path}', connect_args={'check_same_thread': False}
|
||||
)
|
||||
eng = create_engine(url)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine(_test_db):
|
||||
"""Create an async engine pointing at the per-test PostgreSQL database."""
|
||||
info = _test_db
|
||||
url = (
|
||||
f"postgresql+asyncpg://{info['user']}:{info['password']}"
|
||||
f"@{info['host']}:{info['port']}/{info['dbname']}"
|
||||
)
|
||||
eng = create_async_engine(url)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -235,74 +78,38 @@ def session_maker(engine):
|
||||
return sessionmaker(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def async_engine(db_path):
|
||||
"""Create an async engine using the SAME file-based database."""
|
||||
async_engine = create_async_engine(
|
||||
f'sqlite+aiosqlite:///{db_path}',
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
|
||||
async def create_tables():
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Run the async function synchronously
|
||||
import asyncio
|
||||
|
||||
asyncio.run(create_tables())
|
||||
return async_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker bound to the async engine."""
|
||||
return async_sessionmaker(
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return async_session_maker
|
||||
|
||||
|
||||
def add_minimal_fixtures(session_maker):
|
||||
with session_maker() as session:
|
||||
# Insert FK parent rows first: Org, Role, User
|
||||
session.add(
|
||||
Org(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
name='mock-org',
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Role(
|
||||
id=1,
|
||||
name='admin',
|
||||
rank=1,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
session.add(
|
||||
User(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
current_org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_consents_to_analytics=True,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
# Now insert rows that depend on Org/Role/User
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
role_id=1,
|
||||
llm_api_key='mock-api-key',
|
||||
status='active',
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredConversationMetadata(
|
||||
conversation_id='mock-conversation-id',
|
||||
created_at=datetime.fromisoformat('2025-03-07'),
|
||||
last_updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
accumulated_cost=5.25,
|
||||
prompt_tokens=500,
|
||||
completion_tokens=250,
|
||||
total_tokens=750,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
session.add(
|
||||
StoredConversationMetadataSaas(
|
||||
conversation_id='mock-conversation-id',
|
||||
user_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
org_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
BillingSession(
|
||||
id='mock-billing-session-id',
|
||||
@@ -332,6 +139,24 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-06'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredConversationMetadata(
|
||||
conversation_id='mock-conversation-id',
|
||||
created_at=datetime.fromisoformat('2025-03-07'),
|
||||
last_updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
accumulated_cost=5.25,
|
||||
prompt_tokens=500,
|
||||
completion_tokens=250,
|
||||
total_tokens=750,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredConversationMetadataSaas(
|
||||
conversation_id='mock-conversation-id',
|
||||
user_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
org_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredOfflineToken(
|
||||
user_id='mock-user-id',
|
||||
@@ -340,6 +165,38 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Org(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
name='mock-org',
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Role(
|
||||
id=1,
|
||||
name='admin',
|
||||
rank=1,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
User(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
current_org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_consents_to_analytics=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
role_id=1,
|
||||
llm_api_key='mock-api-key',
|
||||
status='active',
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StripeCustomer(
|
||||
keycloak_user_id='mock-user-id',
|
||||
|
||||
@@ -1,20 +1,48 @@
|
||||
"""Unit tests for AuthTokenStore using PostgreSQL via testcontainers."""
|
||||
"""Unit tests for AuthTokenStore using SQLite in-memory database."""
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.auth_token_store import (
|
||||
ACCESS_TOKEN_EXPIRY_BUFFER,
|
||||
LOCK_TIMEOUT_SECONDS,
|
||||
AuthTokenStore,
|
||||
)
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.base import Base
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker bound to the async engine."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
# Create all tables
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return async_session_maker
|
||||
|
||||
|
||||
class TestIsTokenExpired:
|
||||
"""Tests for _is_token_expired method."""
|
||||
|
||||
|
||||
@@ -3,9 +3,56 @@
|
||||
import pytest
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
# Use module-scoped engine to share database across fixtures
|
||||
_test_engine = None
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def event_loop():
|
||||
"""Create an instance of the default event loop for each test case."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
async def async_engine(event_loop):
|
||||
"""Create an async SQLite engine for testing.
|
||||
|
||||
This fixture creates an in-memory SQLite database and ensures
|
||||
all tables are created before tests run.
|
||||
"""
|
||||
global _test_engine
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
_test_engine = engine
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def webhook_store(async_session_maker):
|
||||
|
||||
@@ -8,11 +8,35 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import OrgAppSettingsUpdate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_app_settings_store import OrgAppSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
|
||||
@@ -9,11 +9,35 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
|
||||
@@ -14,7 +14,9 @@ from server.utils.saas_app_conversation_info_injector import (
|
||||
SaasSQLAppConversationInfoService,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
@@ -33,17 +35,41 @@ ORG2_ID = UUID('d2222222-2222-2222-2222-222222222222')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session(async_session_maker) -> AsyncGenerator[AsyncSession, None]:
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create an async session for testing."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session_maker() as db_session:
|
||||
yield db_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_with_users(
|
||||
async_session_maker,
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
async def async_session_with_users(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create an async session with pre-populated Org and User rows for testing."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session_maker() as db_session:
|
||||
# Insert Orgs first (required for User foreign key)
|
||||
@@ -637,3 +663,131 @@ class TestSaasSQLAppConversationInfoServiceAdminContext:
|
||||
|
||||
admin_page = await admin_service.search_app_conversation_info()
|
||||
assert len(admin_page.items) == 5
|
||||
|
||||
|
||||
class TestSaasSQLAppConversationInfoServiceWebhookFallback:
|
||||
"""Test suite for webhook callback fallback using info.created_by_user_id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_uses_created_by_user_id_fallback(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info uses info.created_by_user_id when user_context returns None.
|
||||
|
||||
This is the key fix for SDK-created conversations: when the webhook endpoint
|
||||
uses ADMIN context (user_id=None), the service should fall back to using
|
||||
the created_by_user_id from the AppConversationInfo object.
|
||||
"""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info with created_by_user_id set (as would come from sandbox_info)
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID), # This should be used as fallback
|
||||
sandbox_id='sandbox_webhook_test',
|
||||
title='Webhook Created Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should be created with user_id from info.created_by_user_id
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None, 'SAAS metadata should be created'
|
||||
assert (
|
||||
saas_metadata.user_id == USER1_ID
|
||||
), 'user_id should match info.created_by_user_id'
|
||||
assert saas_metadata.org_id == ORG1_ID, 'org_id should match user current org'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_with_admin_context_no_user_id_skips_saas_metadata(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test that save_app_conversation_info skips SAAS metadata when both user_context and info have no user_id."""
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Create service with ADMIN context (user_id=None)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
# Create conversation info without created_by_user_id
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=None, # No user_id available
|
||||
sandbox_id='sandbox_no_user',
|
||||
title='No User Conversation',
|
||||
)
|
||||
|
||||
# Act: Save using ADMIN context with no user_id fallback
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: SAAS metadata should NOT be created
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert (
|
||||
saas_metadata is None
|
||||
), 'SAAS metadata should not be created without user_id'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_created_conversation_visible_to_user(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""Test end-to-end: conversation saved via webhook is visible to the owning user."""
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
|
||||
# Arrange: Save conversation using ADMIN context (simulating webhook)
|
||||
admin_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=ADMIN,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_webhook_e2e',
|
||||
title='E2E Webhook Conversation',
|
||||
)
|
||||
await admin_service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Act: Query as the owning user
|
||||
user1_service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=SpecifyUserContext(user_id=str(USER1_ID)),
|
||||
)
|
||||
user1_page = await user1_service.search_app_conversation_info()
|
||||
|
||||
# Assert: User should see the webhook-created conversation
|
||||
assert len(user1_page.items) == 1
|
||||
assert user1_page.items[0].id == conv_id
|
||||
assert user1_page.items[0].title == 'E2E Webhook Conversation'
|
||||
|
||||
@@ -8,11 +8,35 @@ import uuid
|
||||
|
||||
import pytest
|
||||
from server.routes.user_app_settings_models import UserAppSettingsUpdate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
from storage.user_app_settings_store import UserAppSettingsStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_by_id_success(async_session_maker):
|
||||
"""
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
"""Unit tests for UserAuthorizationStore using PostgreSQL via testcontainers."""
|
||||
"""Unit tests for UserAuthorizationStore using SQLite in-memory database."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.user_authorization import UserAuthorization, UserAuthorizationType
|
||||
from storage.user_authorization_store import UserAuthorizationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker bound to the async engine."""
|
||||
session_maker = async_sessionmaker(
|
||||
bind=async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
return session_maker
|
||||
|
||||
|
||||
class TestGetMatchingAuthorizations:
|
||||
"""Tests for get_matching_authorizations method."""
|
||||
|
||||
|
||||
@@ -6,24 +6,13 @@ import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_org(async_session_maker):
|
||||
"""Create a test Org in the database for FK constraints."""
|
||||
_id = uuid.uuid4()
|
||||
async with async_session_maker() as session:
|
||||
session.add(Org(id=_id, name='test-org'))
|
||||
await session.commit()
|
||||
return _id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_user(test_org):
|
||||
"""Mock user with org_id backed by a real Org record."""
|
||||
def mock_user():
|
||||
"""Mock user with org_id."""
|
||||
user = MagicMock()
|
||||
user.current_org_id = test_org
|
||||
user.current_org_id = uuid.uuid4()
|
||||
return user
|
||||
|
||||
|
||||
@@ -120,11 +109,11 @@ async def test_create_api_key(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_valid(api_key_store, async_session_maker, test_org):
|
||||
async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
"""Test validating a valid API key."""
|
||||
# Setup - create an API key in the database
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-api-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -147,11 +136,11 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker, test_o
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_expired(api_key_store, async_session_maker, test_org):
|
||||
async def test_validate_api_key_expired(api_key_store, async_session_maker):
|
||||
"""Test validating an expired API key."""
|
||||
# Setup - create an expired API key in the database
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-expired-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -160,7 +149,7 @@ async def test_validate_api_key_expired(api_key_store, async_session_maker, test
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='Test Key',
|
||||
expires_at=datetime.now() - timedelta(days=1),
|
||||
expires_at=datetime.now(UTC) - timedelta(days=1),
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
@@ -175,12 +164,12 @@ async def test_validate_api_key_expired(api_key_store, async_session_maker, test
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_expired_timezone_naive(
|
||||
api_key_store, async_session_maker, test_org
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating an expired API key with timezone-naive datetime from database."""
|
||||
# Setup - create an expired API key with timezone-naive datetime
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-expired-naive-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -205,12 +194,12 @@ async def test_validate_api_key_expired_timezone_naive(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_valid_timezone_naive(
|
||||
api_key_store, async_session_maker, test_org
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test validating a valid API key with timezone-naive datetime from database."""
|
||||
# Setup - create a valid API key with timezone-naive datetime (future date)
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-valid-naive-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -246,12 +235,12 @@ async def test_validate_api_key_not_found(api_key_store, async_session_maker):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_api_key_stores_timezone_naive_last_used_at(
|
||||
api_key_store, async_session_maker, test_org
|
||||
api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that validate_api_key stores a timezone-naive datetime for last_used_at."""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-timezone-naive-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -280,11 +269,11 @@ async def test_validate_api_key_stores_timezone_naive_last_used_at(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key(api_key_store, async_session_maker, test_org):
|
||||
async def test_delete_api_key(api_key_store, async_session_maker):
|
||||
"""Test deleting an API key."""
|
||||
# Setup - create an API key in the database
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
api_key_value = 'test-delete-key'
|
||||
|
||||
async with async_session_maker() as session:
|
||||
@@ -325,11 +314,11 @@ async def test_delete_api_key_not_found(api_key_store, async_session_maker):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id(api_key_store, async_session_maker, test_org):
|
||||
async def test_delete_api_key_by_id(api_key_store, async_session_maker):
|
||||
"""Test deleting an API key by ID."""
|
||||
# Setup - create an API key in the database
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
@@ -365,7 +354,7 @@ async def test_list_api_keys(
|
||||
# Setup
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_get_user.return_value = mock_user
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create API keys in the database
|
||||
async with async_session_maker() as session:
|
||||
@@ -418,7 +407,7 @@ async def test_retrieve_mcp_api_key(
|
||||
# Setup
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_get_user.return_value = mock_user
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create API keys in the database
|
||||
async with async_session_maker() as session:
|
||||
@@ -457,7 +446,7 @@ async def test_retrieve_mcp_api_key_not_found(
|
||||
# Setup
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_get_user.return_value = mock_user
|
||||
now = datetime.now()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create only non-MCP keys in the database
|
||||
async with async_session_maker() as session:
|
||||
@@ -481,11 +470,11 @@ async def test_retrieve_mcp_api_key_not_found(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_api_key_by_name(api_key_store, async_session_maker, test_org):
|
||||
async def test_retrieve_api_key_by_name(api_key_store, async_session_maker):
|
||||
"""Test retrieving an API key by name."""
|
||||
# Setup
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
key_name = 'Test Key'
|
||||
key_value = 'test-key-by-name'
|
||||
|
||||
@@ -521,11 +510,11 @@ async def test_retrieve_api_key_by_name_not_found(api_key_store, async_session_m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_name(api_key_store, async_session_maker, test_org):
|
||||
async def test_delete_api_key_by_name(api_key_store, async_session_maker):
|
||||
"""Test deleting an API key by name."""
|
||||
# Setup
|
||||
user_id = str(uuid.uuid4())
|
||||
org_id = test_org
|
||||
org_id = uuid.uuid4()
|
||||
key_name = 'Test Key to Delete'
|
||||
key_value = 'test-delete-by-name'
|
||||
|
||||
|
||||
@@ -83,19 +83,6 @@ class TestConversationCallback:
|
||||
def conversation_metadata(self, session_maker):
|
||||
"""Create a test conversation metadata record."""
|
||||
with session_maker() as session:
|
||||
# Create FK parents first
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
org = Org(id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'), name='test-org')
|
||||
session.add(org)
|
||||
session.flush()
|
||||
user = User(
|
||||
id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'), current_org_id=org.id
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
metadata = StoredConversationMetadata(
|
||||
conversation_id='test_conversation_123'
|
||||
)
|
||||
|
||||
@@ -4,12 +4,27 @@ Test that the models are correctly defined.
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_maker(engine):
|
||||
return sessionmaker(bind=engine)
|
||||
|
||||
|
||||
def test_user_model(session_maker):
|
||||
"""Test that the User model works correctly."""
|
||||
with session_maker() as session:
|
||||
@@ -24,11 +39,6 @@ def test_user_model(session_maker):
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
# Create role (FK parent for OrgMember)
|
||||
role = Role(id=1, name='admin', rank=1)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
# Create org_member relationship
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
|
||||
@@ -2,6 +2,9 @@ import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
@@ -9,6 +12,31 @@ from storage.role import Role
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_members(async_session_maker):
|
||||
# Test getting org_members by org ID
|
||||
|
||||
@@ -124,9 +124,6 @@ async def test_create_org_with_owner_success(
|
||||
|
||||
# Create user in database first
|
||||
with session_maker() as session:
|
||||
org = Org(id=temp_org_id, name='temp-org')
|
||||
session.add(org)
|
||||
session.flush()
|
||||
user = User(id=user_id, current_org_id=temp_org_id)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
@@ -229,10 +229,7 @@ async def test_persist_org_with_owner_success(async_session_maker, mock_litellm_
|
||||
|
||||
# Create user and role first
|
||||
async with async_session_maker() as session:
|
||||
placeholder_org = Org(name='placeholder')
|
||||
session.add(placeholder_org)
|
||||
await session.flush()
|
||||
user = User(id=user_id, current_org_id=placeholder_org.id)
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(user)
|
||||
session.add(role)
|
||||
@@ -291,10 +288,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
placeholder_org = Org(name='placeholder')
|
||||
session.add(placeholder_org)
|
||||
await session.flush()
|
||||
user = User(id=user_id, current_org_id=placeholder_org.id)
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(user)
|
||||
session.add(role)
|
||||
@@ -342,10 +336,7 @@ async def test_persist_org_with_owner_transaction_atomicity(
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
placeholder_org = Org(name='placeholder')
|
||||
session.add(placeholder_org)
|
||||
await session.flush()
|
||||
user = User(id=user_id, current_org_id=placeholder_org.id)
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(user)
|
||||
session.add(role)
|
||||
@@ -398,10 +389,7 @@ async def test_persist_org_with_owner_with_multiple_fields(
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
placeholder_org = Org(name='placeholder')
|
||||
session.add(placeholder_org)
|
||||
await session.flush()
|
||||
user = User(id=user_id, current_org_id=placeholder_org.id)
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(user)
|
||||
session.add(role)
|
||||
@@ -523,19 +511,14 @@ async def test_delete_org_cascade_litellm_failure_causes_rollback(
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
org = Org(
|
||||
id=org_id,
|
||||
name='Test Organization',
|
||||
contact_name='John Doe',
|
||||
contact_email='john@example.com',
|
||||
)
|
||||
role = Role(id=1, name='owner', rank=1)
|
||||
session.add(org)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
user = User(id=user_id, current_org_id=org_id)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org_member = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
@@ -543,7 +526,7 @@ async def test_delete_org_cascade_litellm_failure_causes_rollback(
|
||||
status='active',
|
||||
llm_api_key='test-key',
|
||||
)
|
||||
session.add(org_member)
|
||||
session.add_all([role, user, org, org_member])
|
||||
await session.commit()
|
||||
|
||||
# Mock delete_org_cascade to simulate LiteLLM failure
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_role_by_id_with_session(async_session_maker):
|
||||
"""Test getting role by ID with an explicit session."""
|
||||
|
||||
@@ -34,11 +34,11 @@ def mock_user_store():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_and_get(session_maker_with_minimal_fixtures):
|
||||
async def test_save_and_get(session_maker):
|
||||
store = SaasConversationStore(
|
||||
'5594c7b6-f959-4b81-92e9-b09c206f5081',
|
||||
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
session_maker_with_minimal_fixtures,
|
||||
session_maker,
|
||||
)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='my-conversation-id',
|
||||
@@ -63,11 +63,11 @@ async def test_save_and_get(session_maker_with_minimal_fixtures):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search(session_maker_with_minimal_fixtures):
|
||||
async def test_search(session_maker):
|
||||
store = SaasConversationStore(
|
||||
'5594c7b6-f959-4b81-92e9-b09c206f5081',
|
||||
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
session_maker_with_minimal_fixtures,
|
||||
session_maker,
|
||||
)
|
||||
|
||||
# Create test conversations with different timestamps
|
||||
@@ -88,12 +88,9 @@ async def test_search(session_maker_with_minimal_fixtures):
|
||||
await store.save_metadata(conv)
|
||||
|
||||
# Test basic search - should return all valid conversations sorted by created_at
|
||||
# Note: session_maker_with_minimal_fixtures pre-populates a 'mock-conversation-id'
|
||||
# record, so we expect 6 results (1 fixture + 5 test conversations)
|
||||
result = await store.search(limit=10)
|
||||
assert len(result.results) == 6
|
||||
assert len(result.results) == 5
|
||||
assert [c.conversation_id for c in result.results] == [
|
||||
'mock-conversation-id',
|
||||
'conv-4',
|
||||
'conv-3',
|
||||
'conv-2',
|
||||
@@ -105,24 +102,21 @@ async def test_search(session_maker_with_minimal_fixtures):
|
||||
# Test pagination
|
||||
result = await store.search(limit=2)
|
||||
assert len(result.results) == 2
|
||||
assert [c.conversation_id for c in result.results] == [
|
||||
'mock-conversation-id',
|
||||
'conv-4',
|
||||
]
|
||||
assert [c.conversation_id for c in result.results] == ['conv-4', 'conv-3']
|
||||
assert result.next_page_id is not None
|
||||
|
||||
# Test next page
|
||||
result = await store.search(page_id=result.next_page_id, limit=2)
|
||||
assert len(result.results) == 2
|
||||
assert [c.conversation_id for c in result.results] == ['conv-3', 'conv-2']
|
||||
assert [c.conversation_id for c in result.results] == ['conv-2', 'conv-1']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_metadata(session_maker_with_minimal_fixtures):
|
||||
async def test_delete_metadata(session_maker):
|
||||
store = SaasConversationStore(
|
||||
'5594c7b6-f959-4b81-92e9-b09c206f5081',
|
||||
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
session_maker_with_minimal_fixtures,
|
||||
session_maker,
|
||||
)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='to-delete',
|
||||
@@ -142,22 +136,22 @@ async def test_delete_metadata(session_maker_with_minimal_fixtures):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_metadata(session_maker_with_minimal_fixtures):
|
||||
async def test_get_nonexistent_metadata(session_maker):
|
||||
store = SaasConversationStore(
|
||||
'5594c7b6-f959-4b81-92e9-b09c206f5081',
|
||||
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
session_maker_with_minimal_fixtures,
|
||||
session_maker,
|
||||
)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await store.get_metadata('nonexistent-id')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists(session_maker_with_minimal_fixtures):
|
||||
async def test_exists(session_maker):
|
||||
store = SaasConversationStore(
|
||||
'5594c7b6-f959-4b81-92e9-b09c206f5081',
|
||||
UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
session_maker_with_minimal_fixtures,
|
||||
session_maker,
|
||||
)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='exists-test',
|
||||
|
||||
@@ -5,7 +5,6 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from storage.org import Org
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
from storage.stored_custom_secrets import StoredCustomSecrets
|
||||
|
||||
@@ -30,12 +29,7 @@ def mock_user():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def secrets_store(async_session_maker, session_maker, mock_config, mock_user):
|
||||
# Create Org for mock_user's org_id to satisfy FK constraints
|
||||
with session_maker() as session:
|
||||
session.add(Org(id=mock_user.current_org_id, name='test-org'))
|
||||
session.commit()
|
||||
|
||||
def secrets_store(async_session_maker, mock_config):
|
||||
# Inject the test session maker into the store module
|
||||
import storage.saas_secrets_store as store_module
|
||||
|
||||
|
||||
+48
-3
@@ -11,7 +11,8 @@ from server.sharing.shared_conversation_models import (
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoService,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.org import Org
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
@@ -23,6 +24,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
@@ -30,8 +32,31 @@ from openhands.storage.data_models.conversation_metadata import ConversationTrig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session(async_session_maker) -> AsyncGenerator[AsyncSession, None]:
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create an async session for testing."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session_maker() as db_session:
|
||||
yield db_session
|
||||
|
||||
@@ -415,11 +440,31 @@ class TestSharedConversationInfoServiceWithSaasMetadata:
|
||||
the conversation_metadata_saas table when it exists.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine_with_saas(self):
|
||||
"""Create an async SQLite engine with all SAAS tables."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_with_saas(
|
||||
self, async_session_maker
|
||||
self, async_engine_with_saas
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create an async session for testing with SAAS tables."""
|
||||
async_session_maker = async_sessionmaker(
|
||||
async_engine_with_saas, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async with async_session_maker() as db_session:
|
||||
yield db_session
|
||||
|
||||
|
||||
@@ -27,12 +27,10 @@ def add_test_org_and_user(session_maker):
|
||||
from storage.user import User
|
||||
|
||||
with session_maker() as session:
|
||||
# Use existing role from minimal fixtures (id=1, 'admin')
|
||||
role = session.query(Role).filter(Role.id == 1).first()
|
||||
if not role:
|
||||
role = Role(name='test-role', rank=1)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
# Create role first
|
||||
role = Role(name='test-role', rank=1)
|
||||
session.add(role)
|
||||
session.flush()
|
||||
|
||||
# Create org
|
||||
org = Org(id=test_org_id, name='test-org', contact_email='testy@tester.com')
|
||||
|
||||
@@ -1236,11 +1236,10 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
|
||||
assert len(saas_rows) == 3, 'All 3 conversations should be migrated'
|
||||
|
||||
# Verify the user_id and org_id values
|
||||
# PostgreSQL returns UUID types from UUID columns, so compare with str()
|
||||
for row in saas_rows:
|
||||
assert (
|
||||
str(row.user_id) == user_uuid_str
|
||||
row.user_id == user_uuid_str
|
||||
), f'user_id should match: {row.user_id} vs {user_uuid_str}'
|
||||
assert (
|
||||
str(row.org_id) == user_uuid_str
|
||||
row.org_id == user_uuid_str
|
||||
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
|
||||
|
||||
@@ -4,6 +4,34 @@ import pytest
|
||||
from server.verified_models.verified_model_service import (
|
||||
VerifiedModelService,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.8.7",
|
||||
"@microlink/react-json-view": "^1.27.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -106,14 +106,15 @@ class EventServiceBase(EventService, ABC):
|
||||
reverse=(sort_order == EventSortOrder.TIMESTAMP_DESC),
|
||||
)
|
||||
|
||||
# Apply pagination to items (not paths)
|
||||
start_offset = 0
|
||||
next_page_id = None
|
||||
if page_id:
|
||||
start_offset = int(page_id)
|
||||
paths = paths[start_offset:]
|
||||
if len(paths) > limit:
|
||||
paths = paths[:limit]
|
||||
items = items[start_offset:]
|
||||
if len(items) > limit:
|
||||
next_page_id = str(start_offset + limit)
|
||||
items = items[:limit]
|
||||
|
||||
return EventPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import logging
|
||||
import pkgutil
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.security import APIKeyHeader
|
||||
from jwt import InvalidTokenError
|
||||
from pydantic import SecretStr
|
||||
@@ -23,61 +23,87 @@ from openhands.app_server.config import (
|
||||
depends_app_conversation_info_service,
|
||||
depends_event_service,
|
||||
depends_jwt_service,
|
||||
depends_sandbox_service,
|
||||
get_event_callback_service,
|
||||
get_global_config,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.services.jwt_service import JwtService
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
as_admin,
|
||||
)
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk import ConversationExecutionStatus, Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
|
||||
sandbox_service_dependency = depends_sandbox_service()
|
||||
event_service_dependency = depends_event_service()
|
||||
app_conversation_info_service_dependency = depends_app_conversation_info_service()
|
||||
jwt_dependency = depends_jwt_service()
|
||||
app_mode = get_global_config().app_mode
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def valid_sandbox(
|
||||
user_context: UserContext = Depends(as_admin),
|
||||
request: Request,
|
||||
session_api_key: str = Depends(
|
||||
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
|
||||
),
|
||||
sandbox_service: SandboxService = sandbox_service_dependency,
|
||||
) -> SandboxInfo:
|
||||
"""Use a session api key for validation, and get a sandbox. Subsequent actions
|
||||
are executed in the context of the owner of the sandbox"""
|
||||
if not session_api_key:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
|
||||
)
|
||||
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
# Create a state which will be used internally only for this operation
|
||||
state = InjectorState()
|
||||
|
||||
# Since we need access to all sandboxes, this is executed in the context of the admin.
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
async with get_sandbox_service(state) as sandbox_service:
|
||||
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
|
||||
session_api_key
|
||||
)
|
||||
return sandbox_info
|
||||
if sandbox_info is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
|
||||
)
|
||||
|
||||
# In SAAS Mode there is always a user, so we set the owner of the sandbox
|
||||
# as the current user (Validated by the session_api_key they provided)
|
||||
if sandbox_info.created_by_user_id:
|
||||
setattr(
|
||||
request.state,
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext(sandbox_info.created_by_user_id),
|
||||
)
|
||||
elif app_mode == AppMode.SAAS:
|
||||
_logger.error(
|
||||
'Sandbox had no user specified', extra={'sandbox_id': sandbox_info.id}
|
||||
)
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, detail='Sandbox had no user specified'
|
||||
)
|
||||
|
||||
return sandbox_info
|
||||
|
||||
|
||||
async def valid_conversation(
|
||||
conversation_id: UUID,
|
||||
sandbox_info: SandboxInfo,
|
||||
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
) -> AppConversationInfo:
|
||||
app_conversation_info = (
|
||||
@@ -90,9 +116,11 @@ async def valid_conversation(
|
||||
sandbox_id=sandbox_info.id,
|
||||
created_by_user_id=sandbox_info.created_by_user_id,
|
||||
)
|
||||
|
||||
# Sanity check - Make sure that the conversation and sandbox were created by the same user
|
||||
if app_conversation_info.created_by_user_id != sandbox_info.created_by_user_id:
|
||||
# Make sure that the conversation and sandbox were created by the same user
|
||||
raise AuthError()
|
||||
|
||||
return app_conversation_info
|
||||
|
||||
|
||||
@@ -139,15 +167,11 @@ async def on_conversation_update(
|
||||
async def on_event(
|
||||
events: list[Event],
|
||||
conversation_id: UUID,
|
||||
sandbox_info: SandboxInfo = Depends(valid_sandbox),
|
||||
app_conversation_info: AppConversationInfo = Depends(valid_conversation),
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
event_service: EventService = event_service_dependency,
|
||||
) -> Success:
|
||||
"""Webhook callback for when event stream events occur."""
|
||||
app_conversation_info = await valid_conversation(
|
||||
conversation_id, sandbox_info, app_conversation_info_service
|
||||
)
|
||||
|
||||
try:
|
||||
# Save events...
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.12.0-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.13.0-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
Generated
+24
-26
@@ -6367,14 +6367,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad"},
|
||||
{file = "openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d"},
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6391,14 +6391,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02"},
|
||||
{file = "openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9"},
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6421,14 +6421,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33"},
|
||||
{file = "openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c"},
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -11577,14 +11577,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13"},
|
||||
{file = "pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d"},
|
||||
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
|
||||
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -13579,24 +13579,22 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.4"
|
||||
version = "6.5.5"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "runtime"]
|
||||
files = [
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc"},
|
||||
{file = "tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1"},
|
||||
{file = "tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b"},
|
||||
{file = "tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6"},
|
||||
{file = "tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -14848,4 +14846,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
|
||||
content-hash = "8988a1da93e30d92a44ff7690ad39ce34a164c3a7b249e0d63a270a505bd52a9"
|
||||
|
||||
+7
-7
@@ -57,9 +57,9 @@ dependencies = [
|
||||
"numpy",
|
||||
"openai==2.8",
|
||||
"openhands-aci==0.3.3",
|
||||
"openhands-agent-server==1.12",
|
||||
"openhands-sdk==1.12",
|
||||
"openhands-tools==1.12",
|
||||
"openhands-agent-server==1.13",
|
||||
"openhands-sdk==1.13",
|
||||
"openhands-tools==1.13",
|
||||
"opentelemetry-api>=1.33.1",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
|
||||
"pathspec>=0.12.1",
|
||||
@@ -144,7 +144,7 @@ runtime = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -249,9 +249,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
openhands-sdk = "1.12"
|
||||
openhands-agent-server = "1.12"
|
||||
openhands-tools = "1.12"
|
||||
openhands-sdk = "1.13"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
jwcrypto = ">=1.5.6"
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
@@ -161,6 +161,113 @@ class TestFilesystemEventServiceSearchEvents:
|
||||
assert hasattr(result, 'next_page_id')
|
||||
assert len(result.items) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_limits_results(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that search_events respects the limit parameter for pagination."""
|
||||
conversation_id = uuid4()
|
||||
total_events = 10
|
||||
page_limit = 3
|
||||
|
||||
# Create more events than the limit
|
||||
for _ in range(total_events):
|
||||
await service.save_event(conversation_id, create_token_event())
|
||||
|
||||
# First page should return only 'limit' events
|
||||
result = await service.search_events(conversation_id, limit=page_limit)
|
||||
|
||||
assert len(result.items) == page_limit
|
||||
assert result.next_page_id is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_iterates_all_events(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that pagination correctly iterates through all events without duplicates.
|
||||
|
||||
This test verifies the fix for a bug where pagination was applied to 'paths'
|
||||
instead of 'items', causing all events to be returned on every page.
|
||||
"""
|
||||
conversation_id = uuid4()
|
||||
total_events = 10
|
||||
page_limit = 3
|
||||
|
||||
# Create events and track their IDs
|
||||
created_event_ids = set()
|
||||
for _ in range(total_events):
|
||||
event = create_token_event()
|
||||
created_event_ids.add(event.id)
|
||||
await service.save_event(conversation_id, event)
|
||||
|
||||
# Iterate through all pages and collect event IDs
|
||||
collected_event_ids = set()
|
||||
page_id = None
|
||||
page_count = 0
|
||||
|
||||
while True:
|
||||
result = await service.search_events(
|
||||
conversation_id, page_id=page_id, limit=page_limit
|
||||
)
|
||||
page_count += 1
|
||||
|
||||
for item in result.items:
|
||||
# Verify no duplicates - this would fail with the old buggy code
|
||||
assert item.id not in collected_event_ids, (
|
||||
f'Duplicate event {item.id} found on page {page_count}'
|
||||
)
|
||||
collected_event_ids.add(item.id)
|
||||
|
||||
if result.next_page_id is None:
|
||||
break
|
||||
page_id = result.next_page_id
|
||||
|
||||
# Verify we got all events exactly once
|
||||
assert collected_event_ids == created_event_ids
|
||||
assert len(collected_event_ids) == total_events
|
||||
|
||||
# With 10 events and limit of 3, we should have 4 pages (3+3+3+1)
|
||||
expected_pages = (total_events + page_limit - 1) // page_limit
|
||||
assert page_count == expected_pages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_pagination_with_filters(
|
||||
self, service: FilesystemEventService
|
||||
):
|
||||
"""Test that pagination works correctly when combined with filters."""
|
||||
conversation_id = uuid4()
|
||||
|
||||
# Create a mix of events
|
||||
token_events = [create_token_event() for _ in range(5)]
|
||||
pause_events = [create_pause_event() for _ in range(3)]
|
||||
|
||||
for event in token_events + pause_events:
|
||||
await service.save_event(conversation_id, event)
|
||||
|
||||
# Search only for token events with pagination
|
||||
page_limit = 2
|
||||
collected_ids = set()
|
||||
page_id = None
|
||||
|
||||
while True:
|
||||
result = await service.search_events(
|
||||
conversation_id,
|
||||
kind__eq='TokenEvent',
|
||||
page_id=page_id,
|
||||
limit=page_limit,
|
||||
)
|
||||
|
||||
for item in result.items:
|
||||
assert item.kind == 'TokenEvent'
|
||||
collected_ids.add(item.id)
|
||||
|
||||
if result.next_page_id is None:
|
||||
break
|
||||
page_id = result.next_page_id
|
||||
|
||||
# Should have found all 5 token events
|
||||
assert len(collected_ids) == 5
|
||||
|
||||
|
||||
class TestFilesystemEventServiceIntegration:
|
||||
"""Integration tests for FilesystemEventService."""
|
||||
|
||||
@@ -3,18 +3,65 @@
|
||||
This module tests the webhook authentication and authorization logic.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import contextlib
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
router as webhook_router,
|
||||
)
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
valid_conversation,
|
||||
valid_sandbox,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class MockRequestState:
|
||||
"""A mock request state that tracks attribute assignments."""
|
||||
|
||||
def __init__(self):
|
||||
self._state = {}
|
||||
self._attributes = {}
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
if name.startswith('_'):
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
self._attributes[name] = value
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name in self._attributes:
|
||||
return self._attributes[name]
|
||||
raise AttributeError(
|
||||
f"'{type(self).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
|
||||
def create_mock_request():
|
||||
"""Create a mock FastAPI Request object with proper state."""
|
||||
request = MagicMock()
|
||||
request.state = MockRequestState()
|
||||
return request
|
||||
|
||||
|
||||
def create_sandbox_service_context_manager(sandbox_service):
|
||||
"""Create an async context manager that yields the given sandbox service."""
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _context_manager(state, request=None):
|
||||
yield sandbox_service
|
||||
|
||||
return _context_manager
|
||||
|
||||
|
||||
class TestValidSandbox:
|
||||
@@ -22,14 +69,15 @@ class TestValidSandbox:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_with_valid_api_key(self):
|
||||
"""Test that valid API key returns sandbox info."""
|
||||
"""Test that valid API key returns sandbox info and sets user_context."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key-123'
|
||||
user_id = 'user-123'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-123',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id='user-123',
|
||||
created_by_user_id=user_id,
|
||||
sandbox_spec_id='spec-123',
|
||||
)
|
||||
|
||||
@@ -38,12 +86,17 @@ class TestValidSandbox:
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == expected_sandbox
|
||||
@@ -51,18 +104,136 @@ class TestValidSandbox:
|
||||
session_api_key
|
||||
)
|
||||
|
||||
# Verify user_context is set correctly on request.state
|
||||
assert USER_CONTEXT_ATTR in mock_request.state._attributes
|
||||
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
|
||||
assert isinstance(user_context, SpecifyUserContext)
|
||||
assert user_context.user_id == user_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_sets_user_context_to_sandbox_owner(self):
|
||||
"""Test that user_context is set to the sandbox owner's user ID."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
sandbox_owner_id = 'sandbox-owner-user-id'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-456',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=sandbox_owner_id,
|
||||
sandbox_spec_id='spec-456',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Assert - user_context should be set to the sandbox owner
|
||||
assert USER_CONTEXT_ATTR in mock_request.state._attributes
|
||||
user_context = mock_request.state._attributes[USER_CONTEXT_ATTR]
|
||||
assert isinstance(user_context, SpecifyUserContext)
|
||||
assert user_context.user_id == sandbox_owner_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_no_user_context_when_no_user_id(self):
|
||||
"""Test that user_context is not set when sandbox has no created_by_user_id."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-789',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=None, # No user ID
|
||||
sandbox_spec_id='spec-789',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Assert - sandbox is returned but user_context should NOT be set
|
||||
assert result == expected_sandbox
|
||||
|
||||
# Verify user_context is NOT set on request.state
|
||||
assert USER_CONTEXT_ATTR not in mock_request.state._attributes
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_no_user_context_when_no_user_id_raises_401_in_saas_mode(
|
||||
self,
|
||||
):
|
||||
"""Test that user_context is not set when sandbox has no created_by_user_id."""
|
||||
# Arrange
|
||||
session_api_key = 'valid-api-key'
|
||||
expected_sandbox = SandboxInfo(
|
||||
id='sandbox-789',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key=session_api_key,
|
||||
created_by_user_id=None, # No user ID
|
||||
sandbox_spec_id='spec-789',
|
||||
)
|
||||
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_sandbox_service.get_sandbox_by_session_api_key = AsyncMock(
|
||||
return_value=expected_sandbox
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.app_mode',
|
||||
AppMode.SAAS,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as excinfo:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
assert excinfo.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sandbox_without_api_key_raises_401(self):
|
||||
"""Test that missing API key raises 401 error."""
|
||||
# Arrange
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
request=mock_request,
|
||||
session_api_key=None,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -78,13 +249,18 @@ class TestValidSandbox:
|
||||
return_value=None
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert 'Invalid session API key' in exc_info.value.detail
|
||||
@@ -95,13 +271,13 @@ class TestValidSandbox:
|
||||
# Arrange - empty string is falsy, so it gets rejected at the check
|
||||
session_api_key = ''
|
||||
mock_sandbox_service = AsyncMock()
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert - should raise 401 because empty string fails the truth check
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -263,12 +439,17 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=conversation_info
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act - Call valid_sandbox first
|
||||
sandbox_result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
sandbox_result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# Then call valid_conversation
|
||||
conversation_result = await valid_conversation(
|
||||
@@ -291,13 +472,18 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=None
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act & Assert - Should fail at valid_sandbox
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@@ -328,12 +514,17 @@ class TestWebhookAuthenticationIntegration:
|
||||
return_value=different_user_info
|
||||
)
|
||||
|
||||
mock_request = create_mock_request()
|
||||
|
||||
# Act - valid_sandbox succeeds
|
||||
sandbox_result = await valid_sandbox(
|
||||
user_context=ADMIN,
|
||||
session_api_key=session_api_key,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
)
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.get_sandbox_service',
|
||||
create_sandbox_service_context_manager(mock_sandbox_service),
|
||||
):
|
||||
sandbox_result = await valid_sandbox(
|
||||
request=mock_request,
|
||||
session_api_key=session_api_key,
|
||||
)
|
||||
|
||||
# But valid_conversation fails
|
||||
from openhands.app_server.errors import AuthError
|
||||
@@ -344,3 +535,88 @@ class TestWebhookAuthenticationIntegration:
|
||||
sandbox_info=sandbox_result,
|
||||
app_conversation_info_service=mock_conversation_service,
|
||||
)
|
||||
|
||||
|
||||
class TestWebhookRouterHTTPIntegration:
|
||||
"""Integration tests for webhook router HTTP layer.
|
||||
|
||||
These tests validate that FastAPI routing correctly extracts conversation_id
|
||||
from the request body rather than requiring it as a query parameter.
|
||||
"""
|
||||
|
||||
def test_conversation_update_endpoint_does_not_require_query_param(self):
|
||||
"""Test that /webhooks/conversations endpoint accepts conversation_id in body only.
|
||||
|
||||
This test validates the fix for the regression where the endpoint incorrectly
|
||||
required conversation_id as a query parameter due to using Depends(valid_conversation).
|
||||
|
||||
The endpoint should:
|
||||
1. Accept POST requests without any query parameters
|
||||
2. Extract conversation_id from the request body (conversation_info.id)
|
||||
3. Return 401 (not 422) when auth fails, proving the request was parsed correctly
|
||||
"""
|
||||
# Create a minimal FastAPI app with just the webhook router
|
||||
app = FastAPI()
|
||||
app.include_router(webhook_router, prefix='/api/v1')
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
# Create a valid request body with conversation_id in it
|
||||
conversation_id = str(uuid4())
|
||||
request_body = {
|
||||
'id': conversation_id,
|
||||
'execution_status': 'running',
|
||||
'agent': {
|
||||
'llm': {
|
||||
'model': 'gpt-4',
|
||||
},
|
||||
},
|
||||
'stats': {
|
||||
'usage_to_metrics': {},
|
||||
},
|
||||
}
|
||||
|
||||
# POST to /webhooks/conversations WITHOUT any query parameters
|
||||
# If the old bug existed (conversation_id required as query param),
|
||||
# FastAPI would return 422 Unprocessable Entity
|
||||
response = client.post(
|
||||
'/api/v1/webhooks/conversations',
|
||||
json=request_body,
|
||||
# No X-Session-API-Key header - should fail auth but NOT validation
|
||||
)
|
||||
|
||||
# We expect 401 Unauthorized (missing session API key)
|
||||
# NOT 422 Unprocessable Entity (which would indicate conversation_id
|
||||
# was incorrectly required as a query parameter)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED, (
|
||||
f'Expected 401 (auth failure), got {response.status_code}. '
|
||||
f'If 422, the endpoint incorrectly requires conversation_id as query param. '
|
||||
f'Response: {response.json()}'
|
||||
)
|
||||
assert response.json()['detail'] == 'X-Session-API-Key header is required'
|
||||
|
||||
def test_events_endpoint_still_requires_conversation_id_in_path(self):
|
||||
"""Test that /webhooks/events/{conversation_id} correctly requires path param.
|
||||
|
||||
This ensures we didn't accidentally break the events endpoint which legitimately
|
||||
requires conversation_id as a path parameter.
|
||||
"""
|
||||
# Create a minimal FastAPI app with just the webhook router
|
||||
app = FastAPI()
|
||||
app.include_router(webhook_router, prefix='/api/v1')
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
conversation_id = str(uuid4())
|
||||
request_body = [] # Empty events list
|
||||
|
||||
# POST to /webhooks/events/{conversation_id} with path parameter
|
||||
response = client.post(
|
||||
f'/api/v1/webhooks/events/{conversation_id}',
|
||||
json=request_body,
|
||||
# No X-Session-API-Key header - should fail auth but NOT validation
|
||||
)
|
||||
|
||||
# We expect 401 Unauthorized (missing session API key)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert response.json()['detail'] == 'X-Session-API-Key header is required'
|
||||
|
||||
@@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.event_callback.webhook_router import on_conversation_update
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
|
||||
from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
@@ -118,9 +119,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Saved conversation retains the parent_conversation_id
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -137,12 +135,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return existing conversation
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -175,9 +172,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Saved conversation has parent_conversation_id as None
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
conversation_id = mock_conversation_info.id
|
||||
@@ -191,12 +185,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=None,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return existing conversation
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -228,9 +221,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- New conversation has parent_conversation_id as None
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
conversation_id = mock_conversation_info.id
|
||||
@@ -242,12 +232,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
created_by_user_id=sandbox_info.created_by_user_id,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return stub (as it would for new conversation)
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=stub_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -280,9 +269,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- All metadata including parent_conversation_id is preserved
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -302,12 +288,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return existing conversation
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -349,9 +334,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Parent_conversation_id remains unchanged after all updates
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -366,9 +348,8 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return conversation with parent
|
||||
# In real scenario, this would be retrieved from DB after first save
|
||||
async def mock_valid_conv(*args, **kwargs):
|
||||
# Act - Update multiple times, simulating what valid_conversation would return
|
||||
for _ in range(3):
|
||||
# After first save, get from DB with parent preserved
|
||||
saved = await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
@@ -376,21 +357,20 @@ class TestOnConversationUpdateParentConversationId:
|
||||
if saved:
|
||||
# Override created_by_user_id for auth check
|
||||
saved.created_by_user_id = 'user_123'
|
||||
return saved
|
||||
return initial_conv
|
||||
existing = saved
|
||||
else:
|
||||
existing = initial_conv
|
||||
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
side_effect=mock_valid_conv,
|
||||
):
|
||||
# Act - Update multiple times
|
||||
for _ in range(3):
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing,
|
||||
):
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
app_conversation_info_service=app_conversation_info_service,
|
||||
)
|
||||
assert isinstance(result, Success)
|
||||
assert isinstance(result, Success)
|
||||
|
||||
# Assert
|
||||
saved_conv = await app_conversation_info_service.get_app_conversation_info(
|
||||
@@ -417,9 +397,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Function returns early, no updates are made
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -441,12 +418,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
# Set conversation to DELETING status
|
||||
mock_conversation_info.execution_status = ConversationExecutionStatus.DELETING
|
||||
|
||||
# Mock valid_conversation (though it won't be called for DELETING status)
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
@@ -481,9 +457,6 @@ class TestOnConversationUpdateParentConversationId:
|
||||
Assert:
|
||||
- Parent_conversation_id is preserved and title is generated
|
||||
"""
|
||||
from openhands.app_server.event_callback.webhook_router import (
|
||||
on_conversation_update,
|
||||
)
|
||||
|
||||
# Arrange
|
||||
parent_id = uuid4()
|
||||
@@ -498,12 +471,11 @@ class TestOnConversationUpdateParentConversationId:
|
||||
parent_conversation_id=parent_id,
|
||||
)
|
||||
|
||||
# Mock valid_conversation to return existing conversation
|
||||
# Act - call on_conversation_update directly with mocked valid_conversation
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=existing_conv,
|
||||
):
|
||||
# Act
|
||||
result = await on_conversation_update(
|
||||
conversation_info=mock_conversation_info,
|
||||
sandbox_info=sandbox_info,
|
||||
|
||||
@@ -451,11 +451,9 @@ class TestOnEventStatsProcessing:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_processes_stats_events(self):
|
||||
"""Test that on_event processes stats events."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import on_event
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = 'sandbox_123'
|
||||
@@ -482,15 +480,6 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
events = [stats_event, other_event]
|
||||
|
||||
# Mock dependencies
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test_key',
|
||||
created_by_user_id='user_123',
|
||||
sandbox_spec_id='spec_123',
|
||||
)
|
||||
|
||||
mock_app_conversation_info = AppConversationInfo(
|
||||
id=conversation_id,
|
||||
sandbox_id=sandbox_id,
|
||||
@@ -499,9 +488,6 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
mock_event_service = AsyncMock()
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
|
||||
# Set up process_stats_event to call update_conversation_statistics
|
||||
async def process_stats_event_side_effect(event, conversation_id):
|
||||
@@ -519,44 +505,33 @@ class TestOnEventStatsProcessing:
|
||||
process_stats_event_side_effect
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=mock_app_conversation_info,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
) as mock_callbacks,
|
||||
):
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
) as mock_callbacks:
|
||||
# Call on_event directly with dependencies
|
||||
await on_event(
|
||||
events=events,
|
||||
conversation_id=conversation_id,
|
||||
sandbox_info=mock_sandbox,
|
||||
app_conversation_info=mock_app_conversation_info,
|
||||
app_conversation_info_service=mock_app_conversation_info_service,
|
||||
event_service=mock_event_service,
|
||||
)
|
||||
|
||||
# Verify events were saved
|
||||
assert mock_event_service.save_event.call_count == 2
|
||||
# Verify events were saved
|
||||
assert mock_event_service.save_event.call_count == 2
|
||||
|
||||
# Verify stats event was processed
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
|
||||
# Verify stats event was processed
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
|
||||
|
||||
# Verify callbacks were scheduled
|
||||
mock_callbacks.assert_called_once()
|
||||
# Verify callbacks were scheduled
|
||||
mock_callbacks.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_skips_non_stats_events(self):
|
||||
"""Test that on_event skips non-stats events."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands.app_server.event_callback.webhook_router import on_event
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
|
||||
conversation_id = uuid4()
|
||||
@@ -568,14 +543,6 @@ class TestOnEventStatsProcessing:
|
||||
MessageAction(content='test'),
|
||||
]
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test_key',
|
||||
created_by_user_id='user_123',
|
||||
sandbox_spec_id='spec_123',
|
||||
)
|
||||
|
||||
mock_app_conversation_info = AppConversationInfo(
|
||||
id=conversation_id,
|
||||
sandbox_id=sandbox_id,
|
||||
@@ -584,30 +551,18 @@ class TestOnEventStatsProcessing:
|
||||
|
||||
mock_event_service = AsyncMock()
|
||||
mock_app_conversation_info_service = AsyncMock()
|
||||
mock_app_conversation_info_service.get_app_conversation_info.return_value = (
|
||||
mock_app_conversation_info
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_sandbox',
|
||||
return_value=mock_sandbox,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router.valid_conversation',
|
||||
return_value=mock_app_conversation_info,
|
||||
),
|
||||
patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
),
|
||||
with patch(
|
||||
'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
|
||||
):
|
||||
# Call on_event directly with dependencies
|
||||
await on_event(
|
||||
events=events,
|
||||
conversation_id=conversation_id,
|
||||
sandbox_info=mock_sandbox,
|
||||
app_conversation_info=mock_app_conversation_info,
|
||||
app_conversation_info_service=mock_app_conversation_info_service,
|
||||
event_service=mock_event_service,
|
||||
)
|
||||
|
||||
# Verify stats update was NOT called
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
|
||||
# Verify stats update was NOT called
|
||||
mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
|
||||
|
||||
@@ -3642,7 +3642,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -3656,9 +3656,9 @@ dependencies = [
|
||||
{ name = "websockets" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/18/d76d977201ec93faf22d6cc979b5c9953a0b554bf3294cdb3186d48a5d5a/openhands_agent_server-1.12.0.tar.gz", hash = "sha256:7ea7ce579175f713ed68b68cde5d685ef694627ac7bbff40d2e22913f065c46d", size = 72715, upload-time = "2026-03-05T19:22:23.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/d0/419756ad3368e7ab47c07111dfb4bf40073c110817914e09553b8e056fe8/openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a", size = 73594, upload-time = "2026-03-10T18:41:25.52Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/47/dc31d7ffd6f6687ce4cc0114e01cf1f7f13f9ba841cd47dac5a983e57fb9/openhands_agent_server-1.12.0-py3-none-any.whl", hash = "sha256:3bd62fef10092f1155af116a8a7417041d574eff9d4e4b6f7a24bfc432de2fad", size = 87800, upload-time = "2026-03-05T19:22:27.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/e1/77b9b3181e6cba89c601533757d148f911416ff968a4ea5fe0882d479ccf/openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29", size = 88607, upload-time = "2026-03-10T18:41:18.321Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3826,9 +3826,9 @@ requires-dist = [
|
||||
{ name = "numpy" },
|
||||
{ name = "openai", specifier = "==2.8" },
|
||||
{ name = "openhands-aci", specifier = "==0.3.3" },
|
||||
{ name = "openhands-agent-server", specifier = "==1.12" },
|
||||
{ name = "openhands-sdk", specifier = "==1.12" },
|
||||
{ name = "openhands-tools", specifier = "==1.12" },
|
||||
{ name = "openhands-agent-server", specifier = "==1.13" },
|
||||
{ name = "openhands-sdk", specifier = "==1.13" },
|
||||
{ name = "openhands-tools", specifier = "==1.13" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
|
||||
{ name = "pathspec", specifier = ">=0.12.1" },
|
||||
@@ -3906,7 +3906,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "agent-client-protocol" },
|
||||
@@ -3923,14 +3923,14 @@ dependencies = [
|
||||
{ name = "tenacity" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/44/715dd4c43e1a4ba2c47ebd251240dd6aca0dd604cc1354932f0344f93b40/openhands_sdk-1.12.0.tar.gz", hash = "sha256:ac348e7134ea21e1ab453978962504aff8eb47e62df1fb7a503d769d55658ea9", size = 323133, upload-time = "2026-03-05T19:22:26.623Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/5e35e99252f16c3e9b8eec843b7054ed7d3ad9fadcc0b40064ab3de55469/openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c", size = 330526, upload-time = "2026-03-10T18:41:19.513Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/2f/b7ba4f261d806aaab46f372d2049503ccedde373bb0648b88ebce58ebfe7/openhands_sdk-1.12.0-py3-none-any.whl", hash = "sha256:857793f5c27fd63c0d4d37762550e6c504a03dd06116475c23adcc14bb5c4c02", size = 411337, upload-time = "2026-03-05T19:22:29.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b1/31737964179a8e5a0ed1d0485082a703e2d4cd346701ab4a383ddf33eebb/openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185", size = 420504, upload-time = "2026-03-10T18:41:24.224Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bashlex" },
|
||||
@@ -3943,9 +3943,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "tom-swe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/84/9552e75326c341707d36f7a86ba9a55a8fcb48bfd97e4d1ebe989260fdd8/openhands_tools-1.12.0.tar.gz", hash = "sha256:f2b4d81d0b6771f5416f8b702db09a14999fa8e553073bcf38f344e29aae770c", size = 110293, upload-time = "2026-03-05T19:22:23.906Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/91/0af0f29dc0da57e7df13bd1653eff80d5c47b8311c6825568837d6ba2af7/openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d", size = 111922, upload-time = "2026-03-10T18:41:26.872Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/26/70031063c81bb1215f5a5d85c33c4e62e6a3d318dd8e3609e5ce68040faa/openhands_tools-1.12.0-py3-none-any.whl", hash = "sha256:57207e9e30f9d7fe9121cd21b072580cfdc2a00831edeaf8e8d685d721bb9e33", size = 150468, upload-time = "2026-03-05T19:22:24.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/e7/44d677fdd73f249c9bc8a76d2a32848ed96f54324b7d4b0589bb70f7d4e8/openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68", size = 152193, upload-time = "2026-03-10T18:41:20.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7383,11 +7383,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.7.5"
|
||||
version = "6.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/52/37cc0aa9e9d1bf7729a737a0d83f8b3f851c8eb137373d9f71eafb0a3405/pypdf-6.7.5.tar.gz", hash = "sha256:40bb2e2e872078655f12b9b89e2f900888bb505e88a82150b64f9f34fa25651d", size = 5304278, upload-time = "2026-03-02T09:05:21.464Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/89/336673efd0a88956562658aba4f0bbef7cb92a6fbcbcaf94926dbc82b408/pypdf-6.7.5-py3-none-any.whl", hash = "sha256:07ba7f1d6e6d9aa2a17f5452e320a84718d4ce863367f7ede2fd72280349ab13", size = 331421, upload-time = "2026-03-02T09:05:19.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8528,21 +8528,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.4"
|
||||
version = "6.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user