Files
aiturk-hermes-ide/tests/gateway/test_hosted_rooms.py
T

1342 lines
41 KiB
Python

"""Behavior tests for the gateway-hosted room event log."""
from __future__ import annotations
import json
import sqlite3
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import pytest
from gateway import hosted_room_driver as driver
from gateway import hosted_rooms as rooms
import hermes_state
from gateway.hosted_room_policy_checkpoint import HostedRoomPolicyCheckpoint
from hermes_state import SessionDB
USER = {"kind": "user", "id": "desktop-user", "display_name": "User"}
GATEWAY_A = {"kind": "gateway", "id": "gateway-a"}
GATEWAY_B = {"kind": "gateway", "id": "gateway-b"}
def _create_pre_actor_database(path) -> None:
conn = sqlite3.connect(path)
try:
conn.execute(
"""CREATE TABLE hosted_rooms (
room_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
members_json TEXT NOT NULL,
next_seq INTEGER NOT NULL,
revision INTEGER NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
disbanded_at REAL
)"""
)
conn.execute(
"""CREATE TABLE hosted_room_events (
room_id TEXT NOT NULL,
seq INTEGER NOT NULL,
event_id TEXT NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (room_id, seq),
UNIQUE (room_id, event_id)
)"""
)
conn.execute(
"""INSERT INTO hosted_rooms
VALUES ('room-1', 'Legacy', '[]', 2, 1, 1, 1, NULL)"""
)
conn.execute(
"""INSERT INTO hosted_room_events
VALUES ('room-1', 1, 'legacy-event', 'message.created', '{}', 1)"""
)
conn.commit()
finally:
conn.close()
def _read_legacy_state(path: str) -> tuple[str, int]:
state = rooms.room_state(path, room_id="room-1")
return state["authority_gateway_id"], state["latest_seq"]
def _create(db, room_id="room-1"):
return rooms.create_room(
db,
room_id=room_id,
name="Release room",
members=[{"profile": "ops", "handle": "ops"}],
authority_gateway_id="gateway-a",
now=10,
)
def _append(db, **kwargs):
if kwargs.get("kind") == "message.user":
kwargs.setdefault("authority_gateway_id", "gateway-a")
kwargs.setdefault("authority_epoch", 1)
return rooms.append_event(db, **kwargs)
def _disband(db, **kwargs):
kwargs.setdefault("expected_gateway_id", "gateway-a")
kwargs.setdefault("expected_epoch", 1)
return rooms.disband_room(db, **kwargs)
def _assert_retired_identity_stays_reserved(db, room_id, *, fresh_id):
# Reopen the database to prove the reservation is durable rather than a
# process-local cache, while the heavier room/event payload is gone.
with sqlite3.connect(db) as conn:
assert (
conn.execute(
"SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,)
).fetchone()
is None
)
assert (
conn.execute(
"SELECT retired_at FROM hosted_room_retired_ids WHERE room_id=?",
(room_id,),
).fetchone()
is not None
)
with pytest.raises(rooms.RoomConflictError, match="disbanded"):
_create(db, room_id)
with pytest.raises(rooms.RoomHistoryExpiredError) as send_error:
_append(
db,
room_id=room_id,
event_id="stale-send",
kind="message.user",
actor=USER,
payload={"text": "stale"},
)
assert send_error.value.reason == "room_history_expired"
with pytest.raises(rooms.RoomHistoryExpiredError) as log_error:
rooms.read_events(db, room_id=room_id, include_disbanded=True)
assert log_error.value.reason == "room_history_expired"
repeated = _disband(db, room_id=room_id, now=999)
assert repeated == {
"room_id": room_id,
"disbanded_at": 20.0,
"idempotent": True,
"history_expired": True,
}
assert _create(db, fresh_id)["room_id"] == fresh_id
def test_create_room_is_idempotent_but_conflicts_fail_closed(tmp_path):
db = tmp_path / "state.db"
first = _create(db)
second = _create(db)
assert first["idempotent"] is False
assert second["idempotent"] is True
assert first["room_id"] == second["room_id"] == "room-1"
with pytest.raises(rooms.RoomConflictError):
rooms.create_room(
db,
room_id="room-1",
name="A different room",
members=[],
authority_gateway_id="gateway-a",
)
def test_concurrent_first_database_open_keeps_every_room(tmp_path):
db = tmp_path / "state.db"
with ThreadPoolExecutor(max_workers=8) as pool:
created = list(pool.map(lambda index: _create(db, f"room-{index}"), range(16)))
assert {room["room_id"] for room in created} == {
f"room-{index}" for index in range(16)
}
assert len(rooms.list_rooms(db)) == 16
def test_first_database_open_retries_only_transient_journal_lock(
tmp_path,
monkeypatch,
):
original = hermes_state.apply_wal_with_fallback
attempts = 0
def transient_lock(conn, **kwargs):
nonlocal attempts
attempts += 1
if attempts < 3:
raise sqlite3.OperationalError("database is locked")
return original(conn, **kwargs)
monkeypatch.setattr(hermes_state, "apply_wal_with_fallback", transient_lock)
assert _create(tmp_path / "state.db")["room_id"] == "room-1"
assert attempts == 3
def test_first_database_open_does_not_retry_other_journal_errors(
tmp_path,
monkeypatch,
):
attempts = 0
def configured_delete_refusal(conn, **kwargs):
nonlocal attempts
attempts += 1
raise sqlite3.OperationalError(
"could not verify configured journal_mode=delete (database is locked)"
)
monkeypatch.setattr(
hermes_state,
"apply_wal_with_fallback",
configured_delete_refusal,
)
with pytest.raises(sqlite3.OperationalError, match="configured"):
_create(tmp_path / "state.db")
assert attempts == 1
def test_room_state_exposes_authority_and_replay_cursor(tmp_path):
db = tmp_path / "state.db"
room = _create(db)
assert room["authority_gateway_id"] == "gateway-a"
assert room["authority_epoch"] == 1
assert rooms.room_state(db, room_id="room-1") == {
**room,
"latest_seq": 0,
}
def test_authority_claim_fences_stale_gateway_events(tmp_path):
db = tmp_path / "state.db"
_create(db)
first = _append(
db,
room_id="room-1",
event_id="turn-1",
kind="turn.started",
actor=GATEWAY_A,
authority_gateway_id="gateway-a",
authority_epoch=1,
payload={"member": "ops"},
)
claimed = rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id="gateway-b",
event_id="claim-gateway-b",
now=30,
)
retried = rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id="gateway-b",
event_id="claim-gateway-b",
now=40,
)
assert claimed["authority_gateway_id"] == "gateway-b"
assert claimed["authority_epoch"] == 2
assert claimed["idempotent"] is False
assert claimed["claim_event"]["kind"] == "authority.claimed"
assert claimed["claim_event"]["authority_epoch"] == 2
assert claimed["claim_event"]["payload"] == {
"previous_gateway_id": "gateway-a",
"authority_gateway_id": "gateway-b",
"authority_epoch": 2,
}
assert retried["authority_epoch"] == 2
assert retried["idempotent"] is True
assert retried["claim_event"]["seq"] == claimed["claim_event"]["seq"]
assert retried["claim_event"]["idempotent"] is True
state = rooms.room_state(db, room_id="room-1")
assert state["authority_claim"]["event_id"] == "claim-gateway-b"
assert state["authority_claim"]["payload"]["previous_gateway_id"] == "gateway-a"
# An exact retry admitted before takeover stays idempotent and cannot
# produce a second side effect, even though its epoch is now stale.
repeated = _append(
db,
room_id="room-1",
event_id="turn-1",
kind="turn.started",
actor=GATEWAY_A,
authority_gateway_id="gateway-a",
authority_epoch=1,
payload={"member": "ops"},
)
assert repeated["seq"] == first["seq"]
assert repeated["idempotent"] is True
with pytest.raises(rooms.AuthorityConflictError, match="stale"):
_append(
db,
room_id="room-1",
event_id="turn-2-stale",
kind="turn.started",
actor=GATEWAY_A,
authority_gateway_id="gateway-a",
authority_epoch=1,
payload={"member": "ops"},
)
with pytest.raises(rooms.AuthorityConflictError, match="stale"):
_append(
db,
room_id="room-1",
event_id="member-2-stale",
kind="message.member",
actor={"kind": "member", "id": "ops"},
authority_gateway_id="gateway-a",
authority_epoch=1,
payload={"text": "stale result"},
)
current = _append(
db,
room_id="room-1",
event_id="turn-2-current",
kind="turn.started",
actor=GATEWAY_B,
authority_gateway_id="gateway-b",
authority_epoch=2,
payload={"member": "ops"},
)
assert current["authority_epoch"] == 2
current_member = _append(
db,
room_id="room-1",
event_id="member-2-current",
kind="message.member",
actor={"kind": "member", "id": "ops"},
authority_gateway_id="gateway-b",
authority_epoch=2,
payload={"text": "current result"},
)
assert current_member["authority_epoch"] == 2
def test_concurrent_authority_claim_has_one_winner(tmp_path):
db = tmp_path / "state.db"
_create(db)
def claim(gateway_id):
try:
return rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id=gateway_id,
event_id=f"claim-{gateway_id}",
)
except rooms.AuthorityConflictError:
return None
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(claim, ["gateway-b", "gateway-c"]))
winners = [result for result in results if result is not None]
assert len(winners) == 1
assert winners[0]["authority_epoch"] == 2
assert rooms.room_state(db, room_id="room-1")["authority_gateway_id"] in {
"gateway-b",
"gateway-c",
}
def test_retry_of_successful_but_superseded_claim_is_distinct(tmp_path):
db = tmp_path / "state.db"
_create(db)
rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id="gateway-b",
event_id="claim-b",
)
rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-b",
expected_epoch=2,
new_gateway_id="gateway-c",
event_id="claim-c",
)
with pytest.raises(rooms.AuthoritySupersededError, match="later superseded"):
rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id="gateway-b",
event_id="claim-b",
)
def test_authority_scoped_events_require_gateway_and_epoch(tmp_path):
db = tmp_path / "state.db"
_create(db)
with pytest.raises(rooms.HostedRoomError, match="authority_gateway_id"):
rooms.append_event(
db,
room_id="room-1",
event_id="turn-1",
kind="member.unavailable",
actor=GATEWAY_A,
payload={"member": "ops"},
)
with pytest.raises(rooms.HostedRoomError, match="actor.id"):
_append(
db,
room_id="room-1",
event_id="turn-2",
kind="turn.started",
actor=GATEWAY_B,
authority_gateway_id="gateway-a",
authority_epoch=1,
payload={"member": "ops"},
)
with pytest.raises(rooms.HostedRoomError, match="authority_gateway_id"):
rooms.append_event(
db,
room_id="room-1",
event_id="message-1",
kind="message.user",
actor=USER,
payload={"text": "hello"},
)
appended = _append(
db,
room_id="room-1",
event_id="message-1",
kind="message.user",
actor=USER,
payload={"text": "hello"},
)
assert appended["authority_epoch"] == 1
def test_append_is_idempotent_and_conflicting_event_id_is_rejected(tmp_path):
db = tmp_path / "state.db"
_create(db)
first = _append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "hello"},
now=20,
)
repeated = _append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "hello"},
now=30,
)
assert first["seq"] == repeated["seq"] == 1
assert repeated["idempotent"] is True
assert rooms.read_events(db, room_id="room-1")["latest_seq"] == 1
with pytest.raises(rooms.EventConflictError):
_append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "changed"},
)
def test_since_seq_returns_ordered_deltas_and_stable_cursor(tmp_path):
db = tmp_path / "state.db"
_create(db)
for index in range(1, 5):
_append(
db,
room_id="room-1",
event_id=f"event-{index}",
kind="message.user",
actor=USER,
payload={"index": index},
now=20 + index,
)
first = rooms.read_events(db, room_id="room-1", since_seq=0, limit=2)
assert [event["seq"] for event in first["events"]] == [1, 2]
assert first == {
"events": first["events"],
"cursor": 2,
"latest_seq": 4,
"has_more": True,
"authority": {"gateway_id": "gateway-a", "epoch": 1},
}
second = rooms.read_events(
db,
room_id="room-1",
since_seq=first["cursor"],
limit=2,
)
assert [event["seq"] for event in second["events"]] == [3, 4]
assert second["cursor"] == 4
assert second["has_more"] is False
settled = rooms.read_events(db, room_id="room-1", since_seq=4)
assert settled["events"] == []
assert settled["cursor"] == settled["latest_seq"] == 4
def test_room_log_survives_store_reopen(tmp_path):
db = tmp_path / "state.db"
_create(db)
_append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "persist me"},
)
assert rooms.list_rooms(db)[0]["name"] == "Release room"
replay = rooms.read_events(db, room_id="room-1")
assert replay["events"][0]["payload"] == {"text": "persist me"}
@pytest.mark.parametrize("session_first", [True, False])
def test_room_tables_coexist_with_session_db_schema(tmp_path, session_first):
db = tmp_path / "state.db"
if session_first:
SessionDB(db_path=db).close()
_create(db)
_append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "shared database"},
)
SessionDB(db_path=db).close()
replay = rooms.read_events(db, room_id="room-1")
assert replay["latest_seq"] == 1
assert replay["events"][0]["payload"]["text"] == "shared database"
def test_concurrent_appends_allocate_one_monotonic_sequence(tmp_path):
db = tmp_path / "state.db"
_create(db)
def append(index):
return _append(
db,
room_id="room-1",
event_id=f"event-{index}",
kind="message.user",
actor=USER,
payload={"index": index},
)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(append, range(24)))
assert sorted(result["seq"] for result in results) == list(range(1, 25))
replay = rooms.read_events(db, room_id="room-1", limit=100)
assert [event["seq"] for event in replay["events"]] == list(range(1, 25))
def test_rolled_back_append_does_not_consume_sequence(tmp_path):
db = tmp_path / "state.db"
_create(db)
conn = sqlite3.connect(db)
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"""INSERT INTO hosted_room_events
(room_id, seq, event_id, kind, actor_json, payload_json, created_at)
VALUES ('room-1', 1, 'crash-event', 'message.user',
'{"id":"desktop-user","kind":"user"}', '{}', 20)"""
)
conn.execute("UPDATE hosted_rooms SET next_seq=2 WHERE room_id='room-1'")
conn.rollback()
finally:
conn.close()
event = _append(
db,
room_id="room-1",
event_id="after-restart",
kind="message.user",
actor=USER,
payload={"text": "safe"},
)
assert event["seq"] == 1
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"room_id": "../escape"}, "invalid room_id"),
({"event_id": "event id"}, "invalid event_id"),
({"kind": "Message Created"}, "invalid event kind"),
({"kind": "directive.run"}, "cannot append"),
({"actor": {"kind": "member", "id": "ops"}}, "cannot append"),
({"payload": []}, "payload must be an object"),
],
)
def test_invalid_event_contract_is_rejected(tmp_path, kwargs, message):
db = tmp_path / "state.db"
_create(db)
params = {
"room_id": "room-1",
"event_id": "event-1",
"kind": "message.user",
"actor": USER,
"payload": {},
}
params.update(kwargs)
with pytest.raises(rooms.HostedRoomError, match=message):
_append(db, **params)
def test_unknown_room_and_invalid_cursor_fail_closed(tmp_path):
db = tmp_path / "state.db"
_create(db)
with pytest.raises(rooms.RoomNotFoundError):
rooms.read_events(db, room_id="missing")
with pytest.raises(rooms.HostedRoomError, match="since_seq"):
rooms.read_events(db, room_id="room-1", since_seq=-1)
with pytest.raises(rooms.HostedRoomError, match="ahead"):
rooms.read_events(db, room_id="room-1", since_seq=1)
with pytest.raises(rooms.HostedRoomError, match="limit"):
rooms.read_events(db, room_id="room-1", limit=0)
def test_actor_is_part_of_event_idempotency_and_replay(tmp_path):
db = tmp_path / "state.db"
_create(db)
event = _append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor=USER,
payload={"text": "hello"},
)
assert event["actor"] == USER
assert rooms.read_events(db, room_id="room-1")["events"][0]["actor"] == USER
with pytest.raises(rooms.EventConflictError):
_append(
db,
room_id="room-1",
event_id="event-1",
kind="message.user",
actor={"kind": "user", "id": "another-user"},
payload={"text": "hello"},
)
def test_disband_is_idempotent_and_room_id_cannot_be_reused(tmp_path):
db = tmp_path / "state.db"
_create(db)
first = _disband(db, room_id="room-1", now=50)
repeated = _disband(db, room_id="room-1", now=60)
assert first["room_id"] == "room-1"
assert first["disbanded_at"] == 50.0
assert first["idempotent"] is False
assert first["event"]["kind"] == "room.disbanded"
assert first["event"]["seq"] == 1
assert first["event"]["payload"] == {"room_id": "room-1"}
assert repeated["idempotent"] is True
assert repeated["event"]["seq"] == first["event"]["seq"]
assert rooms.list_rooms(db) == []
deleted = rooms.list_rooms(db, include_disbanded=True)
assert deleted[0]["disbanded_at"] == 50.0
assert deleted[0]["latest_seq"] == 1
state = rooms.room_state(db, room_id="room-1", include_disbanded=True)
assert state["disbanded_at"] == 50.0
assert state["latest_seq"] == 1
replay = rooms.read_events(db, room_id="room-1", include_disbanded=True)
assert [event["kind"] for event in replay["events"]] == ["room.disbanded"]
with pytest.raises(rooms.RoomConflictError):
_create(db)
with pytest.raises(rooms.RoomNotFoundError) as missing:
rooms.read_events(db, room_id="room-1")
assert not isinstance(missing.value, rooms.RoomHistoryExpiredError)
def test_disband_rejects_stale_authority(tmp_path):
db = tmp_path / "state.db"
_create(db)
rooms.claim_authority(
db,
room_id="room-1",
expected_gateway_id="gateway-a",
expected_epoch=1,
new_gateway_id="gateway-b",
event_id="claim-b",
)
with pytest.raises(rooms.AuthorityConflictError, match="stale"):
_disband(db, room_id="room-1")
tombstone = _disband(
db,
room_id="room-1",
expected_gateway_id="gateway-b",
expected_epoch=2,
)
assert tombstone["event"]["authority_epoch"] == 2
def test_room_log_pages_are_bounded_by_serialized_event_bytes(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_create(db)
for index in range(4):
_append(
db,
room_id="room-1",
event_id=f"message-{index}",
kind="message.user",
actor=USER,
payload={"text": "x" * 180, "index": index},
)
one_event = rooms.read_events(db, room_id="room-1", limit=1)
budget = len(
json.dumps(one_event, ensure_ascii=False, separators=(",", ":")).encode(
"utf-8"
)
) + 1
monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget)
first = rooms.read_events(db, room_id="room-1", limit=4)
assert len(first["events"]) == 1
assert first["has_more"] is True
second = rooms.read_events(
db,
room_id="room-1",
since_seq=first["cursor"],
limit=4,
)
assert second["events"][0]["seq"] == first["cursor"] + 1
def test_room_log_pages_bound_multibyte_utf8_and_advance_cursor(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_create(db)
for index in range(3):
_append(
db,
room_id="room-1",
event_id=f"unicode-{index}",
kind="message.user",
actor=USER,
payload={"text": "😀漢é" * 40, "index": index},
)
def page_bytes(page):
return len(
json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode(
"utf-8"
)
)
one_event_pages = [
rooms.read_events(db, room_id="room-1", since_seq=index, limit=1)
for index in range(3)
]
budget = max(page_bytes(page) for page in one_event_pages) + 1
monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget)
cursor = 0
replayed = []
while True:
page = rooms.read_events(db, room_id="room-1", since_seq=cursor, limit=3)
assert page["events"]
assert page_bytes(page) <= budget
replayed.extend(page["events"])
assert page["cursor"] > cursor
cursor = page["cursor"]
if not page["has_more"]:
break
assert [event["seq"] for event in replayed] == [1, 2, 3]
def test_room_log_page_bound_includes_json_structure_overhead(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_create(db)
for index in range(20):
_append(
db,
room_id="room-1",
event_id=f"small-{index:02d}",
kind="message.user",
actor=USER,
payload={"text": "ok", "index": index},
)
budget = 1_000
monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget)
cursor = 0
replayed = []
while True:
page = rooms.read_events(db, room_id="room-1", since_seq=cursor, limit=20)
assert page["events"]
assert len(
json.dumps(page, ensure_ascii=False, separators=(",", ":")).encode(
"utf-8"
)
) <= budget
replayed.extend(page["events"])
assert page["cursor"] > cursor
cursor = page["cursor"]
if not page["has_more"]:
break
assert [event["seq"] for event in replayed] == list(range(1, 21))
def test_active_rooms_and_event_storage_are_bounded(tmp_path, monkeypatch):
db = tmp_path / "state.db"
_create(db)
monkeypatch.setattr(rooms, "MAX_ACTIVE_ROOMS", 1)
with pytest.raises(rooms.HostedRoomError, match="too many active"):
_create(db, "room-2")
_disband(db, room_id="room-1", now=20)
_create(db, "room-2")
first = _append(
db,
room_id="room-2",
event_id="message-1",
kind="message.user",
actor=USER,
payload={"text": "first"},
)
with sqlite3.connect(db) as conn:
current_bytes = conn.execute(
"SELECT event_bytes FROM hosted_rooms WHERE room_id='room-2'"
).fetchone()[0]
monkeypatch.setattr(rooms, "MAX_ROOM_EVENT_BYTES", current_bytes)
with pytest.raises(rooms.HostedRoomError, match="storage limit"):
_append(
db,
room_id="room-2",
event_id="message-2",
kind="message.user",
actor=USER,
payload={"text": "second"},
)
assert rooms.read_events(db, room_id="room-2")["events"] == [first]
assert _disband(db, room_id="room-2")["event"]["kind"] == "room.disbanded"
def test_room_listing_is_paged_and_old_tombstones_are_pruned(tmp_path, monkeypatch):
db = tmp_path / "state.db"
monkeypatch.setattr(rooms, "MAX_DISBANDED_ROOM_TOMBSTONES", 1)
for index in range(3):
room_id = f"room-{index}"
_create(db, room_id)
if index < 2:
_disband(db, room_id=room_id, now=20 + index)
first_page = rooms.list_rooms(db, include_disbanded=True, limit=1)
second_page = rooms.list_rooms(
db,
include_disbanded=True,
limit=1,
offset=1,
)
assert [room["room_id"] for room in first_page + second_page] == [
"room-1",
"room-2",
]
with pytest.raises(rooms.RoomNotFoundError):
rooms.room_state(db, room_id="room-0", include_disbanded=True)
_assert_retired_identity_stays_reserved(db, "room-0", fresh_id="room-new")
def test_retention_pruning_keeps_retired_room_id_reserved(tmp_path):
db = tmp_path / "state.db"
_create(db, "room-old")
_disband(db, room_id="room-old", now=20)
assert (
rooms.prune_disbanded_rooms(
db,
now=20 + rooms.DISBANDED_ROOM_RETENTION_SECONDS + 1,
)
== 1
)
_assert_retired_identity_stays_reserved(db, "room-old", fresh_id="room-new")
def test_byte_pressure_pruning_keeps_retired_room_id_reserved(
tmp_path,
monkeypatch,
):
db = tmp_path / "state.db"
_create(db, "room-full")
monkeypatch.setattr(rooms, "MAX_GATEWAY_EVENT_BYTES", 0)
_disband(db, room_id="room-full", now=20)
_assert_retired_identity_stays_reserved(db, "room-full", fresh_id="room-new")
def test_tombstone_pruning_owns_only_room_log_driver_and_policy_tables(tmp_path):
db = tmp_path / "state.db"
_create(db)
identity = driver.TaskIdentity(
room_id="room-1",
task_id="task-1",
thread_id="thread-1",
turn_id="turn-1",
)
driver.admit_task(
db,
identity,
payload={
"target_profile": "ops",
"prompt": "Inspect.",
"source_event_seq": 1,
},
clock=lambda: 20,
)
driver.acquire_lease(
db,
room_id="room-1",
gateway_id="gateway-a",
authority_epoch=1,
process_generation="process-a",
ttl_seconds=30,
clock=lambda: 20,
)
HostedRoomPolicyCheckpoint(db)
_disband(db, room_id="room-1", now=50)
with sqlite3.connect(db) as conn:
conn.execute(
"""INSERT INTO hosted_room_policy_cursors(
room_id, through_seq, stopped_through_seq, updated_at
) VALUES ('room-1', 1, 0, 50)"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_threads
VALUES ('room-1', 'thread-1', 'user-1', 1, 0)"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_events
VALUES ('room-1', 'thread-1', 'user-1', 1, '{}')"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_watermarks
VALUES ('room-1', 'thread-1', 'ops', 1)"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_publications
VALUES ('room-1', 'task-1', 'turn.settled', 0, 1)"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_transcript
VALUES (
'room-1', 'thread-1', 1, 'message.user', NULL
)"""
)
conn.execute(
"""CREATE TABLE hosted_room_messaging_refs (
room_id TEXT NOT NULL,
marker TEXT NOT NULL
)"""
)
conn.execute(
"""INSERT INTO hosted_room_policy_transcript_state
VALUES ('room-1', 1)"""
)
conn.execute(
"""INSERT INTO hosted_room_messaging_refs
VALUES ('room-1', 'outside-pr-b')"""
)
assert (
rooms.prune_disbanded_rooms(
db,
now=50 + rooms.DISBANDED_ROOM_RETENTION_SECONDS + 1,
)
== 1
)
with sqlite3.connect(db) as conn:
for table in (
"hosted_rooms",
"hosted_room_events",
"hosted_room_driver_tasks",
"hosted_room_driver_leases",
"hosted_room_policy_cursors",
"hosted_room_policy_threads",
"hosted_room_policy_events",
"hosted_room_policy_watermarks",
"hosted_room_policy_publications",
"hosted_room_policy_transcript",
"hosted_room_policy_transcript_state",
):
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
assert (
conn.execute("SELECT marker FROM hosted_room_messaging_refs").fetchone()[0]
== "outside-pr-b"
)
def test_peer_reservation_rejects_stale_or_conflicting_authority(tmp_path):
db = tmp_path / "state.db"
current = {
"room_id": "room-peer",
"member_id": "member-peer",
"target_profile": "reviewer",
"authority_gateway_id": "gateway-current",
"authority_epoch": 2,
}
rooms.reserve_peer_room(db, claims=current, expires_at=300, now=100)
with pytest.raises(rooms.AuthorityConflictError, match="authority changed"):
rooms.reserve_peer_room(
db,
claims={
**current,
"authority_gateway_id": "gateway-stale",
"authority_epoch": 1,
},
expires_at=300,
now=100,
)
with pytest.raises(rooms.AuthorityConflictError, match="authority changed"):
rooms.reserve_peer_room(
db,
claims={**current, "authority_gateway_id": "gateway-conflict"},
expires_at=300,
now=100,
)
assert rooms.peer_room_is_reserved(
db,
room_id="room-peer",
target_profile="reviewer",
now=200,
)
def test_policy_sync_cannot_recreate_projection_after_room_pruning(
tmp_path,
monkeypatch,
):
db = tmp_path / "state.db"
_create(db)
_append(
db,
room_id="room-1",
event_id="user-1",
kind="message.user",
actor=USER,
payload={"text": "hello", "thread_id": "thread-1"},
now=11,
)
checkpoint = HostedRoomPolicyCheckpoint(db)
original_read = rooms.read_events
def read_then_prune(*args, **kwargs):
page = original_read(*args, **kwargs)
_disband(db, room_id="room-1", now=20)
rooms.prune_disbanded_rooms(
db,
now=20 + rooms.DISBANDED_ROOM_RETENTION_SECONDS + 1,
)
return page
monkeypatch.setattr(rooms, "read_events", read_then_prune)
with pytest.raises(rooms.RoomNotFoundError, match="not found"):
checkpoint.sync(room_id="room-1", latest_seq=1)
with sqlite3.connect(db) as conn:
for table in (
"hosted_room_policy_cursors",
"hosted_room_policy_events",
"hosted_room_policy_transcript",
"hosted_room_policy_transcript_state",
):
assert conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] == 0
def test_pre_actor_draft_database_migrates_with_explicit_legacy_identity(tmp_path):
db = tmp_path / "state.db"
_create_pre_actor_database(db)
replay = rooms.read_events(db, room_id="room-1")
assert replay["events"][0]["actor"] == {"kind": "system", "id": "legacy"}
state = rooms.room_state(db, room_id="room-1")
assert state["authority_gateway_id"] == "legacy"
assert state["authority_epoch"] == 1
adopted = rooms.create_room(
db,
room_id="room-1",
name="Legacy",
members=[],
authority_gateway_id="gateway-a",
now=2,
)
assert adopted["authority_gateway_id"] == "gateway-a"
assert adopted["authority_epoch"] == 2
assert adopted["adopted"] is True
assert adopted["claim_event"]["seq"] == 2
assert adopted["claim_event"]["payload"]["previous_gateway_id"] == "legacy"
def test_migration_reserves_existing_disbanded_room_id_before_pruning(tmp_path):
db = tmp_path / "state.db"
_create_pre_actor_database(db)
with sqlite3.connect(db) as conn:
conn.execute("UPDATE hosted_rooms SET disbanded_at=20 WHERE room_id='room-1'")
conn.commit()
assert (
rooms.prune_disbanded_rooms(
db,
now=20 + rooms.DISBANDED_ROOM_RETENTION_SECONDS + 1,
)
== 1
)
_assert_retired_identity_stays_reserved(db, "room-1", fresh_id="room-new")
def test_legacy_adoption_fills_missing_targets_but_rejects_target_changes(tmp_path):
db = tmp_path / "state.db"
legacy_members = [
{"member_id": "ops", "profile": "ops", "handle": "ops"},
]
targeted_members = [
{
**legacy_members[0],
"target": {"kind": "local", "profile": "ops"},
},
]
rooms.create_room(
db,
room_id="legacy-targets",
name="Legacy targets",
members=legacy_members,
authority_gateway_id="legacy",
now=1,
)
adopted = rooms.create_room(
db,
room_id="legacy-targets",
name="Legacy targets",
members=targeted_members,
authority_gateway_id="gateway-a",
now=2,
)
assert adopted["members"] == targeted_members
with pytest.raises(rooms.RoomConflictError, match="different state"):
rooms.create_room(
db,
room_id="legacy-targets",
name="Legacy targets",
members=[
{
**legacy_members[0],
"target": {
"kind": "peer",
"installation_id": "install-b",
"profile": "ops",
},
},
],
authority_gateway_id="gateway-a",
now=3,
)
def test_draft_schema_migration_is_safe_across_processes(tmp_path):
db = tmp_path / "state.db"
_create_pre_actor_database(db)
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(_read_legacy_state, [str(db)] * 4))
assert results == [("legacy", 1)] * 4
replay = rooms.read_events(db, room_id="room-1")
assert replay["events"][0]["actor"] == {"kind": "system", "id": "legacy"}
def test_legacy_remote_run_receipt_migrates_without_current_lineage_access(
tmp_path,
):
db = tmp_path / "state.db"
with sqlite3.connect(db) as conn:
conn.execute(
"""CREATE TABLE hosted_room_remote_runs (
room_id TEXT NOT NULL,
member_id TEXT NOT NULL,
task_id TEXT NOT NULL,
execution_generation INTEGER NOT NULL,
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 (task_id, execution_generation)
)"""
)
conn.execute(
"""INSERT INTO hosted_room_remote_runs VALUES(
'room-1', 'member-reviewer', 'task-1', 1, 'install-peer',
'reviewer', 'run-legacy', 'session-legacy', 1, 1
)"""
)
current = {
"room_id": "room-1",
"home_install_id": "install-home",
"authority_gateway_id": "gateway-home",
"authority_epoch": 2,
"member_id": "member-reviewer",
"target_install_id": "install-peer",
"target_profile": "reviewer",
"task_id": "task-1",
"execution_generation": 1,
}
assert rooms.remote_run_receipt(db, record=current) is None
legacy = rooms.list_remote_run_receipts(db)
assert legacy[0]["home_install_id"] == "legacy"
assert legacy[0]["authority_gateway_id"] == "legacy"
rooms.upsert_remote_run_receipt(
db,
record={**current, "run_id": "run-current", "session_id": "session-current"},
)
assert rooms.remote_run_receipt(db, record=current)["run_id"] == "run-current"
def test_interrupted_draft_schema_migration_rolls_back_atomically(
tmp_path,
monkeypatch,
):
db = tmp_path / "state.db"
_create_pre_actor_database(db)
original = rooms._initialize_schema
def interrupt_after_first_alter(conn):
conn.execute(
"ALTER TABLE hosted_rooms "
"ADD COLUMN authority_gateway_id TEXT NOT NULL DEFAULT 'legacy'"
)
raise RuntimeError("simulated migration interruption")
monkeypatch.setattr(rooms, "_initialize_schema", interrupt_after_first_alter)
with pytest.raises(RuntimeError, match="simulated migration interruption"):
rooms.list_rooms(db)
with sqlite3.connect(db) as conn:
columns = {row[1] for row in conn.execute("PRAGMA table_info(hosted_rooms)")}
assert "authority_gateway_id" not in columns
monkeypatch.setattr(rooms, "_initialize_schema", original)
assert rooms.room_state(db, room_id="room-1")["authority_gateway_id"] == "legacy"
def test_gateway_event_budget_leaves_pre_update_snapshot_headroom():
from hermes_cli import update_cmd
# SQLite stores room ids and index entries beyond the logical payload
# accounting, while session data shares the same file. Keep at least an
# order-of-magnitude safety margin below the physical snapshot cutoff.
assert (
rooms.MAX_GATEWAY_EVENT_BYTES * 32
<= update_cmd._PRE_UPDATE_SNAPSHOT_MAX_FILE_SIZE
)
def test_room_log_page_bound_counts_bytes_not_characters(tmp_path, monkeypatch):
"""Char-based accounting (SQLite LENGTH(TEXT) / len(str)) must not let a
multibyte page exceed the byte ceiling.
Budget sits between the character length and the byte length of a
two-event page: character accounting would admit both events, byte
accounting admits exactly one.
"""
db = tmp_path / "state.db"
_create(db)
for index in range(2):
_append(
db,
room_id="room-1",
event_id=f"bytes-{index}",
kind="message.user",
actor=USER,
payload={"text": "😀" * 200, "index": index},
)
def page_json(page):
return json.dumps(page, ensure_ascii=False, separators=(",", ":"))
def page_bytes(page):
return len(page_json(page).encode("utf-8"))
one_event = rooms.read_events(db, room_id="room-1", since_seq=0, limit=1)
unbounded_two = rooms.read_events(db, room_id="room-1", since_seq=0, limit=2)
budget = page_bytes(one_event) + 64
# Precondition: the budget discriminates — char accounting would accept
# the two-event page, byte accounting must reject it.
assert len(page_json(unbounded_two)) <= budget
assert page_bytes(unbounded_two) > budget
monkeypatch.setattr(rooms, "MAX_LOG_PAGE_BYTES", budget)
page = rooms.read_events(db, room_id="room-1", since_seq=0, limit=2)
assert [event["seq"] for event in page["events"]] == [1]
assert page_bytes(page) <= budget
assert page["has_more"] is True