Files
iris_x_hermes/gateway-plugin/purge.py
T
ARIA b8e756c3dd
CI / Gateway plugin tests (pull_request) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 7m5s
Split adapter.py monolith into focused modules; restore Ruff complexity defaults (issue #12)
adapter.py was a 3,493-line monolith. Split it into focused modules with
clear separation of responsibilities, bringing it down to ~857 lines:

- Module-level helpers: hooks, classify, pickers, commands, setup,
  defaults, secrets
- Frame-handler mixins: inbound, tool_frames, push_frames, media_frames,
  picker_frames, channel_frames, query_frames
- mixin_base: IrisAdapterBase (declaration-only base for shared attrs)
- adapter.py now holds only IrisAdapter (the composition of the 7 mixins
  + BasePlatformAdapter), register(), and test-facing re-exports

The mixins come before BasePlatformAdapter in the MRO so their methods
override the base; super() calls (e.g. send_image) still resolve to
BasePlatformAdapter. No circular imports; dispatch.py and http_server.py
(instance-method callers) are unaffected.

Ruff complexity ceilings (PLR0911/0912/0913/0915) restored to Ruff's
built-in defaults (12/50/6/5) instead of "just above the current maxima",
which ratchets the bar down as code grows. The existing genuinely-complex
functions (frame builders mirroring the wire schema, the QR matrix builder,
the dispatch table) carry an explicit `# noqa: PLR09xx` marking them as
reviewed, frozen exceptions; new code is held to the default ceilings.

All 125 tests green (94 test_android + 31 test_android_http); no new ruff
errors introduced.
2026-08-24 20:55:00 +02:00

156 lines
5.6 KiB
Python

"""Complete (hard) deletion of iris messages from the hermes session store.
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
persisted in the hermes session DB (``state.db``), so a delete request cannot
join on an id. Instead a message is matched to its ``messages`` row by
(session, role, content, timestamp proximity) and that row is deleted.
Deleting a ``messages`` row also drops it from the FTS5 search index via the
``messages_fts_*_delete`` triggers, so **no search trace survives** and the
message is not recoverable.
Channel / thread deletion wipes the whole lane: every session (and its
messages) for the chat (a channel) or the specific thread.
The session DB is opened read-write with a busy timeout. It is normally held
by the gateway core in WAL mode, which allows one writer at a time, so a brief
write from a second connection is safe.
"""
import contextlib
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
def _connect(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path), timeout=10.0)
conn.row_factory = sqlite3.Row
return conn
def _flat_session_id(conn: sqlite3.Connection, chat_id: str) -> str | None:
"""The flat-lane session (thread_id NULL / empty) for *chat_id*."""
row = conn.execute(
"SELECT id FROM sessions WHERE chat_id = ? "
"AND (thread_id IS NULL OR thread_id = '') LIMIT 1",
(chat_id,),
).fetchone()
return row["id"] if row else None
def _thread_session_id(conn: sqlite3.Connection, chat_id: str, thread_id: str) -> str | None:
row = conn.execute(
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ? LIMIT 1",
(chat_id, thread_id),
).fetchone()
return row["id"] if row else None
def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> int:
"""Hard-delete a whole lane from the session store.
* ``thread_id is None`` -> a **channel**: every session under *chat_id*
(the flat lane plus all of its threads) and all of their messages.
* ``thread_id`` set -> a **thread**: the single session for
(chat_id, thread_id) and its messages.
Returns the number of message rows removed (0 when the lane is absent or
the DB is missing). Never raises: any DB error yields 0 (the caller still
deletes the directory entry + outbox frames, so the lane is gone from the
app's point of view even if the session-store wipe fails).
"""
db_path = Path(db_path)
if not db_path.exists():
return 0
conn = _connect(db_path)
try:
if thread_id:
rows = conn.execute(
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ?",
(chat_id, thread_id),
).fetchall()
else:
rows = conn.execute("SELECT id FROM sessions WHERE chat_id = ?", (chat_id,)).fetchall()
ids = [r["id"] for r in rows]
if not ids:
return 0
# Delete per session with fully parameterized queries (a channel has
# only a handful of sessions: the flat lane plus its threads). Messages
# first (foreign_keys is not enforced on this DB), then the session
# rows. The FTS delete triggers fire on the message deletes.
n_msgs = 0
for sid in ids:
cur = conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
n_msgs += max(0, cur.rowcount)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
conn.commit()
return n_msgs
except sqlite3.Error as e:
logger.warning("iris purge: delete_lane failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
conn.close()
def delete_message( # noqa: PLR0913
db_path: Path,
chat_id: str,
thread_id: str | None,
role: str,
content: str,
ts_ms: int | None,
) -> int:
"""Hard-delete a single message from the session store.
Matches the ``messages`` row by (session, role, content) and, when several
rows share that content, the one whose timestamp is closest to *ts_ms*.
The content must match exactly (after stripping) -- a mismatch deletes
nothing rather than the wrong message. Returns 1 when a row was removed,
0 otherwise. Never raises.
"""
db_path = Path(db_path)
if not db_path.exists() or not content or not role:
return 0
conn = _connect(db_path)
try:
sid = (
_thread_session_id(conn, chat_id, thread_id)
if thread_id
else _flat_session_id(conn, chat_id)
)
if sid is None:
return 0
ts_s = (ts_ms or 0) / 1000.0
want = content.strip()
rows = conn.execute(
"SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = ?",
(sid, role),
).fetchall()
target: int | None = None
best_dt: float | None = None
for r in rows:
if (r["content"] or "").strip() != want:
continue
try:
dt = abs(float(r["timestamp"]) - ts_s)
except (TypeError, ValueError):
dt = 0.0
if best_dt is None or dt < best_dt:
best_dt = dt
target = r["id"]
if target is None:
return 0
conn.execute("DELETE FROM messages WHERE id = ?", (target,))
conn.commit()
return 1
except sqlite3.Error as e:
logger.warning("iris purge: delete_message failed: %s", e)
return 0
finally:
with contextlib.suppress(Exception):
conn.close()