feat(autonomous-release): add native session adapters
This commit is contained in:
+246
@@ -0,0 +1,246 @@
|
||||
"""Candidate-only OpenClaw and Codex native session adapter contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
import uuid
|
||||
|
||||
from .errors import StateConflict
|
||||
|
||||
|
||||
class NativeProvider(Protocol):
|
||||
simulation: bool
|
||||
|
||||
def spawn(
|
||||
self, request: Mapping[str, Any], credential_reference: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def status(
|
||||
self, provider_session_id: str, credential_reference: str
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def list_tree(
|
||||
self, parent_session_id: str, credential_reference: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
def send_text(
|
||||
self,
|
||||
provider_session_id: str,
|
||||
payload: Mapping[str, Any],
|
||||
credential_reference: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def interrupt(self, provider_session_id: str, credential_reference: str) -> None: ...
|
||||
|
||||
def close(self, provider_session_id: str, credential_reference: str) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NativeSession:
|
||||
provider: str
|
||||
broker_session_id: str
|
||||
provider_session_id: str
|
||||
task_id: str
|
||||
parent_session_id: str
|
||||
recipient_binding: Mapping[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionVerification:
|
||||
broker_session_verified: bool
|
||||
openclaw_native_child_session_verified: bool
|
||||
codex_child_session_verified: bool
|
||||
simulation: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TextResult:
|
||||
content_type: str
|
||||
encoding: str
|
||||
image_only: bool
|
||||
task_id: str
|
||||
message_id: str
|
||||
receipt: str
|
||||
recipient_binding: Mapping[str, str]
|
||||
secrets_included: bool
|
||||
|
||||
|
||||
class CandidateNativeAdapter:
|
||||
provider = ""
|
||||
required_reference = ""
|
||||
|
||||
def __init__(self, client: NativeProvider, credential_reference: str) -> None:
|
||||
if credential_reference != self.required_reference:
|
||||
raise StateConflict(
|
||||
"CANDIDATE_CREDENTIAL_REFERENCE_MISMATCH",
|
||||
"adapter requires its exact Candidate credential reference",
|
||||
)
|
||||
self.client = client
|
||||
self.credential_reference = credential_reference
|
||||
|
||||
@staticmethod
|
||||
def _recipient(value: Mapping[str, Any]) -> dict[str, str]:
|
||||
recipient_id = value.get("recipient_id")
|
||||
if (
|
||||
not isinstance(recipient_id, str)
|
||||
or not recipient_id
|
||||
or isinstance(value.get("recipients"), (list, tuple))
|
||||
):
|
||||
raise StateConflict(
|
||||
"RECIPIENT_BINDING_REQUIRED",
|
||||
"one trusted TaskPlan recipient binding is required",
|
||||
)
|
||||
return {str(key): str(item) for key, item in value.items()}
|
||||
|
||||
def spawn(
|
||||
self,
|
||||
task_id: str,
|
||||
parent_session_id: str,
|
||||
recipient_binding: Mapping[str, Any],
|
||||
) -> NativeSession:
|
||||
if not task_id or not parent_session_id:
|
||||
raise StateConflict("NATIVE_SESSION_BINDING_REQUIRED", "task and parent are required")
|
||||
recipient = self._recipient(recipient_binding)
|
||||
response = self.client.spawn(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"parent_session_id": parent_session_id,
|
||||
"recipient_binding": recipient,
|
||||
"candidate_only": True,
|
||||
"production_session_access": False,
|
||||
},
|
||||
self.credential_reference,
|
||||
)
|
||||
provider_session_id = response.get("provider_session_id")
|
||||
if (
|
||||
not isinstance(provider_session_id, str)
|
||||
or not provider_session_id
|
||||
or response.get("task_id") != task_id
|
||||
or response.get("parent_session_id") != parent_session_id
|
||||
or response.get("candidate_only") is not True
|
||||
or response.get("production_session_access") is not False
|
||||
):
|
||||
raise StateConflict(
|
||||
"NATIVE_SESSION_PROVISIONING_INVALID",
|
||||
"provider response is not bound to this Candidate task",
|
||||
)
|
||||
broker_id = f"broker-{uuid.uuid4()}"
|
||||
if broker_id == provider_session_id:
|
||||
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
|
||||
return NativeSession(
|
||||
provider=self.provider,
|
||||
broker_session_id=broker_id,
|
||||
provider_session_id=provider_session_id,
|
||||
task_id=task_id,
|
||||
parent_session_id=parent_session_id,
|
||||
recipient_binding=recipient,
|
||||
)
|
||||
|
||||
def verify(self, session: NativeSession) -> SessionVerification:
|
||||
self._validate_session(session)
|
||||
status = self.client.status(session.provider_session_id, self.credential_reference)
|
||||
tree = self.client.list_tree(session.parent_session_id, self.credential_reference)
|
||||
status_valid = (
|
||||
status.get("provider_session_id") == session.provider_session_id
|
||||
and status.get("state") in {"PROVISIONED", "ACTIVE", "RUNNING"}
|
||||
)
|
||||
tree_valid = any(
|
||||
item.get("provider_session_id") == session.provider_session_id
|
||||
and item.get("parent_session_id") == session.parent_session_id
|
||||
for item in tree
|
||||
)
|
||||
if not status_valid or not tree_valid:
|
||||
raise StateConflict(
|
||||
"NATIVE_SESSION_VERIFICATION_FAILED",
|
||||
"provider status or parent/child tree is inconsistent",
|
||||
)
|
||||
simulated = bool(getattr(self.client, "simulation", True))
|
||||
return SessionVerification(
|
||||
broker_session_verified=True,
|
||||
openclaw_native_child_session_verified=(
|
||||
self.provider == "openclaw" and not simulated
|
||||
),
|
||||
codex_child_session_verified=(self.provider == "codex" and not simulated),
|
||||
simulation=simulated,
|
||||
)
|
||||
|
||||
def send_text(self, session: NativeSession, text: str) -> TextResult:
|
||||
self._validate_session(session)
|
||||
if not isinstance(text, str) or not text:
|
||||
raise StateConflict("TEXT_RESULT_REQUIRED", "non-empty text is required")
|
||||
response = self.client.send_text(
|
||||
session.provider_session_id,
|
||||
{
|
||||
"task_id": session.task_id,
|
||||
"content_type": "text/plain",
|
||||
"encoding": "utf-8",
|
||||
"image_only": False,
|
||||
"text": text,
|
||||
"recipient_binding": dict(session.recipient_binding),
|
||||
"secrets_included": False,
|
||||
},
|
||||
self.credential_reference,
|
||||
)
|
||||
required = {
|
||||
"content_type": "text/plain",
|
||||
"encoding": "utf-8",
|
||||
"image_only": False,
|
||||
"task_id": session.task_id,
|
||||
"recipient_binding": dict(session.recipient_binding),
|
||||
"secrets_included": False,
|
||||
}
|
||||
if any(response.get(key) != value for key, value in required.items()):
|
||||
raise StateConflict("TEXT_RESULT_CONTRACT_INVALID", "text result binding is invalid")
|
||||
message_id = response.get("message_id")
|
||||
receipt = response.get("receipt")
|
||||
if not isinstance(message_id, str) or not message_id or not isinstance(receipt, str) or not receipt:
|
||||
raise StateConflict("TEXT_RESULT_RECEIPT_REQUIRED", "message and receipt are required")
|
||||
return TextResult(
|
||||
content_type="text/plain",
|
||||
encoding="utf-8",
|
||||
image_only=False,
|
||||
task_id=session.task_id,
|
||||
message_id=message_id,
|
||||
receipt=receipt,
|
||||
recipient_binding=dict(session.recipient_binding),
|
||||
secrets_included=False,
|
||||
)
|
||||
|
||||
def status(self, session: NativeSession) -> str:
|
||||
self._validate_session(session)
|
||||
response = self.client.status(session.provider_session_id, self.credential_reference)
|
||||
if response.get("provider_session_id") != session.provider_session_id:
|
||||
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider session ID changed")
|
||||
state = response.get("state")
|
||||
if not isinstance(state, str) or not state:
|
||||
raise StateConflict("NATIVE_SESSION_STATUS_INVALID", "provider state is missing")
|
||||
return state
|
||||
|
||||
def interrupt(self, session: NativeSession) -> None:
|
||||
self._validate_session(session)
|
||||
self.client.interrupt(session.provider_session_id, self.credential_reference)
|
||||
if self.status(session) != "INTERRUPTED":
|
||||
raise StateConflict("NATIVE_SESSION_INTERRUPT_FAILED", "interrupt was not observed")
|
||||
|
||||
def close(self, session: NativeSession) -> None:
|
||||
self._validate_session(session)
|
||||
self.client.close(session.provider_session_id, self.credential_reference)
|
||||
if self.status(session) != "CLOSED":
|
||||
raise StateConflict("NATIVE_SESSION_CLOSE_FAILED", "close was not observed")
|
||||
|
||||
def _validate_session(self, session: NativeSession) -> None:
|
||||
if session.provider != self.provider:
|
||||
raise StateConflict("NATIVE_SESSION_PROVIDER_MISMATCH", "session provider differs")
|
||||
if session.broker_session_id == session.provider_session_id:
|
||||
raise StateConflict("SESSION_ID_COLLISION", "broker and provider IDs must differ")
|
||||
|
||||
|
||||
class OpenClawNativeAdapter(CandidateNativeAdapter):
|
||||
provider = "openclaw"
|
||||
required_reference = "systemd-credential:openclaw-gateway-candidate"
|
||||
|
||||
|
||||
class CodexNativeAdapter(CandidateNativeAdapter):
|
||||
provider = "codex"
|
||||
required_reference = "systemd-credential:codex-app-server-candidate"
|
||||
Reference in New Issue
Block a user