"""Persistent channel runtime state.""" from __future__ import annotations import json import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from threading import Lock from typing import Any from uuid import uuid4 def _now_ms() -> int: return int(time.time() * 1000) def _iso_now() -> str: return datetime.now(timezone.utc).isoformat() @dataclass(slots=True) class DedupeWriteResult: created: bool record: dict[str, Any] | None = None class ChannelDedupeStore: def __init__(self, path: Path, *, retention_hours: int = 48) -> None: self.path = path self.retention_ms = max(1, int(retention_hours)) * 60 * 60 * 1000 self._lock = Lock() def get(self, dedupe_key: str) -> dict[str, Any] | None: with self._lock: data = self._load() self._prune_unlocked(data, _now_ms()) record = data["records"].get(dedupe_key) self._save(data) return record def mark_processing(self, *, dedupe_key: str, session_id: str, message_id: str) -> DedupeWriteResult: with self._lock: data = self._load() now_ms = _now_ms() self._prune_unlocked(data, now_ms) existing = data["records"].get(dedupe_key) if existing is not None: self._save(data) return DedupeWriteResult(created=False, record=existing) record = { "dedupe_key": dedupe_key, "status": "processing", "session_id": session_id, "message_id": message_id, "run_id": None, "reply": None, "error": None, "created_at_ms": now_ms, "updated_at_ms": now_ms, } data["records"][dedupe_key] = record self._save(data) return DedupeWriteResult(created=True, record=record) def mark_done( self, *, dedupe_key: str, run_id: str | None, reply: str, max_reply_chars: int, ) -> None: self._mark_result( dedupe_key=dedupe_key, status="done", run_id=run_id, reply=reply[: max(0, int(max_reply_chars))], error=None, ) def mark_error(self, *, dedupe_key: str, error: str, max_error_chars: int) -> None: self._mark_result( dedupe_key=dedupe_key, status="error", run_id=None, reply=None, error=error[: max(0, int(max_error_chars))], ) def _mark_result( self, *, dedupe_key: str, status: str, run_id: str | None, reply: str | None, error: str | None, ) -> None: with self._lock: data = self._load() record = data["records"].get(dedupe_key) if record is None: record = {"dedupe_key": dedupe_key, "created_at_ms": _now_ms()} data["records"][dedupe_key] = record record.update( { "status": status, "run_id": run_id, "reply": reply, "error": error, "updated_at_ms": _now_ms(), } ) self._save(data) def _load(self) -> dict[str, Any]: if not self.path.exists(): return {"records": {}} try: data = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {"records": {}} if not isinstance(data, dict) or not isinstance(data.get("records"), dict): return {"records": {}} return data def _save(self, data: dict[str, Any]) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) tmp_path = self.path.with_name(f"{self.path.name}.tmp") tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") tmp_path.replace(self.path) def _prune_unlocked(self, data: dict[str, Any], now_ms: int) -> None: records = data.get("records", {}) expired_before = now_ms - self.retention_ms for key, record in list(records.items()): updated_at_ms = int(record.get("updated_at_ms") or record.get("created_at_ms") or 0) if updated_at_ms < expired_before: records.pop(key, None) class ChannelEventLog: def __init__(self, path: Path) -> None: self.path = path self._lock = Lock() def record( self, *, channel_id: str, kind: str, session_id: str | None = None, message_id: str | None = None, run_id: str | None = None, status: str = "ok", error: str | None = None, text: str | None = None, metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: entry = { "event_id": uuid4().hex, "channel_id": channel_id, "kind": kind, "session_id": session_id, "message_id": message_id, "run_id": run_id, "status": status, "error": error, "text_preview": (text or "")[:120] if text else None, "text_length": len(text) if text else 0, "metadata": metadata or {}, "created_at": _iso_now(), } with self._lock: self.path.parent.mkdir(parents=True, exist_ok=True) with self.path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(entry, ensure_ascii=False) + "\n") return entry def recent(self, *, channel_id: str | None = None, limit: int = 100) -> list[dict[str, Any]]: if not self.path.exists(): return [] lines = self.path.read_text(encoding="utf-8").splitlines() items: list[dict[str, Any]] = [] for line in reversed(lines): try: item = json.loads(line) except json.JSONDecodeError: continue if channel_id and item.get("channel_id") != channel_id: continue items.append(item) if len(items) >= max(1, int(limit)): break return list(reversed(items))