Make thread/channel/message deletion complete (hard delete)
Deleting a thread, channel, or message was a no-op/soft-delete: messages were only dropped from the plugin outbox (still in hermes' session store, hence searchable/recoverable) and channels/threads were merely archived. Now deletion is complete and non-recoverable, with no search trace: - purge.py (new): hard-delete from hermes' session store (state.db). delete_lane wipes a channel's/thread's sessions + messages; deleting a messages row also drops it from the FTS5 index via the delete triggers. delete_message removes one message, matched by (session, role, exact content, closest timestamp) since plugin m_<hex> ids aren't persisted. - channels.py: delete() hard-deletes the row (and a channel's child threads) instead of archiving. - outbox.py: add delete_lane() (wipe all frames for a lane) and message_info() (read a message's final role/text/ts for the match). - adapter.py: on_channel_delete wipes outbox + session store; on_message_delete purges the session-store row per message. - App: delete confirmations no longer claim history stays for search; ChannelStore removes a channel's threads on channel delete. - Docs updated to describe hard deletion.
This commit is contained in:
1 parent
9286937e2d
commit
17bf41a0b9
11 files changed
+373
-51
No files matched your search
@@ -114,6 +114,7 @@ from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import media as media_bridge # noqa: E402
|
||||
from . import protocol # noqa: E402
|
||||
from . import purge as purge_bridge # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
@@ -2519,6 +2520,28 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
),
|
||||
)
|
||||
return
|
||||
# Complete deletion: wipe the lane's history from the outbox (so
|
||||
# ``history`` / ``sync`` can't resurrect it) and from the hermes
|
||||
# session store (so no search trace survives). A channel delete takes
|
||||
# its threads with it (thread_id=None); a thread delete is scoped to
|
||||
# its parent channel + thread_id.
|
||||
if entry.get("kind") == "thread":
|
||||
lane_chat_id = entry.get("parent_chat_id") or chat_id
|
||||
thread_id = chat_id
|
||||
else:
|
||||
lane_chat_id = chat_id
|
||||
thread_id = None
|
||||
removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id)
|
||||
removed_msgs = purge_bridge.delete_lane(
|
||||
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||
)
|
||||
logger.info(
|
||||
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
chat_id,
|
||||
entry.get("kind"),
|
||||
removed_frames,
|
||||
removed_msgs,
|
||||
)
|
||||
resp = protocol.channel_deleted(chat_id)
|
||||
resp.id = frame.id
|
||||
await self._ws_server.broadcast(resp)
|
||||
@@ -2663,13 +2686,14 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||
"""Handle an inbound ``message.delete`` request.
|
||||
|
||||
Removes the requested message(s) from the outbox (so ``history`` and
|
||||
``sync`` no longer return them) and broadcasts ``message.deleted`` to
|
||||
every device (outboxed too, so an offline device learns of the
|
||||
deletion on its next ``sync``). Deleting is idempotent: a message that
|
||||
is already gone (pruned by retention) simply yields 0 removed rows,
|
||||
and the ``message.deleted`` broadcast is still emitted so live caches
|
||||
drop it.
|
||||
Completely deletes the requested message(s): they are removed from the
|
||||
outbox (so ``history`` and ``sync`` no longer return them) **and** from
|
||||
the hermes session store (so no search trace survives and they are not
|
||||
recoverable). ``message.deleted`` is broadcast to every device
|
||||
(outboxed too, so an offline device learns of the deletion on its next
|
||||
``sync``). Deleting is idempotent: a message that is already gone
|
||||
(pruned by retention) simply yields 0 removed rows, and the
|
||||
``message.deleted`` broadcast is still emitted so live caches drop it.
|
||||
"""
|
||||
payload = frame.payload
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
@@ -2698,15 +2722,30 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
)
|
||||
return
|
||||
removed = 0
|
||||
purged = 0
|
||||
db_path = get_hermes_home() / "state.db"
|
||||
for mid in message_ids:
|
||||
# Read the final frame data first (role / text / ts) so the
|
||||
# session-store row can be matched, then drop the outbox frames.
|
||||
info = self._outbox.message_info(chat_id, mid, thread_id=thread_id)
|
||||
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
|
||||
if info:
|
||||
purged += purge_bridge.delete_message(
|
||||
db_path,
|
||||
chat_id,
|
||||
thread_id,
|
||||
info.get("role") or "",
|
||||
info.get("text") or "",
|
||||
info.get("ts"),
|
||||
)
|
||||
logger.info(
|
||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
|
||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||
device_id,
|
||||
chat_id,
|
||||
thread_id,
|
||||
message_ids,
|
||||
removed,
|
||||
purged,
|
||||
)
|
||||
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
|
||||
resp.id = frame.id
|
||||
|
||||
@@ -293,20 +293,28 @@ class ChannelDirectory:
|
||||
return self.get(chat_id)
|
||||
|
||||
def delete(self, chat_id: str) -> dict[str, Any] | None:
|
||||
"""Soft-delete (archive) a channel. History stays for search.
|
||||
"""Hard-delete a channel or thread (and, for a channel, its threads).
|
||||
|
||||
The default channel cannot be deleted. Returns the (archived) entry,
|
||||
or ``None`` when the id is unknown / is the default.
|
||||
The row is removed from the directory entirely -- not recoverable. The
|
||||
caller (adapter) is responsible for wiping the lane's history from the
|
||||
outbox and the hermes session store so no search trace survives.
|
||||
|
||||
The default channel cannot be deleted. Returns the (deleted) entry, or
|
||||
``None`` when the id is unknown / is the default.
|
||||
"""
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT is_default FROM channels WHERE chat_id = ?", (chat_id,)
|
||||
"SELECT * FROM channels WHERE chat_id = ?", (chat_id,)
|
||||
).fetchone()
|
||||
if row is None or row["is_default"]:
|
||||
return None
|
||||
self._conn.execute("UPDATE channels SET archived = 1 WHERE chat_id = ?", (chat_id,))
|
||||
entry = _row_to_entry(row)
|
||||
self._conn.execute("DELETE FROM channels WHERE chat_id = ?", (chat_id,))
|
||||
# A channel takes its threads with it.
|
||||
if entry["kind"] != KIND_THREAD:
|
||||
self._conn.execute("DELETE FROM channels WHERE parent_chat_id = ?", (chat_id,))
|
||||
self._conn.commit()
|
||||
return self.get(chat_id)
|
||||
return entry
|
||||
|
||||
# ── reads ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -272,6 +272,56 @@ class Outbox:
|
||||
|
||||
# ── message deletion ──────────────────────────────────────────────────
|
||||
|
||||
def message_info(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
thread_id: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Look up a message's final frame data (role / text / ts) in the outbox.
|
||||
|
||||
Used to match a ``message.delete`` to the hermes session-store row
|
||||
(which is keyed by content + timestamp, not the plugin's message id).
|
||||
Returns ``{role, text, ts}`` for the message's final frame -- a
|
||||
standalone ``message`` frame when present, else the ``message.stop``
|
||||
frame of a streamed reply -- or ``None`` when the message is not in the
|
||||
outbox (e.g. already pruned by retention).
|
||||
"""
|
||||
if not message_id:
|
||||
return None
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
|
||||
).fetchall()
|
||||
msg_frame: dict[str, Any] | None = None
|
||||
stop_frame: dict[str, Any] | None = None
|
||||
for r in rows:
|
||||
try:
|
||||
frame = json.loads(r["frame"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
if thread_id is not None and frame.get("thread_id") != thread_id:
|
||||
continue
|
||||
payload = frame.get("payload")
|
||||
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
|
||||
continue
|
||||
ftype = frame.get("type")
|
||||
if ftype == "message":
|
||||
msg_frame = {
|
||||
"role": payload.get("role"),
|
||||
"text": payload.get("text", ""),
|
||||
"ts": payload.get("ts"),
|
||||
}
|
||||
elif ftype == "message.stop":
|
||||
stop_frame = {
|
||||
"role": "assistant",
|
||||
"text": payload.get("final_text", ""),
|
||||
"ts": payload.get("ts"),
|
||||
}
|
||||
return msg_frame or stop_frame
|
||||
|
||||
def delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -319,6 +369,40 @@ class Outbox:
|
||||
self._conn.commit()
|
||||
return len(cursors)
|
||||
|
||||
def delete_lane(self, chat_id: str, thread_id: str | None = None) -> int:
|
||||
"""Remove every outbox frame for a lane (channel or thread).
|
||||
|
||||
* ``thread_id is None`` -> a **channel**: all frames whose ``chat_id``
|
||||
column is *chat_id* (the flat lane plus every thread under it).
|
||||
* ``thread_id`` set -> a **thread**: frames for *chat_id* whose frame
|
||||
carries that ``thread_id``.
|
||||
|
||||
Called on channel/thread deletion so neither ``history`` nor a ``sync``
|
||||
replay can resurrect the lane's messages. Returns the number of rows
|
||||
removed.
|
||||
"""
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
|
||||
).fetchall()
|
||||
cursors: list[int] = []
|
||||
for r in rows:
|
||||
if thread_id is None:
|
||||
cursors.append(int(r["cursor"]))
|
||||
continue
|
||||
try:
|
||||
frame = json.loads(r["frame"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(frame, dict) and frame.get("thread_id") == thread_id:
|
||||
cursors.append(int(r["cursor"]))
|
||||
if not cursors:
|
||||
return 0
|
||||
for cursor in cursors:
|
||||
self._conn.execute("DELETE FROM outbox WHERE cursor = ?", (cursor,))
|
||||
self._conn.commit()
|
||||
return len(cursors)
|
||||
|
||||
# ── retention ─────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_prune(self) -> None:
|
||||
|
||||
@@ -556,7 +556,7 @@ def channel_renamed(entry: dict[str, Any]) -> Frame:
|
||||
|
||||
|
||||
def channel_deleted(chat_id: str) -> Frame:
|
||||
"""Broadcast: a channel was archived (soft-deleted)."""
|
||||
"""Broadcast: a channel or thread was deleted (its history wiped too)."""
|
||||
return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Complete (hard) deletion of android messages from the hermes session store.
|
||||
|
||||
The android 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("android purge: delete_lane failed: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_message(
|
||||
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("android purge: delete_message failed: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
conn.close()
|
||||
Reference in new issue
Block a user