98 lines
3.0 KiB
Python
98 lines
3.0 KiB
Python
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Mapping, Protocol
|
|
|
|
from .contract import OpenClawResult, utc_now
|
|
|
|
|
|
class EventPublisher(Protocol):
|
|
def publish(self, event: Mapping[str, Any]) -> None: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExecutionEvent:
|
|
event_type: str
|
|
tenant_id: str
|
|
task_id: str
|
|
trace_id: str
|
|
command_id: str
|
|
state: str
|
|
created_at: str = field(default_factory=utc_now)
|
|
details: Mapping[str, Any] = field(default_factory=dict)
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"event_type": self.event_type,
|
|
"tenant_id": self.tenant_id,
|
|
"task_id": self.task_id,
|
|
"trace_id": self.trace_id,
|
|
"command_id": self.command_id,
|
|
"state": self.state,
|
|
"created_at": self.created_at,
|
|
"details": json.loads(
|
|
json.dumps(dict(self.details), ensure_ascii=False, default=str)
|
|
),
|
|
}
|
|
|
|
|
|
class InMemoryEventPublisher:
|
|
"""Thread-safe publisher used by tests and first integration slice."""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
self.events: list[dict[str, Any]] = []
|
|
|
|
def publish(self, event: Mapping[str, Any]) -> None:
|
|
with self._lock:
|
|
self.events.append(
|
|
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
|
|
)
|
|
|
|
|
|
class AuditCallbackSink:
|
|
"""Convert runtime callbacks into tenant-scoped audit events."""
|
|
|
|
def __init__(self, publisher: EventPublisher) -> None:
|
|
self.publisher = publisher
|
|
|
|
@staticmethod
|
|
def _event(
|
|
event_type: str,
|
|
result: OpenClawResult,
|
|
) -> ExecutionEvent:
|
|
details: dict[str, Any] = {
|
|
"runtime_id": result.runtime_id,
|
|
"attempts": result.attempts,
|
|
}
|
|
if result.error:
|
|
details["error"] = dict(result.error)
|
|
if result.output:
|
|
details["output_present"] = True
|
|
return ExecutionEvent(
|
|
event_type=event_type,
|
|
tenant_id=result.tenant_id,
|
|
task_id=result.task_id,
|
|
trace_id=result.trace_id,
|
|
command_id=result.command_id,
|
|
state=result.state.value,
|
|
details=details,
|
|
)
|
|
|
|
def on_started(self, result: OpenClawResult) -> None:
|
|
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
|
|
|
|
def on_completed(self, result: OpenClawResult) -> None:
|
|
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
|
|
|
|
def on_failed(self, result: OpenClawResult) -> None:
|
|
event_type = {
|
|
"CANCELLED": "OPENCLAW_CANCELLED",
|
|
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
|
|
"REJECTED": "OPENCLAW_REJECTED",
|
|
}.get(result.state.value, "OPENCLAW_FAILED")
|
|
self.publisher.publish(self._event(event_type, result).as_dict())
|