215 lines
8.0 KiB
Python
215 lines
8.0 KiB
Python
"""Runtime transport implementations for the OpenClaw adapter."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from typing import Any, Callable, Mapping, Protocol
|
|
|
|
from .contract import (
|
|
CommandState,
|
|
OpenClawCommand,
|
|
OpenClawResult,
|
|
RuntimeHealth,
|
|
utc_now,
|
|
)
|
|
|
|
|
|
class RuntimeTransport(Protocol):
|
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
|
def cancel(self, command_id: str) -> bool: ...
|
|
def health(self) -> RuntimeHealth: ...
|
|
|
|
|
|
class TransportError(RuntimeError):
|
|
"""Runtime transport or protocol failure."""
|
|
|
|
|
|
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
|
|
|
|
|
|
class InProcessRuntimeTransport:
|
|
"""Deterministic runtime used by tests and controlled local integrations."""
|
|
|
|
def __init__(
|
|
self,
|
|
handler: RuntimeHandler,
|
|
*,
|
|
runtime_id: str = "openclaw-in-process",
|
|
version: str = "v1",
|
|
workspace_isolated: bool = True,
|
|
credential_ref_configured: bool = True,
|
|
) -> None:
|
|
self.handler = handler
|
|
self.runtime_id = runtime_id
|
|
self.version = version
|
|
self.workspace_isolated = workspace_isolated
|
|
self.credential_ref_configured = credential_ref_configured
|
|
self._lock = threading.RLock()
|
|
self._running: set[str] = set()
|
|
self._cancelled: set[str] = set()
|
|
|
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
|
if timeout_seconds <= 0:
|
|
raise ValueError("timeout_seconds must be positive")
|
|
with self._lock:
|
|
if command.command_id in self._cancelled:
|
|
return OpenClawResult(
|
|
command_id=command.command_id,
|
|
tenant_id=command.tenant_id,
|
|
task_id=command.task_id,
|
|
trace_id=command.trace_id,
|
|
state=CommandState.CANCELLED,
|
|
error={"code": "cancelled", "message": "cancelled before execution"},
|
|
finished_at=utc_now(),
|
|
runtime_id=self.runtime_id,
|
|
)
|
|
self._running.add(command.command_id)
|
|
try:
|
|
raw = self.handler(command)
|
|
if isinstance(raw, OpenClawResult):
|
|
return raw
|
|
if not isinstance(raw, Mapping):
|
|
raise TransportError("runtime handler must return a mapping or OpenClawResult")
|
|
return OpenClawResult(
|
|
command_id=command.command_id,
|
|
tenant_id=command.tenant_id,
|
|
task_id=command.task_id,
|
|
trace_id=command.trace_id,
|
|
state=CommandState(str(raw.get("state", "COMPLETED"))),
|
|
output=raw.get("output"),
|
|
error=raw.get("error"),
|
|
started_at=str(raw.get("started_at") or utc_now()),
|
|
finished_at=str(raw.get("finished_at") or utc_now()),
|
|
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
|
|
attempts=int(raw.get("attempts", 1)),
|
|
)
|
|
finally:
|
|
with self._lock:
|
|
self._running.discard(command.command_id)
|
|
|
|
def cancel(self, command_id: str) -> bool:
|
|
with self._lock:
|
|
self._cancelled.add(command_id)
|
|
return command_id in self._running
|
|
|
|
def health(self) -> RuntimeHealth:
|
|
with self._lock:
|
|
active = len(self._running)
|
|
return RuntimeHealth(
|
|
runtime="openclaw",
|
|
runtime_id=self.runtime_id,
|
|
status="healthy",
|
|
version=self.version,
|
|
heartbeat_at=utc_now(),
|
|
active_tasks=active,
|
|
workspace_isolated=self.workspace_isolated,
|
|
credential_ref_configured=self.credential_ref_configured,
|
|
details={"transport": "in_process"},
|
|
)
|
|
|
|
|
|
class HTTPRuntimeTransport:
|
|
"""Private HTTP transport for an OpenClaw runtime service."""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
*,
|
|
runtime_id: str = "openclaw-http",
|
|
credential_ref: str | None = None,
|
|
max_response_bytes: int = 2_097_152,
|
|
) -> None:
|
|
normalized = str(base_url).rstrip("/")
|
|
if not normalized.startswith(("http://", "https://")):
|
|
raise ValueError("base_url must use http or https")
|
|
self.base_url = normalized
|
|
self.runtime_id = runtime_id
|
|
self.credential_ref = credential_ref
|
|
self.max_response_bytes = int(max_response_bytes)
|
|
|
|
def _request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
payload: Mapping[str, Any] | None,
|
|
timeout_seconds: float,
|
|
) -> Mapping[str, Any]:
|
|
body = None
|
|
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
|
|
if payload is not None:
|
|
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
if self.credential_ref:
|
|
headers["X-AIOS-Credential-Ref"] = self.credential_ref
|
|
request = urllib.request.Request(
|
|
f"{self.base_url}{path}",
|
|
data=body,
|
|
method=method,
|
|
headers=headers,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
|
raw = response.read(self.max_response_bytes + 1)
|
|
except urllib.error.HTTPError as exc:
|
|
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
|
|
if len(raw) > self.max_response_bytes:
|
|
raise TransportError("runtime response exceeded size limit")
|
|
decoded = json.loads(raw.decode("utf-8"))
|
|
if not isinstance(decoded, Mapping):
|
|
raise TransportError("runtime response must be a JSON object")
|
|
return decoded
|
|
|
|
@staticmethod
|
|
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
|
|
return {**command.unsigned_dict(), "signature": command.signature}
|
|
|
|
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
|
value = self._request(
|
|
"POST",
|
|
"/v1/execute",
|
|
payload=self._command_payload(command),
|
|
timeout_seconds=timeout_seconds,
|
|
)
|
|
return OpenClawResult(
|
|
command_id=str(value["command_id"]),
|
|
tenant_id=str(value["tenant_id"]),
|
|
task_id=str(value["task_id"]),
|
|
trace_id=str(value["trace_id"]),
|
|
state=CommandState(str(value["state"])),
|
|
output=value.get("output"),
|
|
error=value.get("error"),
|
|
started_at=value.get("started_at"),
|
|
finished_at=value.get("finished_at"),
|
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
|
attempts=int(value.get("attempts", 1)),
|
|
)
|
|
|
|
def cancel(self, command_id: str) -> bool:
|
|
value = self._request(
|
|
"POST",
|
|
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
|
|
payload={},
|
|
timeout_seconds=10.0,
|
|
)
|
|
return bool(value.get("accepted"))
|
|
|
|
def health(self) -> RuntimeHealth:
|
|
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
|
|
return RuntimeHealth(
|
|
runtime=str(value.get("runtime") or "openclaw"),
|
|
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
|
status=str(value.get("status") or "unknown"),
|
|
version=str(value.get("version") or "unknown"),
|
|
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
|
|
active_tasks=int(value.get("active_tasks", 0)),
|
|
workspace_isolated=bool(value.get("workspace_isolated", False)),
|
|
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
|
|
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
|
|
)
|