"""Regression tests for #5455 — extend read-only state.db opens to the remaining pure-read projections. The session-listing path already opens the live agent ``state.db`` read-only (``file:...?mode=ro``) so a write-capable handle doesn't add checkpoint/lock surface while the agent streams into the same WAL DB (see ``test_issue5455_listing_readonly_connection``). The lineage-report and lineage-metadata reads, and the gateway-watcher fingerprint projection (a 5s poll), were still opening a read-WRITE connection. This shared them onto the same ``open_state_db_readonly`` helper. """ import sqlite3 from contextlib import closing import pytest import api.agent_sessions as agent_sessions from api.agent_sessions import ( open_state_db_readonly, read_session_lineage_metadata, read_session_lineage_report, ) def _make_lineage_db(path): conn = sqlite3.connect(str(path)) conn.execute( """ CREATE TABLE sessions ( id TEXT PRIMARY KEY, parent_session_id TEXT, end_reason TEXT, source TEXT, session_source TEXT, title TEXT, started_at REAL, ended_at REAL ) """ ) conn.executemany( "INSERT INTO sessions (id, parent_session_id, end_reason, source, session_source, " "title, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?)", [ ("parent-1", None, "compressed", "cli", "cli", "Root", 1000.0, 1100.0), ("child-1", "parent-1", None, "cli", "cli", "Cont", 1100.0, None), ], ) conn.commit() conn.close() def _record_connects(monkeypatch): """Spy on agent_sessions.sqlite3.connect, recording target + uri per call.""" real_connect = sqlite3.connect calls = [] def spy(target, *args, **kwargs): calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))}) return real_connect(target, *args, **kwargs) monkeypatch.setattr(agent_sessions.sqlite3, "connect", spy) return calls # ── open_state_db_readonly (the shared helper) ─────────────────────────────── def test_helper_opens_read_only_uri(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_lineage_db(db) calls = _record_connects(monkeypatch) with closing(open_state_db_readonly(db)) as conn: assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2 assert calls and calls[0]["uri"] is True assert "mode=ro" in calls[0]["target"] def test_helper_connection_rejects_writes(tmp_path): db = tmp_path / "state.db" _make_lineage_db(db) with closing(open_state_db_readonly(db)) as conn: with pytest.raises(sqlite3.OperationalError): conn.execute("INSERT INTO sessions (id) VALUES ('x')") def test_helper_encodes_special_path_chars(tmp_path, monkeypatch): db_dir = tmp_path / "state dir #1" db_dir.mkdir() db = db_dir / "state?.db" _make_lineage_db(db) calls = _record_connects(monkeypatch) with closing(open_state_db_readonly(db)): pass assert calls[0]["target"].startswith("file://") assert "%20" in calls[0]["target"] # space assert "%23" in calls[0]["target"] # '#' assert "%3F" in calls[0]["target"] # '?' assert calls[0]["target"].endswith("?mode=ro") def test_helper_never_falls_back_to_writable(tmp_path, monkeypatch): """A read-only open failure propagates; the helper never retries with a write-capable handle on the live WAL database.""" db = tmp_path / "state.db" _make_lineage_db(db) calls = [] def fail_read_only(target, *args, **kwargs): calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))}) raise sqlite3.OperationalError("synthetic read-only URI failure") monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only) with pytest.raises(sqlite3.OperationalError, match="synthetic read-only URI failure"): open_state_db_readonly(db) # Exactly one attempt, and it was the read-only URI form. assert len(calls) == 1 assert calls[0]["uri"] is True assert calls[0]["target"].endswith("?mode=ro") def test_helper_raises_on_missing_db_instead_of_creating_a_ghost(tmp_path): """A missing path must raise FileNotFoundError, not have the writable fallback silently create an empty state.db there.""" missing = tmp_path / "does_not_exist" / "state.db" with pytest.raises(FileNotFoundError): open_state_db_readonly(missing) assert not missing.exists(), "helper created a ghost state.db for a missing path" # ── lineage reads route through the helper ─────────────────────────────────── def test_lineage_report_opens_read_only(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_lineage_db(db) calls = _record_connects(monkeypatch) report = read_session_lineage_report(db, "child-1") assert report # a non-empty report came back assert calls and calls[0]["uri"] is True assert "mode=ro" in calls[0]["target"] def test_lineage_metadata_opens_read_only(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_lineage_db(db) calls = _record_connects(monkeypatch) meta = read_session_lineage_metadata(db, ["child-1", "parent-1"]) assert isinstance(meta, dict) assert calls and calls[0]["uri"] is True assert "mode=ro" in calls[0]["target"] # ── gateway-watcher fingerprint projection routes through the helper ───────── def test_gateway_watcher_fingerprint_opens_read_only(tmp_path, monkeypatch): # The 5s watcher poll (_cheap_change_fingerprint) calls open_state_db_readonly, # which resolves sqlite3.connect in agent_sessions' namespace — so the same # spy catches it. The DB needs a `source` column or the fingerprint bails to None. import api.gateway_watcher as gateway_watcher db = tmp_path / "state.db" _make_lineage_db(db) calls = _record_connects(monkeypatch) fp = gateway_watcher._cheap_change_fingerprint(db) assert fp is not None # a fingerprint (not the schema-bail None) was produced assert calls and calls[0]["uri"] is True assert "mode=ro" in calls[0]["target"]