mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
feat(api): add legacy compatibility governance
This commit is contained in:
+19
-2
@@ -52,6 +52,13 @@ else:
|
||||
("AuthTier", "RiskTier", "RoutePlane", "endpoint_metadata"),
|
||||
)
|
||||
|
||||
(build_legacy_route_deprecation_headers,) = import_attrs_dual(
|
||||
__package__,
|
||||
"..services.legacy_compat",
|
||||
"services.legacy_compat",
|
||||
("build_legacy_route_deprecation_headers",),
|
||||
)
|
||||
|
||||
try:
|
||||
from aiohttp import web # type: ignore
|
||||
except ModuleNotFoundError: # pragma: no cover (optional for unit tests)
|
||||
@@ -730,7 +737,15 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
|
||||
print(
|
||||
f"[OpenClaw] DEPRECATION WARNING: Legacy route accessed: {request.path}. Please migrate to /openclaw/* equivalents."
|
||||
)
|
||||
return await handler(request)
|
||||
response = await handler(request)
|
||||
if build_legacy_route_deprecation_headers:
|
||||
headers = build_legacy_route_deprecation_headers(
|
||||
getattr(request, "path", path)
|
||||
)
|
||||
response_headers = getattr(response, "headers", None)
|
||||
if headers and hasattr(response_headers, "update"):
|
||||
response_headers.update(headers)
|
||||
return response
|
||||
|
||||
actual_handler = _deprecated_handler
|
||||
|
||||
@@ -751,7 +766,9 @@ def register_dual_route(server, method: str, path: str, handler) -> None:
|
||||
targets = [path, "/api" + path]
|
||||
for t in targets:
|
||||
try:
|
||||
server.app.router.add_route(method, t, handler)
|
||||
# IMPORTANT: fallback routes must use the same wrapper as PromptServer.
|
||||
# Registering the raw legacy handler bypasses deprecation telemetry/headers.
|
||||
server.app.router.add_route(method, t, actual_handler)
|
||||
except RuntimeError:
|
||||
# Route likely exists (e.g. added by step 1 or duplicate)
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Legacy Compatibility Governance
|
||||
|
||||
OpenClaw keeps selected legacy compatibility aliases so older workflows, browser extensions, and deployment scripts have a predictable migration path. New integrations should use the canonical OpenClaw names.
|
||||
|
||||
Compatibility aliases are governed by explicit status, review cadence, telemetry, and removal criteria. An alias is not removed just because a canonical replacement exists; removal requires usage evidence and regression coverage.
|
||||
|
||||
## Status Labels
|
||||
|
||||
- `deprecated-observed`: the alias is still accepted, emits telemetry or warnings where practical, and should move to the canonical surface.
|
||||
- `retained-compatibility`: the alias remains available for older workflows or deployments, with review based on diagnostics, tests, and operator reports.
|
||||
|
||||
## Review Policy
|
||||
|
||||
Every legacy alias has:
|
||||
|
||||
- a review cadence in days
|
||||
- a telemetry or evidence signal
|
||||
- a review trigger
|
||||
- concrete removal criteria
|
||||
|
||||
Removal requires all of these conditions:
|
||||
|
||||
- no observed compatibility usage for two consecutive review windows
|
||||
- a documented canonical migration path
|
||||
- targeted regression coverage and release notes for the removal
|
||||
|
||||
## Governed Aliases
|
||||
|
||||
| Key | Surface | Legacy alias | Canonical surface | Status | Telemetry or evidence |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `api-path-moltbot-prefix` | API path | `/moltbot/*` and `/api/moltbot/*` | `/openclaw/*` and `/api/openclaw/*` | `deprecated-observed` | `legacy_api_hits` |
|
||||
| `header-x-moltbot-aliases` | Header | `X-Moltbot-*` request headers | `X-OpenClaw-*` request headers | `deprecated-observed` | `legacy_api_hits` and warning logs |
|
||||
| `environment-moltbot-prefix` | Environment | `MOLTBOT_*` environment variables | `OPENCLAW_*` environment variables | `retained-compatibility` | configuration diagnostics and warning logs |
|
||||
| `ui-class-moltbot-prefix` | UI class | `moltbot-*` CSS classes and local UI keys | `openclaw-*` CSS classes and local UI keys | `retained-compatibility` | frontend compatibility helper tests and operator reports |
|
||||
| `workflow-node-moltbot-classes` | Workflow node | `Moltbot*` node class aliases and `moltbot` node category | `OpenClaw*` node classes and `openclaw` node category | `retained-compatibility` | workflow portability diagnostics and node-registration regression tests |
|
||||
|
||||
## Operator Visibility
|
||||
|
||||
Legacy API path requests expose deprecation response headers when the response type supports headers:
|
||||
|
||||
- `Deprecation: true`
|
||||
- `X-OpenClaw-Compatibility-Key`
|
||||
- `X-OpenClaw-Compatibility-Status`
|
||||
- `X-OpenClaw-Compatibility-Telemetry`
|
||||
- `X-OpenClaw-Canonical-Path`
|
||||
|
||||
Use these headers with server logs and `legacy_api_hits` to decide whether a deployment still depends on legacy route aliases.
|
||||
+126
-1
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping, Optional, Tuple
|
||||
from typing import Dict, Mapping, Optional, Tuple
|
||||
|
||||
OPENCLAW_API_PREFIX = "/openclaw"
|
||||
LEGACY_API_PREFIX = "/moltbot"
|
||||
@@ -22,6 +22,20 @@ class HeaderAlias:
|
||||
legacy: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacyCompatibilityEntry:
|
||||
key: str
|
||||
surface: str
|
||||
legacy: str
|
||||
canonical: str
|
||||
status: str
|
||||
review_cadence_days: int
|
||||
telemetry_signal: str
|
||||
removal_criteria: Tuple[str, ...]
|
||||
review_trigger: str
|
||||
operator_notice: str
|
||||
|
||||
|
||||
ADMIN_TOKEN_HEADERS = HeaderAlias(
|
||||
primary="X-OpenClaw-Admin-Token",
|
||||
legacy="X-Moltbot-Admin-Token",
|
||||
@@ -47,6 +61,84 @@ WEBHOOK_NONCE_HEADERS = HeaderAlias(
|
||||
legacy="X-Moltbot-Nonce",
|
||||
)
|
||||
|
||||
DEPRECATED_OBSERVED = "deprecated-observed"
|
||||
RETAINED_COMPATIBILITY = "retained-compatibility"
|
||||
|
||||
LEGACY_ROUTE_COMPATIBILITY_KEY = "api-path-moltbot-prefix"
|
||||
|
||||
_STANDARD_REMOVAL_CRITERIA = (
|
||||
"No observed compatibility usage for two consecutive review windows.",
|
||||
"A public migration path exists for the canonical OpenClaw surface.",
|
||||
"Removal is covered by targeted regression tests and release notes.",
|
||||
)
|
||||
|
||||
_LEGACY_COMPATIBILITY_ENTRIES = (
|
||||
LegacyCompatibilityEntry(
|
||||
key=LEGACY_ROUTE_COMPATIBILITY_KEY,
|
||||
surface="api_path",
|
||||
legacy="/moltbot/* and /api/moltbot/*",
|
||||
canonical="/openclaw/* and /api/openclaw/*",
|
||||
status=DEPRECATED_OBSERVED,
|
||||
review_cadence_days=90,
|
||||
telemetry_signal="legacy_api_hits",
|
||||
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
|
||||
review_trigger="Review whenever legacy_api_hits remains zero for the review window or rises after a release.",
|
||||
operator_notice="Use canonical /openclaw routes; legacy routes emit deprecation headers.",
|
||||
),
|
||||
LegacyCompatibilityEntry(
|
||||
key="header-x-moltbot-aliases",
|
||||
surface="header",
|
||||
legacy="X-Moltbot-* request headers",
|
||||
canonical="X-OpenClaw-* request headers",
|
||||
status=DEPRECATED_OBSERVED,
|
||||
review_cadence_days=90,
|
||||
telemetry_signal="legacy_api_hits",
|
||||
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
|
||||
review_trigger="Review with API-path telemetry and any legacy-header warning logs.",
|
||||
operator_notice="Prefer X-OpenClaw-* headers; legacy headers log deprecation warnings.",
|
||||
),
|
||||
LegacyCompatibilityEntry(
|
||||
key="environment-moltbot-prefix",
|
||||
surface="environment",
|
||||
legacy="MOLTBOT_* environment variables",
|
||||
canonical="OPENCLAW_* environment variables",
|
||||
status=RETAINED_COMPATIBILITY,
|
||||
review_cadence_days=180,
|
||||
telemetry_signal="configuration diagnostics and deprecation warning logs",
|
||||
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
|
||||
review_trigger="Review when config diagnostics show no legacy env usage across supported deployment profiles.",
|
||||
operator_notice="Prefer OPENCLAW_* variables; legacy MOLTBOT_* fallbacks remain compatibility-only.",
|
||||
),
|
||||
LegacyCompatibilityEntry(
|
||||
key="ui-class-moltbot-prefix",
|
||||
surface="ui_class",
|
||||
legacy="moltbot-* CSS classes and local UI keys",
|
||||
canonical="openclaw-* CSS classes and local UI keys",
|
||||
status=RETAINED_COMPATIBILITY,
|
||||
review_cadence_days=180,
|
||||
telemetry_signal="frontend compatibility helper tests and user-reported extension compatibility",
|
||||
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
|
||||
review_trigger="Review when canonical frontend markup has shipped through two stable release windows.",
|
||||
operator_notice="Use openclaw-* selectors for new integrations; moltbot-* aliases are generated for older extensions.",
|
||||
),
|
||||
LegacyCompatibilityEntry(
|
||||
key="workflow-node-moltbot-classes",
|
||||
surface="workflow_node",
|
||||
legacy="Moltbot* node class aliases and moltbot node category",
|
||||
canonical="OpenClaw* node classes and openclaw node category",
|
||||
status=RETAINED_COMPATIBILITY,
|
||||
review_cadence_days=180,
|
||||
telemetry_signal="workflow portability diagnostics and node-registration regression tests",
|
||||
removal_criteria=_STANDARD_REMOVAL_CRITERIA,
|
||||
review_trigger="Review after node metadata migration proves older workflows keep deterministic replacement hints.",
|
||||
operator_notice="Keep canonical OpenClaw node names in new workflows; legacy Moltbot names remain for older workflow loads.",
|
||||
),
|
||||
)
|
||||
|
||||
_LEGACY_COMPATIBILITY_BY_KEY: Dict[str, LegacyCompatibilityEntry] = {
|
||||
entry.key: entry for entry in _LEGACY_COMPATIBILITY_ENTRIES
|
||||
}
|
||||
|
||||
|
||||
def _header_value(headers: Mapping[str, str], name: str) -> str:
|
||||
value = headers.get(name)
|
||||
@@ -104,6 +196,39 @@ def get_header_alias_value(
|
||||
return "", False
|
||||
|
||||
|
||||
def iter_legacy_compatibility_entries() -> Tuple[LegacyCompatibilityEntry, ...]:
|
||||
return _LEGACY_COMPATIBILITY_ENTRIES
|
||||
|
||||
|
||||
def get_legacy_compatibility_entry(
|
||||
key: str,
|
||||
) -> Optional[LegacyCompatibilityEntry]:
|
||||
return _LEGACY_COMPATIBILITY_BY_KEY.get(key)
|
||||
|
||||
|
||||
def canonicalize_legacy_api_path(path: str) -> str:
|
||||
if path.startswith("/api" + LEGACY_API_PREFIX + "/"):
|
||||
return path.replace(LEGACY_API_PREFIX, OPENCLAW_API_PREFIX, 1)
|
||||
if path.startswith(LEGACY_API_PREFIX + "/"):
|
||||
return path.replace(LEGACY_API_PREFIX, OPENCLAW_API_PREFIX, 1)
|
||||
return path
|
||||
|
||||
|
||||
def build_legacy_route_deprecation_headers(path: str) -> Dict[str, str]:
|
||||
canonical_path = canonicalize_legacy_api_path(path)
|
||||
if canonical_path == path:
|
||||
return {}
|
||||
|
||||
entry = _LEGACY_COMPATIBILITY_BY_KEY[LEGACY_ROUTE_COMPATIBILITY_KEY]
|
||||
return {
|
||||
"Deprecation": "true",
|
||||
"X-OpenClaw-Compatibility-Key": entry.key,
|
||||
"X-OpenClaw-Compatibility-Status": entry.status,
|
||||
"X-OpenClaw-Compatibility-Telemetry": entry.telemetry_signal,
|
||||
"X-OpenClaw-Canonical-Path": canonical_path,
|
||||
}
|
||||
|
||||
|
||||
def get_api_path_candidates(path: str) -> Tuple[str, ...]:
|
||||
if path.startswith(OPENCLAW_API_PREFIX + "/"):
|
||||
return (path, path.replace(OPENCLAW_API_PREFIX, LEGACY_API_PREFIX, 1))
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from services.legacy_compat import (
|
||||
ADMIN_TOKEN_HEADERS,
|
||||
LEGACY_API_PREFIX,
|
||||
OPENCLAW_API_PREFIX,
|
||||
build_legacy_route_deprecation_headers,
|
||||
get_api_path_candidates,
|
||||
get_header_alias_value,
|
||||
iter_legacy_compatibility_entries,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +60,49 @@ class TestLegacyCompat(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(get_api_path_candidates("/history/abc"), ("/history/abc",))
|
||||
|
||||
def test_governance_registry_covers_remaining_legacy_alias_surfaces(self):
|
||||
entries = tuple(iter_legacy_compatibility_entries())
|
||||
surfaces = {entry.surface for entry in entries}
|
||||
|
||||
self.assertIn("api_path", surfaces)
|
||||
self.assertIn("header", surfaces)
|
||||
self.assertIn("environment", surfaces)
|
||||
self.assertIn("ui_class", surfaces)
|
||||
self.assertIn("workflow_node", surfaces)
|
||||
|
||||
for entry in entries:
|
||||
with self.subTest(entry=entry.key):
|
||||
self.assertTrue(entry.key)
|
||||
self.assertTrue(entry.status)
|
||||
self.assertGreater(entry.review_cadence_days, 0)
|
||||
self.assertTrue(entry.telemetry_signal)
|
||||
self.assertTrue(entry.removal_criteria)
|
||||
self.assertTrue(entry.review_trigger)
|
||||
|
||||
def test_legacy_route_deprecation_headers_include_canonical_path(self):
|
||||
headers = build_legacy_route_deprecation_headers("/api/moltbot/health")
|
||||
|
||||
self.assertEqual(headers["Deprecation"], "true")
|
||||
self.assertEqual(headers["X-OpenClaw-Canonical-Path"], "/api/openclaw/health")
|
||||
self.assertEqual(
|
||||
headers["X-OpenClaw-Compatibility-Status"],
|
||||
"deprecated-observed",
|
||||
)
|
||||
self.assertEqual(
|
||||
headers["X-OpenClaw-Compatibility-Telemetry"],
|
||||
"legacy_api_hits",
|
||||
)
|
||||
|
||||
def test_governance_doc_mentions_every_registry_key(self):
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
doc = (repo_root / "docs" / "legacy-compatibility-governance.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
for entry in iter_legacy_compatibility_entries():
|
||||
with self.subTest(entry=entry.key):
|
||||
self.assertIn(entry.key, doc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
from asyncio import run
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# from aiohttp import web # Removed for CI compatibility (no extra deps)
|
||||
@@ -33,6 +34,41 @@ class TestRouteRegistration(unittest.TestCase):
|
||||
self.assertIn("/moltbot/test", paths_registered)
|
||||
self.assertIn("/api/moltbot/test", paths_registered)
|
||||
|
||||
def test_direct_legacy_fallback_uses_deprecation_wrapper(self):
|
||||
server = MagicMock()
|
||||
server.routes = MagicMock()
|
||||
server.app = MagicMock()
|
||||
server.app.router = MagicMock()
|
||||
server.app.router.add_route = MagicMock()
|
||||
|
||||
class Response:
|
||||
def __init__(self):
|
||||
self.headers = {}
|
||||
|
||||
class Request:
|
||||
path = "/api/moltbot/test"
|
||||
|
||||
async def handler(req):
|
||||
return Response()
|
||||
|
||||
register_dual_route(server, "GET", "/moltbot/test", handler)
|
||||
|
||||
fallback_handler = None
|
||||
for call in server.app.router.add_route.call_args_list:
|
||||
if call.args[1] == "/api/moltbot/test":
|
||||
fallback_handler = call.args[2]
|
||||
break
|
||||
|
||||
self.assertIsNotNone(fallback_handler)
|
||||
self.assertIsNot(fallback_handler, handler)
|
||||
|
||||
response = run(fallback_handler(Request()))
|
||||
self.assertEqual(response.headers["Deprecation"], "true")
|
||||
self.assertEqual(
|
||||
response.headers["X-OpenClaw-Canonical-Path"],
|
||||
"/api/openclaw/test",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user