"""Durable state for gateway-hosted Bot Mode rooms. This module owns only hosted-room identity and its append-only event log. It does not deliver events, lease relay work, or run agent turns; those belong to the relay and the hosted-room driver, so the room log composes with a durable relay without creating a second transport queue. The caller supplies the database path so tests and alternate gateway layouts can isolate state. Production handlers use the gateway's root ``state.db``. """ from __future__ import annotations import hashlib import json import re import sqlite3 import time from pathlib import Path from typing import Any, Mapping, NoReturn from gateway.hosted_rooms_common import ( canonical_json, identifier, non_negative_int, open_sqlite, positive_int, table_columns, table_exists, transaction, ) PROTOCOL_VERSION = 2 MAX_ROOM_ID_CHARS = 128 MAX_EVENT_ID_CHARS = 128 MAX_ROOM_NAME_CHARS = 200 MAX_EVENT_KIND_CHARS = 64 MAX_ACTOR_ID_CHARS = 128 MAX_ACTOR_LABEL_CHARS = 200 MAX_MEMBERS = 128 MAX_MEMBERS_JSON_BYTES = 128 * 1024 MAX_EVENT_JSON_BYTES = 256 * 1024 MAX_LOG_LIMIT = 500 MAX_LOG_PAGE_BYTES = 2 * 1024 * 1024 MAX_ROOM_LIST_LIMIT = 500 MAX_ACTIVE_ROOMS = 256 MAX_DISBANDED_ROOM_TOMBSTONES = 512 DISBANDED_ROOM_RETENTION_SECONDS = 90 * 24 * 60 * 60 MAX_EVENTS_PER_ROOM = 50_000 MAX_ROOM_EVENT_BYTES = 256 * 1024 * 1024 # Leave substantial headroom below the pre-update state.db snapshot ceiling. # Event accounting does not include SQLite indexes or repeated room ids, so the # logical budget must stay well below the physical-file limit. MAX_GATEWAY_EVENT_BYTES = 16 * 1024 * 1024 CONTROL_EVENT_COUNT_RESERVE = 64 CONTROL_EVENT_BYTE_RESERVE = 1024 * 1024 _JOURNAL_MODE_LOCK_RETRIES = 8 _EVENT_KIND_RE = re.compile(r"^[a-z][a-z0-9_.-]*$") _CONTROL_EVENT_KINDS = frozenset({ "authority.claimed", "authority.lost", "room.disbanded", "room.stop_requested", }) _EVENT_KINDS_BY_ACTOR = { "user": frozenset({"message.user"}), "member": frozenset({"message.member"}), "gateway": frozenset({ "member.unavailable", "room.activity", "room.stop_requested", "turn.deferred", "turn.reassigned", "turn.cancelled", "turn.failed", "turn.settled", "turn.started", }), "system": frozenset({ "authority.claimed", "authority.lost", "room.created", "room.disbanded", "room.members_changed", "room.renamed", }), } _ACTOR_FIELDS = frozenset({"kind", "id", "display_name", "profile", "connection_id"}) # --- schema ----------------------------------------------------------------- _REMOTE_RUN_IDENTITY_COLUMNS = ( "room_id", "home_install_id", "authority_gateway_id", "authority_epoch", "member_id", "target_install_id", "target_profile", "task_id", "execution_generation", ) _REMOTE_RUNS_BODY = """ room_id TEXT NOT NULL, home_install_id TEXT NOT NULL, authority_gateway_id TEXT NOT NULL, authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), member_id TEXT NOT NULL, task_id TEXT NOT NULL, execution_generation INTEGER NOT NULL CHECK (execution_generation >= 1), target_install_id TEXT NOT NULL, target_profile TEXT NOT NULL, run_id TEXT NOT NULL, session_id TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY ( room_id, home_install_id, authority_gateway_id, authority_epoch, member_id, target_install_id, target_profile, task_id, execution_generation ) """ # Executed in this exact order on first open / migration. _SCHEMA_DDL = ( """CREATE TABLE IF NOT EXISTS hosted_rooms ( room_id TEXT PRIMARY KEY, name TEXT NOT NULL, members_json TEXT NOT NULL, authority_gateway_id TEXT NOT NULL, authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch >= 1), next_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_seq >= 1), event_bytes INTEGER NOT NULL DEFAULT 0 CHECK (event_bytes >= 0), revision INTEGER NOT NULL DEFAULT 1 CHECK (revision >= 1), created_at REAL NOT NULL, updated_at REAL NOT NULL, disbanded_at REAL )""", """CREATE TABLE IF NOT EXISTS hosted_room_events ( room_id TEXT NOT NULL, seq INTEGER NOT NULL CHECK (seq >= 1), event_id TEXT NOT NULL, kind TEXT NOT NULL, actor_json TEXT NOT NULL, authority_epoch INTEGER CHECK (authority_epoch IS NULL OR authority_epoch >= 1), payload_json TEXT NOT NULL, created_at REAL NOT NULL, PRIMARY KEY (room_id, seq), UNIQUE (room_id, event_id), FOREIGN KEY (room_id) REFERENCES hosted_rooms(room_id) )""", """CREATE TABLE IF NOT EXISTS hosted_room_retired_ids ( room_id TEXT PRIMARY KEY, retired_at REAL NOT NULL )""", """CREATE TABLE IF NOT EXISTS hosted_room_links ( room_id TEXT NOT NULL, member_id TEXT NOT NULL, target_url TEXT NOT NULL, target_profile TEXT NOT NULL, grant TEXT NOT NULL, catalog_json TEXT NOT NULL, cancellation_scope_id TEXT NOT NULL, trace_id TEXT NOT NULL, transport_security TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ready', updated_at REAL NOT NULL, PRIMARY KEY (room_id, member_id) )""", f"CREATE TABLE IF NOT EXISTS hosted_room_remote_runs ({_REMOTE_RUNS_BODY})", """CREATE TABLE IF NOT EXISTS hosted_room_revoked_grants ( scope_key TEXT PRIMARY KEY, expires_at REAL NOT NULL, revoked_before REAL NOT NULL )""", """CREATE TABLE IF NOT EXISTS hosted_room_peer_reservations ( room_id TEXT NOT NULL, member_id TEXT NOT NULL, target_profile TEXT NOT NULL, authority_gateway_id TEXT NOT NULL, authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1), expires_at REAL NOT NULL, revoked_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, PRIMARY KEY (room_id, member_id, target_profile) )""", ) # (table, required columns) in the order _schema_is_current probes them. _REQUIRED_COLUMNS = ( ("hosted_rooms", frozenset({ "room_id", "name", "members_json", "authority_gateway_id", "authority_epoch", "next_seq", "event_bytes", "revision", "created_at", "updated_at", "disbanded_at", })), ("hosted_room_events", frozenset({ "room_id", "seq", "event_id", "kind", "actor_json", "authority_epoch", "payload_json", "created_at", })), ("hosted_room_retired_ids", frozenset({"room_id", "retired_at"})), ("hosted_room_links", frozenset({ "room_id", "member_id", "target_url", "target_profile", "grant", "catalog_json", "cancellation_scope_id", "trace_id", "transport_security", "status", "updated_at", })), ("hosted_room_remote_runs", frozenset( (*_REMOTE_RUN_IDENTITY_COLUMNS, "run_id", "session_id", "created_at", "updated_at") )), ("hosted_room_revoked_grants", frozenset({"scope_key", "expires_at", "revoked_before"})), ("hosted_room_peer_reservations", frozenset({ "room_id", "member_id", "target_profile", "authority_gateway_id", "authority_epoch", "expires_at", "revoked_at", "created_at", "updated_at", })), ) _REMOTE_RUN_SCHEMA_COLUMNS = _REQUIRED_COLUMNS[4][1] # --- SQL fragments (statement text must stay byte-stable after whitespace normalisation) --- _EVENT_COLUMNS = ( "room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at" ) _SELECT_EVENT = f"SELECT {_EVENT_COLUMNS} FROM hosted_room_events WHERE room_id=? AND event_id=?" _INSERT_EVENT = ( f"INSERT INTO hosted_room_events ({_EVENT_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ) _ROOM_COLUMNS = ( "room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, revision," " created_at, updated_at, disbanded_at" ) _ROOM_COLUMNS_WITH_BYTES = ( "room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, event_bytes," " revision, created_at, updated_at, disbanded_at" ) _SELECT_ROOM = f"SELECT {_ROOM_COLUMNS} FROM hosted_rooms WHERE room_id=?" _SELECT_ROOM_WITH_BYTES = f"SELECT {_ROOM_COLUMNS_WITH_BYTES} FROM hosted_rooms WHERE room_id=?" _SUM_EVENT_BYTES = "SELECT COALESCE(SUM(event_bytes), 0) FROM hosted_rooms" _INSERT_RETIRED = ( "INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at) VALUES (?, ?)" ) _RETIRE_FROM_ROOMS = ( "INSERT OR IGNORE INTO hosted_room_retired_ids (room_id, retired_at)" " SELECT room_id, disbanded_at FROM hosted_rooms WHERE {where}" ) _REMOTE_RUN_WHERE = " AND ".join(f"{column}=?" for column in _REMOTE_RUN_IDENTITY_COLUMNS) _LIVE_RESERVATION_WHERE = ( "WHERE room_id=? AND target_profile=? AND expires_at>? AND revoked_at IS NULL" ) _SELECT_LIVE_RESERVATION = ( f"SELECT 1 FROM hosted_room_peer_reservations {_LIVE_RESERVATION_WHERE} LIMIT 1" ) class HostedRoomError(ValueError): """Base class for invalid or conflicting hosted-room operations.""" class RoomNotFoundError(HostedRoomError): """Raised when a room does not exist or has been disbanded.""" class RoomHistoryExpiredError(RoomNotFoundError): """Raised when a retired room remains reserved after history compaction.""" reason = "room_history_expired" class RoomConflictError(HostedRoomError): """Raised when an idempotency key is reused for different room state.""" class RoomProbeUnavailableError(HostedRoomError): """Raised when a non-blocking ownership probe cannot read the room store.""" class EventConflictError(HostedRoomError): """Raised when an event id is reused with different immutable content.""" class AuthorityConflictError(HostedRoomError): """Raised when a stale room authority attempts to mutate hosted state.""" reason = "authority_conflict" class AuthoritySupersededError(AuthorityConflictError): """Raised when a successful authority claim was later superseded.""" # --- validation --------------------------------------------------------------- def _canonical_json(value: Any, *, label: str, max_bytes: int) -> str: return canonical_json( value, error=HostedRoomError, label=label, max_bytes=max_bytes, ensure_ascii=False ) def _validate_identifier(value: Any, *, label: str, max_chars: int) -> str: return identifier(value, label=label, error=HostedRoomError, max_chars=max_chars) def _room_id(value: Any) -> str: return _validate_identifier(value, label="room_id", max_chars=MAX_ROOM_ID_CHARS) def _event_id(value: Any, *, label: str = "event_id") -> str: return _validate_identifier(value, label=label, max_chars=MAX_EVENT_ID_CHARS) def _actor_id(value: Any, label: str) -> str: return _validate_identifier(value, label=label, max_chars=MAX_ACTOR_ID_CHARS) def _require_positive_int(value: Any, label: str) -> int: return positive_int(value, error=HostedRoomError, message=f"{label} must be a positive integer") def _bounded_limit(value: Any, maximum: int) -> int: if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum: raise HostedRoomError(f"limit must be between 1 and {maximum}") return value def _non_negative(value: Any, label: str) -> int: return non_negative_int( value, error=HostedRoomError, message=f"{label} must be a non-negative integer" ) def _now(now: float | None) -> float: return time.time() if now is None else float(now) def _system_actor_json(actor_id: str) -> str: return _canonical_json({"kind": "system", "id": actor_id}, label="actor", max_bytes=4 * 1024) def _payload_json(payload: Any) -> str: return _canonical_json(payload, label="payload", max_bytes=MAX_EVENT_JSON_BYTES) def _claim_payload_json(previous_gateway_id: str, new_gateway_id: str, epoch: int) -> str: return _payload_json({ "previous_gateway_id": previous_gateway_id, "authority_gateway_id": new_gateway_id, "authority_epoch": epoch, }) def user_event_id(client_event_id: Any) -> str: """Map a client retry key into the server-owned user-event namespace.""" normalized = _event_id(client_event_id) return f"user:{hashlib.sha256(normalized.encode('utf-8')).hexdigest()}" def _validate_room_name(value: Any) -> str: if not isinstance(value, str): raise HostedRoomError("name must be a string") value = value.strip() if not value or len(value) > MAX_ROOM_NAME_CHARS: raise HostedRoomError("invalid room name") return value def _validate_members(value: Any) -> tuple[list[dict[str, Any]], str]: if not isinstance(value, list): raise HostedRoomError("members must be a list") if len(value) > MAX_MEMBERS: raise HostedRoomError("too many room members") members: list[dict[str, Any]] = [] for member in value: if not isinstance(member, dict): raise HostedRoomError("each room member must be an object") members.append(dict(member)) return members, _canonical_json(members, label="members", max_bytes=MAX_MEMBERS_JSON_BYTES) def _legacy_members_match(existing_json: str, proposed: list[dict[str, Any]]) -> bool: """Allow adoption to add routing metadata an older room could not store.""" try: existing = json.loads(existing_json) except (TypeError, ValueError): return False if not isinstance(existing, list) or len(existing) != len(proposed): return False for previous, current in zip(existing, proposed, strict=True): if not isinstance(previous, dict): return False previous, current = dict(previous), dict(current) previous_target = previous.pop("target", None) current_target = current.pop("target", None) if previous != current: return False if previous_target not in (None, {}) and previous_target != current_target: return False return True def _validate_event_kind(value: Any) -> str: return identifier( value, label="kind", error=HostedRoomError, max_chars=MAX_EVENT_KIND_CHARS, pattern=_EVENT_KIND_RE, invalid="invalid event kind", ) def _optional_actor_field(actor: dict[str, Any], field: str, max_chars: int) -> str: value = actor.get(field) if value is None: return "" if not isinstance(value, str): raise HostedRoomError(f"actor.{field} must be a string") value = value.strip() if len(value) > max_chars: raise HostedRoomError(f"actor.{field} is too long") return value def _validate_actor(value: Any, *, kind: str) -> tuple[dict[str, str], str]: if not isinstance(value, dict): raise HostedRoomError("actor must be an object") unknown = set(value) - _ACTOR_FIELDS if unknown: raise HostedRoomError(f"unknown actor fields: {', '.join(sorted(unknown))}") actor_kind = value.get("kind") if not isinstance(actor_kind, str) or actor_kind not in _EVENT_KINDS_BY_ACTOR: raise HostedRoomError("invalid actor.kind") if kind not in _EVENT_KINDS_BY_ACTOR[actor_kind]: raise HostedRoomError(f"actor kind '{actor_kind}' cannot append '{kind}'") actor = {"kind": actor_kind, "id": _actor_id(value.get("id"), "actor.id")} for field, max_chars in ( ("display_name", MAX_ACTOR_LABEL_CHARS), ("profile", MAX_ACTOR_ID_CHARS), ("connection_id", MAX_ACTOR_ID_CHARS), ): field_value = _optional_actor_field(value, field, max_chars) if field_value: actor[field] = field_value return actor, _canonical_json(actor, label="actor", max_bytes=4 * 1024) # --- schema / connections ------------------------------------------------------- def _primary_key_columns(conn: sqlite3.Connection, table: str) -> tuple[str, ...]: rows = [row for row in conn.execute(f"PRAGMA table_info({table})") if row[5]] return tuple(str(row[1]) for row in sorted(rows, key=lambda row: int(row[5]))) def _remote_run_schema_current(conn: sqlite3.Connection, columns: frozenset[str]) -> bool: return ( _REMOTE_RUN_SCHEMA_COLUMNS.issubset(columns) and _primary_key_columns(conn, "hosted_room_remote_runs") == _REMOTE_RUN_IDENTITY_COLUMNS ) def _migrate_remote_run_schema(conn: sqlite3.Connection) -> None: """Fence legacy receipts behind a complete authority-lineage key.""" columns = table_columns(conn, "hosted_room_remote_runs") if _remote_run_schema_current(conn, columns): return conn.execute("DROP TABLE IF EXISTS hosted_room_remote_runs_migrating") conn.execute(f"CREATE TABLE hosted_room_remote_runs_migrating ({_REMOTE_RUNS_BODY})") if columns: home = "home_install_id" if "home_install_id" in columns else "'legacy'" gateway = "authority_gateway_id" if "authority_gateway_id" in columns else "'legacy'" epoch = "authority_epoch" if "authority_epoch" in columns else "1" conn.execute( f"""INSERT OR IGNORE INTO hosted_room_remote_runs_migrating( room_id, home_install_id, authority_gateway_id, authority_epoch, member_id, task_id, execution_generation, target_install_id, target_profile, run_id, session_id, created_at, updated_at ) SELECT room_id, {home}, {gateway}, {epoch}, member_id, task_id, execution_generation, target_install_id, target_profile, run_id, session_id, created_at, updated_at FROM hosted_room_remote_runs""" ) conn.execute("DROP TABLE hosted_room_remote_runs") conn.execute("ALTER TABLE hosted_room_remote_runs_migrating RENAME TO hosted_room_remote_runs") # Draft builds before the actor contract carried no identity. Preserve their # inert replay rows explicitly as legacy system events rather than guessing a # user or Bot author. _LEGACY_ACTOR_JSON = _system_actor_json("legacy").replace("'", "''") # (table, column, ddl) applied in this exact order; each table's PRAGMA is read on first use. _LEGACY_COLUMN_DDL = ( ( "hosted_rooms", "authority_gateway_id", "ALTER TABLE hosted_rooms ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'", ), ( "hosted_rooms", "authority_epoch", "ALTER TABLE hosted_rooms ADD COLUMN authority_epoch INTEGER NOT NULL DEFAULT 1", ), ( "hosted_rooms", "event_bytes", "ALTER TABLE hosted_rooms ADD COLUMN event_bytes INTEGER NOT NULL DEFAULT 0", ), ( "hosted_room_events", "actor_json", "ALTER TABLE hosted_room_events " f"ADD COLUMN actor_json TEXT NOT NULL DEFAULT '{_LEGACY_ACTOR_JSON}'", ), ( "hosted_room_events", "authority_epoch", "ALTER TABLE hosted_room_events ADD COLUMN authority_epoch INTEGER", ), ) def _migrate_legacy_columns(conn: sqlite3.Connection) -> None: """Add columns draft schemas lacked; backfill event_bytes when first introduced.""" columns: dict[str, frozenset[str]] = {} for table, column, ddl in _LEGACY_COLUMN_DDL: if table not in columns: columns[table] = table_columns(conn, table) if column not in columns[table]: conn.execute(ddl) if "event_bytes" not in columns["hosted_rooms"]: conn.execute( """UPDATE hosted_rooms SET event_bytes=COALESCE(( SELECT SUM( length(CAST(event_id AS BLOB)) + length(CAST(kind AS BLOB)) + length(CAST(actor_json AS BLOB)) + length(CAST(payload_json AS BLOB)) ) FROM hosted_room_events WHERE hosted_room_events.room_id=hosted_rooms.room_id ), 0)""" ) def _initialize_schema(conn: sqlite3.Connection) -> None: for statement in _SCHEMA_DDL: conn.execute(statement) _migrate_legacy_columns(conn) # Old schemas kept the final identity tombstone in hosted_rooms itself. # Copy those identities before bounded history pruning can remove their # heavier room/event payloads. This compact registry is intentionally # permanent: a stale coordinate must never name a different Group Chat. conn.execute(_RETIRE_FROM_ROOMS.format(where="disbanded_at IS NOT NULL")) _migrate_remote_run_schema(conn) conn.execute( "CREATE INDEX IF NOT EXISTS idx_hosted_room_events_cursor " "ON hosted_room_events(room_id, seq)" ) if not _schema_is_current(conn): raise HostedRoomError("hosted room schema migration did not complete") def _schema_is_current(conn: sqlite3.Connection) -> bool: # Read every table first (fixed PRAGMA order), then compare. actual = [table_columns(conn, table) for table, _ in _REQUIRED_COLUMNS] for (table, required), columns in zip(_REQUIRED_COLUMNS, actual, strict=True): if not required.issubset(columns): return False if table == "hosted_room_remote_runs" and not _remote_run_schema_current(conn, columns): return False index = conn.execute( "SELECT 1 FROM sqlite_master WHERE type='index' AND name='idx_hosted_room_events_cursor'" ).fetchone() return index is not None def default_db_path() -> Path: """Return the gateway-wide state database for the active install.""" from hermes_constants import get_hermes_home home = get_hermes_home() root = home.parent.parent if home.parent.name == "profiles" else home return root / "state.db" def local_authority_gateway_id() -> str: """Return the stable server-owned identity for hosted-room authority.""" from hermes_cli.install_identity import get_install_id install_id = get_install_id() if not install_id: raise HostedRoomError("stable gateway install identity is unavailable") return _actor_id(f"install:{install_id}", "authority_gateway_id") def _connect(db_path: Path | str) -> sqlite3.Connection: from hermes_state import apply_wal_with_fallback path = Path(db_path) path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=10) conn.row_factory = sqlite3.Row try: for attempt in range(_JOURNAL_MODE_LOCK_RETRIES): try: apply_wal_with_fallback(conn, db_label="state.db (hosted_rooms)") break except sqlite3.OperationalError as exc: last = attempt + 1 == _JOURNAL_MODE_LOCK_RETRIES if str(exc).lower() != "database is locked" or last: raise # SQLite's journal-mode pragma may not honor the connection's # busy timeout while another first opener initializes the DB, # especially on Windows. Retry only that transient lock class. time.sleep(0.01 * (2**attempt)) conn.execute("PRAGMA foreign_keys=ON") if _schema_is_current(conn): return conn # Multiple profile gateways share this root database. Serialize every # draft-schema transition in SQLite itself so a crash rolls back the # whole DDL/data migration and another process can safely retry it. conn.execute("BEGIN IMMEDIATE") _initialize_schema(conn) conn.commit() except Exception: conn.rollback() conn.close() raise return conn def _read_connection(db_path: Path | str) -> sqlite3.Connection: """Open the room store without steady-state journal or migration writes.""" path = Path(db_path) if not path.is_file(): _connect(path).close() conn = open_sqlite(path) if _schema_is_current(conn): return conn conn.close() _connect(path).close() return open_sqlite(path) def _transaction(db_path: Path | str, *, immediate: bool = False): return transaction(_connect, db_path, immediate=immediate) # --- row helpers ------------------------------------------------------------------ def _raise_room_not_found(conn: sqlite3.Connection, room_id: str) -> NoReturn: retained = conn.execute("SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,)).fetchone() if retained is not None: # A retained disband tombstone still has replayable history. The # caller simply did not opt into reading disbanded rooms. raise RoomNotFoundError("hosted room not found") retired = conn.execute( "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,) ).fetchone() if retired is not None: raise RoomHistoryExpiredError( "Group Chat history expired; room_id remains permanently retired" ) raise RoomNotFoundError("hosted room not found") def _reload(conn: sqlite3.Connection, sql: str, params: tuple, missing: str) -> sqlite3.Row: """Re-read a row this transaction just wrote; a miss is an invariant violation.""" row = conn.execute(sql, params).fetchone() if row is None: # pragma: no cover - guarded by the write above raise RuntimeError(missing) return row def _room_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: room = { "room_id": row["room_id"], "name": row["name"], "members": json.loads(row["members_json"]), "authority_gateway_id": row["authority_gateway_id"], "authority_epoch": int(row["authority_epoch"]), "revision": int(row["revision"]), "created_at": float(row["created_at"]), "updated_at": float(row["updated_at"]), "idempotent": idempotent, } # sqlite3.Row: ``x in row`` scans values, so ``.keys()`` is load-bearing. if "disbanded_at" in row.keys() and row["disbanded_at"] is not None: # noqa: SIM118 room["disbanded_at"] = float(row["disbanded_at"]) if "next_seq" in row.keys(): # noqa: SIM118 room["latest_seq"] = int(row["next_seq"]) - 1 return room def _event_from_row(row: sqlite3.Row, *, idempotent: bool = False) -> dict[str, Any]: epoch = row["authority_epoch"] return { "room_id": row["room_id"], "seq": int(row["seq"]), "event_id": row["event_id"], "kind": row["kind"], "actor": json.loads(row["actor_json"]), "authority_epoch": int(epoch) if epoch is not None else None, "payload": json.loads(row["payload_json"]), "created_at": float(row["created_at"]), "idempotent": idempotent, } def _load_event(conn: sqlite3.Connection, room_id: str, event_id: str) -> sqlite3.Row | None: return conn.execute(_SELECT_EVENT, (room_id, event_id)).fetchone() def _gateway_event_bytes(conn: sqlite3.Connection) -> int: return int(conn.execute(_SUM_EVENT_BYTES).fetchone()[0]) def _prepare_event( conn: sqlite3.Connection, room: sqlite3.Row, event_id: str, kind: str, actor_json: str, payload_json: str, *, allow_control: bool = False, ) -> int: """Size one pending event and enforce per-room and gateway capacity; returns its bytes.""" additional_bytes = len((event_id + kind + actor_json + payload_json).encode("utf-8")) count_reserve = CONTROL_EVENT_COUNT_RESERVE if allow_control else 0 byte_reserve = CONTROL_EVENT_BYTE_RESERVE if allow_control else 0 gateway_byte_limit = MAX_GATEWAY_EVENT_BYTES + byte_reserve if int(room["next_seq"]) - 1 >= MAX_EVENTS_PER_ROOM + count_reserve: raise HostedRoomError( "This Group Chat reached its history limit. Start a new Group Chat to continue." ) if int(room["event_bytes"]) + additional_bytes > MAX_ROOM_EVENT_BYTES + byte_reserve: raise HostedRoomError( "This Group Chat reached its storage limit. Start a new Group Chat to continue." ) gateway_bytes = _gateway_event_bytes(conn) if gateway_bytes + additional_bytes > gateway_byte_limit: _prune_disbanded_rooms_locked( conn, now=None, max_gateway_event_bytes=max(0, gateway_byte_limit - additional_bytes) ) gateway_bytes = _gateway_event_bytes(conn) if gateway_bytes + additional_bytes > gateway_byte_limit: raise HostedRoomError( "Group Chat storage is full on this host. Delete an old Group Chat and try again." ) return additional_bytes # --- retention ------------------------------------------------------------------- # Deleted in this order when a disbanded room's payload is purged. _DEPENDENT_TABLES = ( "hosted_room_policy_transcript_state", "hosted_room_policy_transcript", "hosted_room_policy_publications", "hosted_room_policy_watermarks", "hosted_room_policy_events", "hosted_room_policy_threads", "hosted_room_policy_cursors", "hosted_room_driver_tasks", "hosted_room_driver_leases", "hosted_room_remote_runs", "hosted_room_links", "hosted_room_peer_reservations", "hosted_room_events", ) def _prune_disbanded_rooms_locked( conn: sqlite3.Connection, *, now: float | None, max_gateway_event_bytes: int | None = None ) -> int: candidates: set[str] = set() if now is not None: candidates.update( str(row["room_id"]) for row in conn.execute( """SELECT room_id FROM hosted_rooms WHERE disbanded_at IS NOT NULL AND disbanded_at<=?""", (now - DISBANDED_ROOM_RETENTION_SECONDS,), ).fetchall() ) candidates.update( str(row["room_id"]) for row in conn.execute( """SELECT room_id FROM hosted_rooms WHERE disbanded_at IS NOT NULL ORDER BY disbanded_at DESC, room_id ASC LIMIT -1 OFFSET ?""", (MAX_DISBANDED_ROOM_TOMBSTONES,), ).fetchall() ) if max_gateway_event_bytes is not None: retained_bytes = _gateway_event_bytes(conn) if retained_bytes > max_gateway_event_bytes: for row in conn.execute( """SELECT room_id, event_bytes FROM hosted_rooms WHERE disbanded_at IS NOT NULL ORDER BY disbanded_at ASC, room_id ASC""" ).fetchall(): candidates.add(str(row["room_id"])) retained_bytes -= int(row["event_bytes"]) if retained_bytes <= max_gateway_event_bytes: break if not candidates: return 0 placeholders = ",".join("?" for _ in candidates) room_ids = tuple(sorted(candidates)) conn.execute( _RETIRE_FROM_ROOMS.format( where=f"room_id IN ({placeholders}) AND disbanded_at IS NOT NULL" ), room_ids, ) for table in _DEPENDENT_TABLES: if table_exists(conn, table): conn.execute(f"DELETE FROM {table} WHERE room_id IN ({placeholders})", room_ids) conn.execute(f"DELETE FROM hosted_rooms WHERE room_id IN ({placeholders})", room_ids) return len(room_ids) def prune_disbanded_rooms(db_path: Path | str, *, now: float | None = None) -> int: """Purge deleted Group Chat payloads while reserving their identities.""" timestamp = _now(now) with _transaction(db_path, immediate=True) as conn: return _prune_disbanded_rooms_locked(conn, now=timestamp) # --- room links / grants / reservations / remote runs --------------------------------- def list_room_link_records(db_path: Path | str) -> list[dict[str, Any]]: """Return private RoomLink records without logging or formatting grants.""" with _transaction(db_path) as conn: rows = conn.execute( """SELECT room_id, member_id, target_url, target_profile, grant, catalog_json, cancellation_scope_id, trace_id, transport_security, status, updated_at FROM hosted_room_links ORDER BY room_id, member_id""" ).fetchall() return [dict(row) for row in rows] def upsert_room_link_record( db_path: Path | str, *, record: Mapping[str, Any], max_links: int ) -> None: """Atomically insert or replace one private RoomLink record.""" with _transaction(db_path, immediate=True) as conn: existing = conn.execute( "SELECT 1 FROM hosted_room_links WHERE room_id=? AND member_id=?", (record["room_id"], record["member_id"]), ).fetchone() if existing is None: count = int(conn.execute("SELECT COUNT(*) FROM hosted_room_links").fetchone()[0]) if count >= max_links: raise HostedRoomError("too many stored room links") conn.execute( """INSERT INTO hosted_room_links( room_id, member_id, target_url, target_profile, grant, catalog_json, cancellation_scope_id, trace_id, transport_security, status, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(room_id, member_id) DO UPDATE SET target_url=excluded.target_url, target_profile=excluded.target_profile, grant=excluded.grant, catalog_json=excluded.catalog_json, cancellation_scope_id=excluded.cancellation_scope_id, trace_id=excluded.trace_id, transport_security=excluded.transport_security, status=excluded.status, updated_at=excluded.updated_at""", ( record["room_id"], record["member_id"], record["target_url"], record["target_profile"], record["grant"], record["catalog_json"], record["cancellation_scope_id"], record["trace_id"], record["transport_security"], record["status"], record["updated_at"], ), ) def update_room_link_status( db_path: Path | str, *, room_id: str, member_id: str, status: str, now: float | None = None ) -> bool: """Persist a non-secret route health classification.""" with _transaction(db_path, immediate=True) as conn: cursor = conn.execute( "UPDATE hosted_room_links SET status=?, updated_at=? WHERE room_id=? AND member_id=?", (status, _now(now), room_id, member_id), ) return cursor.rowcount == 1 def delete_room_link_records(db_path: Path | str, *, room_id: str) -> int: """Delete persisted peer routes after their target grants are revoked.""" with _transaction(db_path, immediate=True) as conn: return conn.execute("DELETE FROM hosted_room_links WHERE room_id=?", (room_id,)).rowcount def _room_grant_scope_key(claims: Mapping[str, Any]) -> str: """Return a stable non-secret key for one room/home/target/profile scope.""" fields = { key: str(claims.get(key) or "") for key in ( "room_id", "home_install_id", "authority_gateway_id", "authority_epoch", "member_id", "target_install_id", "target_profile", ) } if not all(fields.values()): raise HostedRoomError("room grant scope is incomplete") return hashlib.sha256( json.dumps(fields, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() def revoke_room_grant_scope( db_path: Path | str, *, claims: Mapping[str, Any], expires_at: float, now: float | None = None ) -> None: """Revoke every grant issued at or before now for one exact room scope.""" scope_key = _room_grant_scope_key(claims) timestamp = _now(now) expiry = float(expires_at) if expiry <= timestamp: return with _transaction(db_path, immediate=True) as conn: conn.execute("DELETE FROM hosted_room_revoked_grants WHERE expires_at<=?", (timestamp,)) conn.execute( """INSERT INTO hosted_room_revoked_grants( scope_key, expires_at, revoked_before ) VALUES (?, ?, ?) ON CONFLICT(scope_key) DO UPDATE SET expires_at=MAX(hosted_room_revoked_grants.expires_at, excluded.expires_at), revoked_before=MAX(hosted_room_revoked_grants.revoked_before, excluded.revoked_before)""", (scope_key, expiry, timestamp), ) conn.execute( """UPDATE hosted_room_peer_reservations SET revoked_at=?, updated_at=? WHERE room_id=? AND member_id=? AND target_profile=? AND authority_gateway_id=? AND authority_epoch=?""", ( timestamp, timestamp, str(claims.get("room_id") or ""), str(claims.get("member_id") or ""), str(claims.get("target_profile") or ""), str(claims.get("authority_gateway_id") or ""), int(claims.get("authority_epoch") or 0), ), ) def _reservation_claims(claims: Mapping[str, Any]) -> tuple[str, str, str, str, int]: """Validate (room_id, member_id, target_profile, authority_gateway_id, authority_epoch).""" values = ( _room_id(claims.get("room_id")), _actor_id(claims.get("member_id"), "member_id"), _actor_id(claims.get("target_profile"), "target_profile"), _actor_id(claims.get("authority_gateway_id"), "authority_gateway_id"), int(claims.get("authority_epoch") or 0), ) if values[4] < 1: raise HostedRoomError("authority_epoch must be positive") return values def _reservation_superseded(row: sqlite3.Row, gateway_id: str, epoch: int) -> bool: """A newer epoch, or the same epoch under another gateway, outranks this claim.""" row_epoch = int(row["authority_epoch"]) return row_epoch > epoch or ( row_epoch == epoch and str(row["authority_gateway_id"]) != gateway_id ) def reserve_peer_room( db_path: Path | str, *, claims: Mapping[str, Any], expires_at: float, now: float | None = None ) -> None: """Fence direct Desktop prompts before the first peer run is admitted.""" timestamp = _now(now) expiry = float(expires_at) if expiry <= timestamp: raise HostedRoomError("peer room reservation must expire in the future") values = _reservation_claims(claims) room_id, _member_id, target_profile, gateway_id, epoch = values with _transaction(db_path, immediate=True) as conn: conn.execute("DELETE FROM hosted_room_peer_reservations WHERE expires_at<=?", (timestamp,)) authority_rows = conn.execute( f"""SELECT authority_gateway_id, authority_epoch FROM hosted_room_peer_reservations {_LIVE_RESERVATION_WHERE}""", (room_id, target_profile, timestamp), ).fetchall() if any(_reservation_superseded(row, gateway_id, epoch) for row in authority_rows): raise AuthorityConflictError("peer room reservation authority changed") conn.execute( """UPDATE hosted_room_peer_reservations SET revoked_at=?, updated_at=? WHERE room_id=? AND target_profile=? AND authority_epoch sqlite3.Row | None: with _transaction(db_path) as conn: return conn.execute(sql, params).fetchone() def peer_room_is_reserved( db_path: Path | str, *, room_id: str, target_profile: str, now: float | None = None ) -> bool: """Return whether a live target-side RoomLink reservation fences Desktop.""" timestamp = _now(now) params = (_room_id(room_id), _actor_id(target_profile, "target_profile"), timestamp) return _read_one(db_path, _SELECT_LIVE_RESERVATION, params) is not None def peer_room_grant_is_current( db_path: Path | str, *, claims: Mapping[str, Any], now: float | None = None ) -> bool: """Require a grant to match the target's current live reservation.""" timestamp = _now(now) values = _reservation_claims(claims) row = _read_one( db_path, """SELECT 1 FROM hosted_room_peer_reservations WHERE room_id=? AND member_id=? AND target_profile=? AND authority_gateway_id=? AND authority_epoch=? AND expires_at>? AND revoked_at IS NULL LIMIT 1""", (*values, timestamp), ) return row is not None def room_grant_is_revoked( db_path: Path | str, *, claims: Mapping[str, Any], now: float | None = None ) -> bool: """Return whether a grant predates its exact scope's revocation fence.""" timestamp = _now(now) scope_key = _room_grant_scope_key(claims) issued_at = float(claims.get("issued_at") or 0) row = _read_one( db_path, """SELECT revoked_before FROM hosted_room_revoked_grants WHERE scope_key=? AND expires_at>?""", (scope_key, timestamp), ) return row is not None and issued_at <= float(row["revoked_before"]) def _remote_run_identity(record: Mapping[str, Any]) -> tuple[Any, ...]: return tuple(record[column] for column in _REMOTE_RUN_IDENTITY_COLUMNS) def upsert_remote_run_receipt( db_path: Path | str, *, record: Mapping[str, Any], now: float | None = None ) -> None: """Durably bind one logical peer task attempt to its remote run handle.""" timestamp = _now(now) identity = _remote_run_identity(record) with _transaction(db_path, immediate=True) as conn: existing = conn.execute( f"SELECT * FROM hosted_room_remote_runs WHERE {_REMOTE_RUN_WHERE}", identity ).fetchone() immutable = (*identity, record["run_id"], record["session_id"]) if existing is not None: stored = (*_remote_run_identity(existing), existing["run_id"], existing["session_id"]) if stored != immutable: raise HostedRoomError("remote run receipt conflicts with its logical task") conn.execute( f"UPDATE hosted_room_remote_runs SET updated_at=? WHERE {_REMOTE_RUN_WHERE}", (timestamp, *identity), ) return conn.execute( """INSERT INTO hosted_room_remote_runs( room_id, home_install_id, authority_gateway_id, authority_epoch, member_id, target_install_id, target_profile, task_id, execution_generation, run_id, session_id, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (*immutable, timestamp, timestamp), ) def list_remote_run_receipts( db_path: Path | str, *, room_id: str | None = None, target_profile: str | None = None, session_id: str | None = None, ) -> list[dict[str, Any]]: """Return remote run handles in durable task order.""" filters = [ (column, value) for column, value in ( ("room_id", room_id), ("target_profile", target_profile), ("session_id", session_id) ) if value is not None ] where = f" WHERE {' AND '.join(f'{column}=?' for column, _ in filters)}" if filters else "" with _transaction(db_path) as conn: rows = conn.execute( "SELECT * FROM hosted_room_remote_runs" + where + " ORDER BY created_at, task_id, execution_generation", [value for _, value in filters], ).fetchall() return [dict(row) for row in rows] def remote_run_receipt(db_path: Path | str, *, record: Mapping[str, Any]) -> dict[str, Any] | None: """Return the exact durable remote run handle for one task attempt.""" row = _read_one( db_path, f"SELECT * FROM hosted_room_remote_runs WHERE {_REMOTE_RUN_WHERE}", _remote_run_identity(record), ) return dict(row) if row is not None else None # --- rooms and events ------------------------------------------------------------- def _adopt_legacy_room( conn: sqlite3.Connection, existing: sqlite3.Row, *, room_id: str, members_json: str, authority_gateway_id: str, now: float, ) -> dict[str, Any]: """Claim a 'legacy'-authority room for a real gateway with a fenced claim event.""" target_epoch = int(existing["authority_epoch"]) + 1 seq = int(existing["next_seq"]) actor_json = _system_actor_json("authority-control") payload_json = _claim_payload_json("legacy", authority_gateway_id, target_epoch) claim_bytes = _prepare_event( conn, existing, "system:authority-adopted", "authority.claimed", actor_json, payload_json, allow_control=True, ) conn.execute( _INSERT_EVENT, ( room_id, seq, "system:authority-adopted", "authority.claimed", actor_json, target_epoch, payload_json, now, ), ) adopted = conn.execute( """UPDATE hosted_rooms SET members_json=?, authority_gateway_id=?, authority_epoch=?, next_seq=next_seq+1, revision=revision+1, event_bytes=event_bytes+?, updated_at=? WHERE room_id=? AND authority_gateway_id='legacy' AND authority_epoch=? AND next_seq=? AND disbanded_at IS NULL""", ( members_json, authority_gateway_id, target_epoch, claim_bytes, now, room_id, int(existing["authority_epoch"]), seq, ), ) if adopted.rowcount != 1: raise AuthorityConflictError("legacy room adoption lost its fence") existing = _reload(conn, _SELECT_ROOM, (room_id,), "adopted room could not be reloaded") result = _room_from_row(existing, idempotent=True) result["adopted"] = True claim_event = _reload( conn, _SELECT_EVENT, (room_id, "system:authority-adopted"), "legacy adoption event could not be reloaded", ) result["claim_event"] = _event_from_row(claim_event) return result def create_room( db_path: Path | str, *, room_id: Any, name: Any, members: Any, authority_gateway_id: Any, now: float | None = None, ) -> dict[str, Any]: """Create a room, or return the identical existing room idempotently.""" room_id = _room_id(room_id) name = _validate_room_name(name) normalized_members, members_json = _validate_members(members) authority_gateway_id = _actor_id(authority_gateway_id, "authority_gateway_id") now = _now(now) with _transaction(db_path, immediate=True) as conn: if conn.execute( "SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,) ).fetchone(): raise RoomConflictError("room_id belongs to a disbanded room") existing = conn.execute(_SELECT_ROOM_WITH_BYTES, (room_id,)).fetchone() if existing is not None: if existing["disbanded_at"] is not None: raise RoomConflictError("room_id belongs to a disbanded room") legacy_adoption = ( existing["authority_gateway_id"] == "legacy" and authority_gateway_id != "legacy" ) members_match = existing["members_json"] == members_json or ( legacy_adoption and _legacy_members_match(existing["members_json"], normalized_members) ) if existing["name"] != name or not members_match: raise RoomConflictError("room_id already exists with different state") if legacy_adoption: return _adopt_legacy_room( conn, existing, room_id=room_id, members_json=members_json, authority_gateway_id=authority_gateway_id, now=now, ) if existing["authority_gateway_id"] != authority_gateway_id: raise RoomConflictError("room_id already belongs to a different authority") return _room_from_row(existing, idempotent=True) active_rooms = int( conn.execute( "SELECT COUNT(*) FROM hosted_rooms WHERE disbanded_at IS NULL" ).fetchone()[0] ) if active_rooms >= MAX_ACTIVE_ROOMS: raise HostedRoomError( "This host has too many active Group Chats. Delete one and try again." ) conn.execute( f"""INSERT INTO hosted_rooms ({_ROOM_COLUMNS_WITH_BYTES}) VALUES (?, ?, ?, ?, 1, 1, 0, 1, ?, ?, NULL)""", (room_id, name, members_json, authority_gateway_id, now, now), ) row = _reload( conn, """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, revision, created_at, updated_at FROM hosted_rooms WHERE room_id=?""", (room_id,), "created room could not be reloaded", ) result = _room_from_row(row) result["members"] = normalized_members return result def list_rooms( db_path: Path | str, *, include_disbanded: bool = False, limit: int = MAX_ROOM_LIST_LIMIT, offset: int = 0, ) -> list[dict[str, Any]]: """Return one bounded read-only page ordered by most recent change.""" limit = _bounded_limit(limit, MAX_ROOM_LIST_LIMIT) offset = _non_negative(offset, "offset") conn = _read_connection(db_path) try: rows = conn.execute( f"""SELECT {_ROOM_COLUMNS} FROM hosted_rooms WHERE disbanded_at IS NULL OR ? ORDER BY updated_at DESC, room_id ASC LIMIT ? OFFSET ?""", (int(include_disbanded), limit, offset), ).fetchall() finally: conn.close() return [_room_from_row(row) for row in rows] def rename_room( db_path: Path | str, *, room_id: Any, event_id: Any, name: Any, now: float | None = None ) -> dict[str, Any]: """Rename a live room and append its replay event atomically.""" room_id = _room_id(room_id) event_id = _event_id(event_id) name = _validate_room_name(name) now = _now(now) actor_json = _system_actor_json("room-control") payload_json = _payload_json({"name": name}) with _transaction(db_path, immediate=True) as conn: room = conn.execute(_SELECT_ROOM_WITH_BYTES, (room_id,)).fetchone() if room is None: _raise_room_not_found(conn, room_id) if room["disbanded_at"] is not None: raise RoomNotFoundError("hosted room not found") existing = _load_event(conn, room_id, event_id) if existing is not None: if existing["kind"] != "room.renamed" or existing["payload_json"] != payload_json: raise EventConflictError("event_id already exists with different immutable content") result = _room_from_row(room, idempotent=True) result["event"] = _event_from_row(existing, idempotent=True) return result seq = int(room["next_seq"]) epoch = int(room["authority_epoch"]) event_bytes = _prepare_event(conn, room, event_id, "room.renamed", actor_json, payload_json) # Rename updates the room row before inserting its event (order is load-bearing). conn.execute( """UPDATE hosted_rooms SET name=?, next_seq=?, event_bytes=event_bytes+?, revision=revision+1, updated_at=? WHERE room_id=?""", (name, seq + 1, event_bytes, now, room_id), ) conn.execute( _INSERT_EVENT, (room_id, seq, event_id, "room.renamed", actor_json, epoch, payload_json, now), ) updated = conn.execute(_SELECT_ROOM, (room_id,)).fetchone() event = _load_event(conn, room_id, event_id) result = _room_from_row(updated) result["event"] = _event_from_row(event) return result def append_event( db_path: Path | str, *, room_id: Any, event_id: Any, kind: Any, actor: Any, payload: Any, authority_gateway_id: Any = None, authority_epoch: Any = None, now: float | None = None, ) -> dict[str, Any]: """Append one immutable event and allocate its per-room sequence atomically. Repeating the same ``event_id`` and immutable content returns the original event. Reusing the id for different content fails closed. """ room_id = _room_id(room_id) event_id = _event_id(event_id) kind = _validate_event_kind(kind) normalized_actor, actor_json = _validate_actor(actor, kind=kind) # Every admitted actor kind is room-scoped, so authority fields are always required. authority_gateway_id = _actor_id(authority_gateway_id, "authority_gateway_id") if normalized_actor["kind"] == "gateway" and normalized_actor["id"] != authority_gateway_id: raise HostedRoomError("gateway actor.id must match authority_gateway_id") authority_epoch = _require_positive_int(authority_epoch, "authority_epoch") if not isinstance(payload, dict): raise HostedRoomError("payload must be an object") payload_json = _payload_json(payload) now = _now(now) with _transaction(db_path, immediate=True) as conn: existing = _load_event(conn, room_id, event_id) if existing is not None: if ( existing["kind"] != kind or existing["actor_json"] != actor_json or existing["authority_epoch"] != authority_epoch or existing["payload_json"] != payload_json ): raise EventConflictError("event_id already exists with different content") return _event_from_row(existing, idempotent=True) room = conn.execute( """SELECT next_seq, event_bytes, authority_gateway_id, authority_epoch FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""", (room_id,), ).fetchone() if room is None: _raise_room_not_found(conn, room_id) if ( room["authority_gateway_id"] != authority_gateway_id or int(room["authority_epoch"]) != authority_epoch ): raise AuthorityConflictError("stale hosted room authority") seq = int(room["next_seq"]) event_bytes = _prepare_event( conn, room, event_id, kind, actor_json, payload_json, allow_control=kind in _CONTROL_EVENT_KINDS, ) conn.execute( _INSERT_EVENT, (room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, now), ) advanced = conn.execute( """UPDATE hosted_rooms SET next_seq=?, event_bytes=event_bytes+?, updated_at=? WHERE room_id=? AND next_seq=?""", (seq + 1, event_bytes, now, room_id, seq), ) if advanced.rowcount != 1: raise RuntimeError("hosted room sequence advance lost its write fence") row = _reload( conn, f"SELECT {_EVENT_COLUMNS} FROM hosted_room_events WHERE room_id=? AND seq=?", (room_id, seq), "appended event could not be reloaded", ) result = _event_from_row(row) result["actor"] = normalized_actor return result def _probe(path: Path, table: str, query: str, params: tuple[Any, ...], unavailable: str) -> bool: """Non-blocking existence probe: short timeout, no schema creation or migration.""" if not path.is_file(): return False try: conn = sqlite3.connect(path, timeout=0.05) try: table_row = conn.execute( f"SELECT 1 FROM sqlite_master WHERE type='table' AND name='{table}' LIMIT 1" ).fetchone() if table_row is None: return False return conn.execute(query, params).fetchone() is not None finally: conn.close() except sqlite3.Error as exc: raise RoomProbeUnavailableError(unavailable) from exc def probe_hosted_room(db_path: Path | str, *, room_id: Any) -> bool: """Check room ownership without creating or migrating the shared store. This runs on the synchronous prompt-admission path for older Desktop clients, so it fails quickly under contention instead of blocking the WebSocket reader for SQLite's normal ten-second timeout. """ checked_room_id = _room_id(room_id) return _probe( Path(db_path), "hosted_rooms", "SELECT 1 FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL LIMIT 1", (checked_room_id,), "hosted room ownership is temporarily unavailable", ) def probe_peer_room_reservation( db_path: Path | str, *, room_id: Any, target_profile: Any, now: float | None = None ) -> bool: """Check a peer reservation without creating or migrating shared state.""" checked_room_id = _room_id(room_id) checked_profile = _actor_id(target_profile, "target_profile") path = Path(db_path) if not path.is_file(): return False return _probe( path, "hosted_room_peer_reservations", _SELECT_LIVE_RESERVATION, (checked_room_id, checked_profile, _now(now)), "peer room ownership is temporarily unavailable", ) def room_state( db_path: Path | str, *, room_id: Any, include_disbanded: bool = False ) -> dict[str, Any]: """Return durable replay and authority state for one room.""" room_id = _room_id(room_id) with _transaction(db_path) as conn: row = conn.execute( f"""SELECT {_ROOM_COLUMNS} FROM hosted_rooms WHERE room_id=? AND (disbanded_at IS NULL OR ?)""", (room_id, int(include_disbanded)), ).fetchone() if row is None: _raise_room_not_found(conn, room_id) claim_row = conn.execute( f"""SELECT {_EVENT_COLUMNS} FROM hosted_room_events WHERE room_id=? AND kind='authority.claimed' AND authority_epoch=? ORDER BY seq DESC LIMIT 1""", (room_id, int(row["authority_epoch"])), ).fetchone() state = _room_from_row(row) state["latest_seq"] = int(row["next_seq"]) - 1 if claim_row is not None: state["authority_claim"] = _event_from_row(claim_row) return state def request_room_stop( db_path: Path | str, *, room_id: Any, cancel_id: Any, expected_gateway_id: Any, expected_epoch: Any, ) -> dict[str, Any]: """Append an idempotent fence that supersedes earlier user turns.""" cancel_id = _event_id(cancel_id, label="cancel_id") digest = hashlib.sha256(cancel_id.encode()).hexdigest()[:32] return append_event( db_path, room_id=room_id, event_id=f"room-stop:{digest}", kind="room.stop_requested", actor={"kind": "gateway", "id": expected_gateway_id}, payload={"cancel_id": cancel_id}, authority_gateway_id=expected_gateway_id, authority_epoch=expected_epoch, ) def _append_authority_claim( conn: sqlite3.Connection, row: sqlite3.Row, *, room_id: str, event_id: str, expected_gateway_id: str, expected_epoch: int, new_gateway_id: str, target_epoch: int, actor_json: str, payload_json: str, now: float, ) -> sqlite3.Row | None: """Insert the claim event and CAS the room's authority; returns the stored claim event.""" seq = int(row["next_seq"]) claim_bytes = _prepare_event( conn, row, event_id, "authority.claimed", actor_json, payload_json, allow_control=True ) conn.execute( _INSERT_EVENT, (room_id, seq, event_id, "authority.claimed", actor_json, target_epoch, payload_json, now), ) updated = conn.execute( """UPDATE hosted_rooms SET authority_gateway_id=?, authority_epoch=authority_epoch+1, next_seq=next_seq+1, event_bytes=event_bytes+?, revision=revision+1, updated_at=? WHERE room_id=? AND disbanded_at IS NULL AND authority_gateway_id=? AND authority_epoch=?""", (new_gateway_id, claim_bytes, now, room_id, expected_gateway_id, expected_epoch), ) if updated.rowcount != 1: raise AuthorityConflictError("hosted room authority changed") return _load_event(conn, room_id, event_id) def claim_authority( db_path: Path | str, *, room_id: Any, expected_gateway_id: Any, expected_epoch: Any, new_gateway_id: Any, event_id: Any, now: float | None = None, ) -> dict[str, Any]: """Fence a verified authority transfer with a compare-and-swap epoch. This storage primitive does not decide *when* takeover is safe. A replicated driver must call it only after its lease/quorum policy has established that the previous owner can no longer commit. """ room_id = _room_id(room_id) expected_gateway_id = _actor_id(expected_gateway_id, "expected_gateway_id") new_gateway_id = _actor_id(new_gateway_id, "new_gateway_id") event_id = _event_id(event_id) _require_positive_int(expected_epoch, "expected_epoch") now = _now(now) target_epoch = expected_epoch + 1 claim_actor_json = _system_actor_json("authority-control") claim_payload_json = _claim_payload_json(expected_gateway_id, new_gateway_id, target_epoch) with _transaction(db_path, immediate=True) as conn: row = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""", (room_id,), ).fetchone() if row is None: _raise_room_not_found(conn, room_id) current_gateway = str(row["authority_gateway_id"]) current_epoch = int(row["authority_epoch"]) existing_event = _load_event(conn, room_id, event_id) idempotent = existing_event is not None if idempotent: if ( existing_event["kind"] != "authority.claimed" or existing_event["actor_json"] != claim_actor_json or existing_event["authority_epoch"] != target_epoch or existing_event["payload_json"] != claim_payload_json ): raise EventConflictError("event_id already exists with different content") if current_gateway != new_gateway_id or current_epoch != target_epoch: raise AuthoritySupersededError("authority claim succeeded but was later superseded") elif current_gateway != expected_gateway_id or current_epoch != expected_epoch: raise AuthorityConflictError("hosted room authority changed") else: existing_event = _append_authority_claim( conn, row, room_id=room_id, event_id=event_id, expected_gateway_id=expected_gateway_id, expected_epoch=expected_epoch, new_gateway_id=new_gateway_id, target_epoch=target_epoch, actor_json=claim_actor_json, payload_json=claim_payload_json, now=now, ) state_row = _reload( conn, """SELECT room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, revision, created_at, updated_at FROM hosted_rooms WHERE room_id=?""", (room_id,), "claimed room could not be reloaded", ) state = _room_from_row(state_row, idempotent=idempotent) state["latest_seq"] = int(state_row["next_seq"]) - 1 if existing_event is None: # pragma: no cover - both claim paths set it raise RuntimeError("authority claim event could not be reloaded") state["claim_event"] = _event_from_row(existing_event, idempotent=idempotent) return state def _disband_replay( conn: sqlite3.Connection, room_id: str, room: sqlite3.Row | None ) -> dict[str, Any] | None: """Idempotent replay for a retired or already-disbanded room; None when the room is live.""" if room is None: retired = conn.execute( "SELECT retired_at FROM hosted_room_retired_ids WHERE room_id=?", (room_id,) ).fetchone() if retired is None: raise RoomNotFoundError("hosted room not found") return { "room_id": room_id, "disbanded_at": float(retired["retired_at"]), "idempotent": True, "history_expired": True, } if room["disbanded_at"] is None: return None conn.execute(_INSERT_RETIRED, (room_id, float(room["disbanded_at"]))) event = _load_event(conn, room_id, "system:room-disbanded") result = {"room_id": room_id, "disbanded_at": float(room["disbanded_at"]), "idempotent": True} if event is not None: result["event"] = _event_from_row(event, idempotent=True) return result def disband_room( db_path: Path | str, *, room_id: Any, expected_gateway_id: Any, expected_epoch: Any, now: float | None = None, ) -> dict[str, Any]: """Tombstone a room id permanently and idempotently.""" room_id = _room_id(room_id) expected_gateway_id = _actor_id(expected_gateway_id, "expected_gateway_id") _require_positive_int(expected_epoch, "expected_epoch") now = _now(now) with _transaction(db_path, immediate=True) as conn: room = conn.execute( """SELECT authority_gateway_id, authority_epoch, next_seq, event_bytes, disbanded_at FROM hosted_rooms WHERE room_id=?""", (room_id,), ).fetchone() replay = _disband_replay(conn, room_id, room) if replay is not None: return replay if ( str(room["authority_gateway_id"]) != expected_gateway_id or int(room["authority_epoch"]) != expected_epoch ): raise AuthorityConflictError("stale hosted room authority") seq = int(room["next_seq"]) actor_json = _system_actor_json("room-control") payload_json = _payload_json({"room_id": room_id}) disband_bytes = _prepare_event( conn, room, "system:room-disbanded", "room.disbanded", actor_json, payload_json, allow_control=True, ) conn.execute( _INSERT_EVENT, ( room_id, seq, "system:room-disbanded", "room.disbanded", actor_json, int(room["authority_epoch"]), payload_json, now, ), ) updated = conn.execute( """UPDATE hosted_rooms SET disbanded_at=?, updated_at=?, revision=revision+1, next_seq=next_seq+1, event_bytes=event_bytes+? WHERE room_id=? AND disbanded_at IS NULL AND authority_gateway_id=? AND authority_epoch=?""", (now, now, disband_bytes, room_id, expected_gateway_id, expected_epoch), ) if updated.rowcount != 1: raise RoomConflictError("hosted room disband lost its fence") conn.execute(_INSERT_RETIRED, (room_id, now)) event = _reload( conn, _SELECT_EVENT, (room_id, "system:room-disbanded"), "room disband event could not be reloaded", ) _prune_disbanded_rooms_locked( conn, now=now, max_gateway_event_bytes=MAX_GATEWAY_EVENT_BYTES ) return { "room_id": room_id, "disbanded_at": now, "idempotent": False, "event": _event_from_row(event), } def read_events( db_path: Path | str, *, room_id: Any, since_seq: Any = 0, limit: Any = 100, include_disbanded: bool = False, ) -> dict[str, Any]: """Read a monotonic room-log delta after ``since_seq``.""" room_id = _room_id(room_id) since_seq = _non_negative(since_seq, "since_seq") limit = _bounded_limit(limit, MAX_LOG_LIMIT) with _transaction(db_path) as conn: room = conn.execute( """SELECT next_seq, authority_gateway_id, authority_epoch FROM hosted_rooms WHERE room_id=? AND (disbanded_at IS NULL OR ?)""", (room_id, int(include_disbanded)), ).fetchone() if room is None: _raise_room_not_found(conn, room_id) latest_seq = int(room["next_seq"]) - 1 authority = { "gateway_id": str(room["authority_gateway_id"]), "epoch": int(room["authority_epoch"]) } if since_seq > latest_seq: raise HostedRoomError("since_seq is ahead of the hosted room log") rows = conn.execute( f"""WITH candidates AS ( SELECT {_EVENT_COLUMNS}, SUM( LENGTH(CAST(event_id AS BLOB)) + LENGTH(CAST(kind AS BLOB)) + LENGTH(CAST(actor_json AS BLOB)) + LENGTH(CAST(payload_json AS BLOB)) ) OVER (ORDER BY seq ASC) AS cumulative_bytes FROM hosted_room_events WHERE room_id=? AND seq>? ORDER BY seq ASC LIMIT ? ) SELECT {_EVENT_COLUMNS} FROM candidates WHERE cumulative_bytes<=? ORDER BY seq ASC""", (room_id, since_seq, limit, MAX_LOG_PAGE_BYTES), ).fetchall() events = [_event_from_row(row) for row in rows] def build_page(page_events: list[dict[str, Any]]) -> dict[str, Any]: cursor = page_events[-1]["seq"] if page_events else since_seq return { "events": page_events, "cursor": cursor, "latest_seq": latest_seq, "has_more": cursor < latest_seq, "authority": authority, } def page_bytes(page: dict[str, Any]) -> int: return len(json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) page = build_page(events) if events and page_bytes(page) > MAX_LOG_PAGE_BYTES: # Binary-search the largest prefix whose serialized page fits the budget. low, high = 1, len(events) while low < high: middle = (low + high + 1) // 2 if page_bytes(build_page(events[:middle])) <= MAX_LOG_PAGE_BYTES: low = middle else: high = middle - 1 page = build_page(events[:low]) if page_bytes(page) > MAX_LOG_PAGE_BYTES: raise HostedRoomError("hosted room event exceeds replay page limit") return page