Files
hermes-agent/gateway/hosted_rooms.py
T

1672 lines
70 KiB
Python

"""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<? AND revoked_at IS NULL""",
(timestamp, timestamp, room_id, target_profile, epoch),
)
existing = conn.execute(
"""SELECT authority_gateway_id, authority_epoch FROM hosted_room_peer_reservations
WHERE room_id=? AND member_id=? AND target_profile=?""",
values[:3],
).fetchone()
if existing is not None and _reservation_superseded(existing, gateway_id, epoch):
raise AuthorityConflictError("peer room reservation authority changed")
conn.execute(
"""INSERT INTO hosted_room_peer_reservations(
room_id, member_id, target_profile, authority_gateway_id,
authority_epoch, expires_at, revoked_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)
ON CONFLICT(room_id, member_id, target_profile) DO UPDATE SET
authority_gateway_id=excluded.authority_gateway_id,
authority_epoch=excluded.authority_epoch,
expires_at=MAX(hosted_room_peer_reservations.expires_at,
excluded.expires_at),
revoked_at=NULL,
updated_at=excluded.updated_at""",
(*values, expiry, timestamp, timestamp),
)
def _read_one(db_path: Path | str, sql: str, params: tuple[Any, ...]) -> 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