mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 19:52:23 +02:00
155 lines
5.5 KiB
Python
155 lines
5.5 KiB
Python
"""Per-session WS event sequencing, ring buffer, and terminal-event persistence for resilient reconnects."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections import deque
|
|
from contextlib import asynccontextmanager
|
|
from typing import AsyncIterator, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 500 events covers a 30s drop even at ~20Hz thinking deltas (~50KB/session).
|
|
BUFFER_LIMIT = 500
|
|
|
|
TERMINAL_STATUSES = {"completed", "stopped", "error"}
|
|
|
|
|
|
class p_SessionSeqLog:
|
|
"""Per-session lock + monotonic seq + recent-event ring buffer."""
|
|
|
|
__slots__ = ("lock", "seq", "buffer")
|
|
|
|
def __init__(self) -> None:
|
|
self.lock: asyncio.Lock = asyncio.Lock()
|
|
self.seq: int = 0
|
|
# (seq, json_payload_str): pre-serialized so replays don't redo json.dumps per reconnect.
|
|
self.buffer: deque[tuple[int, str]] = deque(maxlen=BUFFER_LIMIT)
|
|
|
|
|
|
class SeqLogStore:
|
|
"""Process-wide store. Per-session locks live inside `p_SessionSeqLog`."""
|
|
|
|
def __init__(self, persist_dir: Optional[str] = None) -> None:
|
|
self.per_session: dict[str, p_SessionSeqLog] = {}
|
|
# Coarse lock guards only the setdefault path; never crosses an await.
|
|
self.p_dict_lock = asyncio.Lock()
|
|
self.p_persist_dir = persist_dir
|
|
if persist_dir:
|
|
try:
|
|
os.makedirs(persist_dir, exist_ok=True)
|
|
except Exception:
|
|
logger.warning("seq_log: failed to create persist dir %s", persist_dir)
|
|
|
|
async def p_get_or_create(self, session_id: str) -> p_SessionSeqLog:
|
|
log = self.per_session.get(session_id)
|
|
if log is not None:
|
|
return log
|
|
async with self.p_dict_lock:
|
|
log = self.per_session.get(session_id)
|
|
if log is None:
|
|
log = p_SessionSeqLog()
|
|
self.per_session[session_id] = log
|
|
return log
|
|
|
|
def p_peek(self, session_id: str) -> Optional[p_SessionSeqLog]:
|
|
return self.per_session.get(session_id)
|
|
|
|
@asynccontextmanager
|
|
async def stamp(
|
|
self, session_id: str, event: str, data: dict
|
|
) -> AsyncIterator[tuple[int, str]]:
|
|
"""Atomically assign seq, buffer, and yield (seq, payload); caller's send must happen inside the with-block."""
|
|
log = await self.p_get_or_create(session_id)
|
|
async with log.lock:
|
|
log.seq += 1
|
|
seq = log.seq
|
|
payload = {
|
|
"event": event,
|
|
"session_id": session_id,
|
|
"data": data,
|
|
"seq": seq,
|
|
}
|
|
payload_str = json.dumps(payload)
|
|
log.buffer.append((seq, payload_str))
|
|
yield seq, payload_str
|
|
|
|
def replay(
|
|
self, session_id: str, last_seq: int
|
|
) -> tuple[Optional[int], Optional[int], list[str]]:
|
|
"""Return (oldest_buffered_seq, newest_buffered_seq, events)."""
|
|
log = self.p_peek(session_id)
|
|
if log is None:
|
|
return (None, None, [])
|
|
# asyncio is single-threaded; deque list() is safe vs concurrent append/eviction. No lock needed for read.
|
|
snapshot = list(log.buffer)
|
|
if not snapshot:
|
|
return (None, log.seq, [])
|
|
oldest = snapshot[0][0]
|
|
newest = snapshot[-1][0]
|
|
events = [s for (i, s) in snapshot if i > last_seq]
|
|
return (oldest, newest, events)
|
|
|
|
def current_seq(self, session_id: str) -> int:
|
|
"""Last assigned seq, or 0 if no log exists for the session."""
|
|
log = self.p_peek(session_id)
|
|
return log.seq if log else 0
|
|
|
|
def p_terminal_path(self, session_id: str) -> Optional[str]:
|
|
if not self.p_persist_dir:
|
|
return None
|
|
# Session ids are uuid4 hex; sanitize anyway against path traversal.
|
|
safe = "".join(c for c in session_id if c.isalnum() or c in ("-", "_"))
|
|
if not safe:
|
|
return None
|
|
return os.path.join(self.p_persist_dir, f"{safe}.json")
|
|
|
|
def persist_terminal(self, session_id: str, payload_str: str) -> None:
|
|
"""Atomic write of a terminal event for post-restart clients; best-effort, never blocks broadcast."""
|
|
path = self.p_terminal_path(session_id)
|
|
if not path:
|
|
return
|
|
try:
|
|
tmp = path + ".tmp"
|
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
f.write(payload_str)
|
|
os.replace(tmp, path)
|
|
except Exception:
|
|
logger.debug(
|
|
"seq_log: failed to persist terminal event for %s", session_id, exc_info=True
|
|
)
|
|
|
|
def load_terminal(self, session_id: str) -> Optional[str]:
|
|
path = self.p_terminal_path(session_id)
|
|
if not path or not os.path.exists(path):
|
|
return None
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return f.read()
|
|
except Exception:
|
|
return None
|
|
|
|
def clear(self, session_id: str) -> None:
|
|
"""Drop in-memory log and persisted terminal; for full deletion only, closed-but-retained sessions keep it."""
|
|
self.per_session.pop(session_id, None)
|
|
path = self.p_terminal_path(session_id)
|
|
if path and os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def p_default_persist_dir() -> Optional[str]:
|
|
try:
|
|
from backend.config.paths import DATA_ROOT
|
|
return os.path.join(DATA_ROOT, "agents", "terminal_events")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
seq_log = SeqLogStore(persist_dir=p_default_persist_dir())
|