From 6b09f124e6582c75487c28575f7946ee546325b8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 22 Feb 2026 22:05:39 +0530 Subject: [PATCH] refactor: rename compatibility check function and update version extraction logic #159 --- .../app/services/openclaw/admin_service.py | 4 +- .../app/services/openclaw/gateway_compat.py | 126 +++---------- .../app/services/openclaw/session_service.py | 4 +- backend/tests/test_gateway_version_compat.py | 172 +++++++++--------- 4 files changed, 115 insertions(+), 191 deletions(-) diff --git a/backend/app/services/openclaw/admin_service.py b/backend/app/services/openclaw/admin_service.py index 87973540..ee7235f5 100644 --- a/backend/app/services/openclaw/admin_service.py +++ b/backend/app/services/openclaw/admin_service.py @@ -28,7 +28,7 @@ from app.services.openclaw.db_agent_state import ( ) from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.error_messages import normalize_gateway_error_message -from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility +from app.services.openclaw.gateway_compat import check_gateway_version_compatibility from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.provisioning import OpenClawGatewayProvisioner @@ -200,7 +200,7 @@ class GatewayAdminLifecycleService(OpenClawDBService): disable_device_pairing=disable_device_pairing, ) try: - result = await check_gateway_runtime_compatibility(config) + result = await check_gateway_version_compatibility(config) except OpenClawGatewayError as exc: detail = normalize_gateway_error_message(str(exc)) raise HTTPException( diff --git a/backend/app/services/openclaw/gateway_compat.py b/backend/app/services/openclaw/gateway_compat.py index 2ef7ff8d..ce1b99ec 100644 --- a/backend/app/services/openclaw/gateway_compat.py +++ b/backend/app/services/openclaw/gateway_compat.py @@ -4,30 +4,18 @@ from __future__ import annotations import re from dataclasses import dataclass -from typing import Any from app.core.config import settings from app.services.openclaw.gateway_rpc import ( GatewayConfig, - OpenClawGatewayError, - openclaw_call, openclaw_connect_metadata, ) -_VERSION_PATTERN = re.compile(r"(?i)v?(?P\d+(?:\.\d+)+)") -_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = ( - ("version",), - ("gatewayVersion",), - ("appVersion",), - ("buildVersion",), - ("gateway", "version"), - ("app", "version"), - ("server", "version"), - ("runtime", "version"), - ("meta", "version"), - ("build", "version"), - ("info", "version"), +_CALVER_PATTERN = re.compile( + r"^v?(?P\d{4})\.(?P\d{1,2})\.(?P\d{1,2})(?:-(?P\d+))?$", + re.IGNORECASE, ) +_CONNECT_VERSION_PATH: tuple[str, ...] = ("server", "version") @dataclass(frozen=True, slots=True) @@ -46,11 +34,18 @@ def _normalized_minimum_version() -> str: def _parse_version_parts(value: str) -> tuple[int, ...] | None: - match = _VERSION_PATTERN.search(value.strip()) + match = _CALVER_PATTERN.match(value.strip()) if match is None: return None - numeric = match.group("version") - return tuple(int(part) for part in numeric.split(".")) + year = int(match.group("year")) + month = int(match.group("month")) + day = int(match.group("day")) + revision = int(match.group("rev") or 0) + if month < 1 or month > 12: + return None + if day < 1 or day > 31: + return None + return (year, month, day, revision) def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int: @@ -84,36 +79,9 @@ def _coerce_version_string(value: object) -> str | None: return None -def _iter_fallback_version_values(payload: object) -> list[str]: - if not isinstance(payload, dict): - return [] - stack: list[dict[str, Any]] = [payload] - discovered: list[str] = [] - while stack: - node = stack.pop() - for key, value in node.items(): - if isinstance(value, dict): - stack.append(value) - key_lower = key.lower() - if "version" not in key_lower or "protocol" in key_lower: - continue - candidate = _coerce_version_string(value) - if candidate is not None: - discovered.append(candidate) - return discovered - - -def extract_gateway_version(payload: object) -> str | None: - """Extract a gateway runtime version string from status/health payloads.""" - for path in _PRIMARY_VERSION_PATHS: - candidate = _coerce_version_string(_value_at_path(payload, path)) - if candidate is not None: - return candidate - - for candidate in _iter_fallback_version_values(payload): - if _parse_version_parts(candidate) is not None: - return candidate - return None +def extract_connect_server_version(payload: object) -> str | None: + """Extract the canonical runtime version from connect metadata.""" + return _coerce_version_string(_value_at_path(payload, _CONNECT_VERSION_PATH)) def evaluate_gateway_version( @@ -127,7 +95,7 @@ def evaluate_gateway_version( if min_parts is None: msg = ( "Server configuration error: GATEWAY_MIN_VERSION is invalid. " - f"Expected a dotted numeric version, got '{min_version}'." + f"Expected CalVer 'YYYY.M.D' or 'YYYY.M.D-REV', got '{min_version}'." ) return GatewayVersionCheckResult( compatible=False, @@ -177,64 +145,14 @@ def evaluate_gateway_version( ) -async def _fetch_runtime_metadata(config: GatewayConfig) -> object: - last_error: OpenClawGatewayError | None = None - for method in ("status", "health"): - try: - return await openclaw_call(method, config=config) - except OpenClawGatewayError as exc: - last_error = exc - continue - if last_error is not None: - raise last_error - return {} - - -async def _fetch_schema_metadata(config: GatewayConfig) -> object | None: - try: - return await openclaw_call("config.schema", config=config) - except OpenClawGatewayError: - return None - - -async def _fetch_connect_metadata(config: GatewayConfig) -> object | None: - try: - return await openclaw_connect_metadata(config=config) - except OpenClawGatewayError: - return None - - -async def check_gateway_runtime_compatibility( +async def check_gateway_version_compatibility( config: GatewayConfig, *, minimum_version: str | None = None, ) -> GatewayVersionCheckResult: - """Fetch runtime metadata and evaluate gateway version compatibility.""" - connect_payload = await _fetch_connect_metadata(config) - current_version = extract_gateway_version(connect_payload) - if current_version is not None: - return evaluate_gateway_version( - current_version=current_version, - minimum_version=minimum_version, - ) - - schema_payload = await _fetch_schema_metadata(config) - current_version = extract_gateway_version(schema_payload) - if current_version is not None: - return evaluate_gateway_version( - current_version=current_version, - minimum_version=minimum_version, - ) - - payload = await _fetch_runtime_metadata(config) - current_version = extract_gateway_version(payload) - if current_version is None: - try: - health_payload = await openclaw_call("health", config=config) - except OpenClawGatewayError: - health_payload = None - if health_payload is not None: - current_version = extract_gateway_version(health_payload) + """Use connect metadata server.version and evaluate compatibility.""" + connect_payload = await openclaw_connect_metadata(config=config) + current_version = extract_connect_server_version(connect_payload) return evaluate_gateway_version( current_version=current_version, minimum_version=minimum_version, diff --git a/backend/app/services/openclaw/session_service.py b/backend/app/services/openclaw/session_service.py index 2f7e3e1b..9d7059d5 100644 --- a/backend/app/services/openclaw/session_service.py +++ b/backend/app/services/openclaw/session_service.py @@ -21,7 +21,7 @@ from app.schemas.gateway_api import ( ) from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.error_messages import normalize_gateway_error_message -from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility +from app.services.openclaw.gateway_compat import check_gateway_version_compatibility from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import ( @@ -197,7 +197,7 @@ class GatewaySessionService(OpenClawDBService): board, config, main_session = await self.resolve_gateway(params, user=user) self._require_same_org(board, organization_id) try: - compatibility = await check_gateway_runtime_compatibility(config) + compatibility = await check_gateway_version_compatibility(config) except OpenClawGatewayError as exc: return GatewaysStatusResponse( connected=False, diff --git a/backend/tests/test_gateway_version_compat.py b/backend/tests/test_gateway_version_compat.py index 1de0da6d..ee27a754 100644 --- a/backend/tests/test_gateway_version_compat.py +++ b/backend/tests/test_gateway_version_compat.py @@ -16,147 +16,153 @@ from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayErro from app.services.openclaw.session_service import GatewaySessionService -def test_extract_gateway_version_prefers_primary_path() -> None: +def test_extract_connect_server_version_uses_server_version_as_source_of_truth() -> None: payload = { - "gateway": {"version": "2026.2.1"}, - "protocolVersion": 3, - "meta": {"version": "2026.1.30"}, + "version": "dev", + "runtime": {"version": "2026.1.0"}, + "server": {"version": "2026.2.21-2"}, } - assert gateway_compat.extract_gateway_version(payload) == "2026.2.1" + assert gateway_compat.extract_connect_server_version(payload) == "2026.2.21-2" -def test_evaluate_gateway_version_detects_old_runtime() -> None: +def test_extract_connect_server_version_returns_none_when_server_version_missing() -> None: + payload = { + "version": "2026.2.21-2", + "runtime": {"version": "2026.2.21-2"}, + } + + assert gateway_compat.extract_connect_server_version(payload) is None + + +@pytest.mark.parametrize( + ("current_version", "minimum_version", "expected_compatible"), + [ + ("2026.2.21", "2026.2.21", True), + ("2026.02.20", "2026.2.20", True), + ("2026.2.22", "2026.2.21", True), + ("2026.2.21-2", "2026.2.21-1", True), + ("2026.2.21-1", "2026.2.21-2", False), + ("2026.2.20", "2026.2.21", False), + ], +) +def test_evaluate_gateway_version_compares_calver( + *, + current_version: str, + minimum_version: str, + expected_compatible: bool, +) -> None: result = gateway_compat.evaluate_gateway_version( - current_version="2025.12.1", + current_version=current_version, + minimum_version=minimum_version, + ) + + assert result.compatible is expected_compatible + assert result.current_version == current_version + assert result.minimum_version == minimum_version + + +@pytest.mark.parametrize("invalid_current", ["dev", "latest", "2026.13.1", "2026.2.0-beta"]) +def test_evaluate_gateway_version_rejects_non_calver_current(invalid_current: str) -> None: + result = gateway_compat.evaluate_gateway_version( + current_version=invalid_current, minimum_version="2026.1.30", ) assert result.compatible is False - assert result.minimum_version == "2026.1.30" - assert "Minimum supported version is 2026.1.30" in (result.message or "") + assert result.current_version == invalid_current + assert "unsupported version format" in (result.message or "").lower() + + +def test_evaluate_gateway_version_rejects_non_calver_minimum_version() -> None: + result = gateway_compat.evaluate_gateway_version( + current_version="2026.2.21", + minimum_version="1.2.3", + ) + + assert result.compatible is False + assert result.minimum_version == "1.2.3" + assert "expected calver" in (result.message or "").lower() @pytest.mark.asyncio -async def test_check_gateway_runtime_compatibility_prefers_schema_version( +async def test_check_gateway_version_compatibility_uses_connect_server_version_only( monkeypatch: pytest.MonkeyPatch, ) -> None: - calls: list[str] = [] - - async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: - _ = (params, config) - calls.append(method) - if method == "config.schema": - return {"version": "2026.2.13"} - raise AssertionError(f"unexpected method: {method}") - async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: _ = config - return None + return { + "version": "dev", + "runtime": {"version": "2026.1.0"}, + "server": {"version": "2026.2.13"}, + } monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) - monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) - result = await gateway_compat.check_gateway_runtime_compatibility( + result = await gateway_compat.check_gateway_version_compatibility( GatewayConfig(url="ws://gateway.example/ws"), minimum_version="2026.1.30", ) - assert calls == ["config.schema"] assert result.compatible is True assert result.current_version == "2026.2.13" @pytest.mark.asyncio -async def test_check_gateway_runtime_compatibility_prefers_connect_metadata( +async def test_check_gateway_version_compatibility_fails_without_server_version( monkeypatch: pytest.MonkeyPatch, ) -> None: - calls: list[str] = [] - async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: _ = config - return {"server": {"version": "2026.2.21-2"}} - - async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: - _ = (params, config) - calls.append(method) - raise AssertionError(f"unexpected method: {method}") + return {"runtime": {"version": "2026.2.13"}} monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) - monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) - result = await gateway_compat.check_gateway_runtime_compatibility( + result = await gateway_compat.check_gateway_version_compatibility( GatewayConfig(url="ws://gateway.example/ws"), minimum_version="2026.1.30", ) - assert calls == [] - assert result.compatible is True - assert result.current_version == "2026.2.21-2" + assert result.compatible is False + assert result.current_version is None + assert "unable to determine gateway version" in (result.message or "").lower() @pytest.mark.asyncio -async def test_check_gateway_runtime_compatibility_falls_back_to_health( +async def test_check_gateway_version_compatibility_rejects_non_calver_server_version( monkeypatch: pytest.MonkeyPatch, ) -> None: - calls: list[str] = [] - - async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: - _ = (params, config) - calls.append(method) - if method == "config.schema": - raise OpenClawGatewayError("unknown method") - if method == "status": - raise OpenClawGatewayError("unknown method") - return {"version": "2026.2.0"} - async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: _ = config - return None + return {"server": {"version": "dev"}} monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) - monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) - result = await gateway_compat.check_gateway_runtime_compatibility( + result = await gateway_compat.check_gateway_version_compatibility( GatewayConfig(url="ws://gateway.example/ws"), minimum_version="2026.1.30", ) - assert calls == ["config.schema", "status", "health"] - assert result.compatible is True - assert result.current_version == "2026.2.0" + assert result.compatible is False + assert result.current_version == "dev" + assert "unsupported version format" in (result.message or "").lower() @pytest.mark.asyncio -async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_no_version( +async def test_check_gateway_version_compatibility_propagates_connect_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: - calls: list[str] = [] - - async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object: - _ = (params, config) - calls.append(method) - if method == "config.schema": - return {"schema": {"title": "Gateway schema"}} - if method == "status": - return {"uptime": 1234} - return {"version": "2026.2.0"} - async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None: _ = config - return None + raise OpenClawGatewayError("connection refused") monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata) - monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call) - result = await gateway_compat.check_gateway_runtime_compatibility( - GatewayConfig(url="ws://gateway.example/ws"), - minimum_version="2026.1.30", - ) - - assert calls == ["config.schema", "status", "health"] - assert result.compatible is True - assert result.current_version == "2026.2.0" + with pytest.raises(OpenClawGatewayError, match="connection refused"): + await gateway_compat.check_gateway_version_compatibility( + GatewayConfig(url="ws://gateway.example/ws"), + minimum_version="2026.1.30", + ) @pytest.mark.asyncio @@ -172,7 +178,7 @@ async def test_admin_service_rejects_incompatible_gateway( message="Gateway version 2026.1.0 is not supported.", ) - monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check) service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type] with pytest.raises(HTTPException) as exc_info: @@ -190,7 +196,7 @@ async def test_admin_service_maps_gateway_transport_errors( _ = (config, minimum_version) raise OpenClawGatewayError("connection refused") - monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check) service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type] with pytest.raises(HTTPException) as exc_info: @@ -208,7 +214,7 @@ async def test_admin_service_maps_gateway_scope_errors_with_guidance( _ = (config, minimum_version) raise OpenClawGatewayError("missing scope: operator.read") - monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check) service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type] with pytest.raises(HTTPException) as exc_info: @@ -231,7 +237,7 @@ async def test_gateway_status_reports_incompatible_version( message="Gateway version 2026.1.0 is not supported.", ) - monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check) service = GatewaySessionService(session=object()) # type: ignore[arg-type] response = await service.get_status( @@ -252,7 +258,7 @@ async def test_gateway_status_surfaces_scope_error_guidance( _ = (config, minimum_version) raise OpenClawGatewayError("missing scope: operator.read") - monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check) service = GatewaySessionService(session=object()) # type: ignore[arg-type] response = await service.get_status( @@ -284,7 +290,7 @@ async def test_gateway_status_returns_sessions_when_version_compatible( assert method == "sessions.list" return {"sessions": [{"key": "agent:main"}]} - monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check) + monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check) monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call) service = GatewaySessionService(session=object()) # type: ignore[arg-type]