"""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