fix(profiles): migrate session/routing identity on profile rename
Renaming a profile moved profiles/<old>/ to profiles/<new>/, so the row DATA travelled with the directory, but the profile name is also baked into keys/values the move left untouched: session keys (agent:<old>:* namespace), sessions.profile_name (fail-closed owner ladder / Desktop sidebar scope / @session: deep links), sessions.origin_json.profile, gateway_heartbeats.profile, delivery_obligations (session_key + adapter_profile), telegram_dm_topic_* profile_name bindings, and the gateway_routing index. Left stale, every inbound event on a chat keyed to the old name resolved to a profile that no longer exists — flooding errors.log with "Profile <old> does not exist ... falling back to global HERMES_HOME" every few seconds — and renamed sessions dropped out of the sidebar / broke their deep links. The routing index is held in memory by a live multiplexer and written back periodically, so a CLI-side DB rewrite alone is clobbered. Fix in layers: - SessionDB.rekey_profile_state: atomic durable rewrite of the state.db tables, matching the agent:<name>: namespace by exact prefix (substr, not LIKE — '_' is a legal profile-name character and a LIKE wildcard), rewriting the profile inside routing/origin JSON, and REFUSING on a target collision (routing rows or telegram bindings) instead of silently merging. - SessionStore.rekey_profile_routing: rekey the in-memory routing index (keys + origin.profile) then persist — the half a DB write cannot reach. Raises on a target-key collision before mutating. - Control verb migrate-profile-identity (params-carrying; the socket passes params only to handlers that declare them, bare handlers unchanged) so a live gateway rekeys its in-memory copy AND both durable stores (routing home + the renamed profile's own state.db). - rename_profile calls the verb when a multiplexer is live and, if it fails, does NOT fall back to a racing CLI-side write: it prints a warning telling the operator to restart the gateway and retry. With no live gateway it performs the durable rewrite itself (safe: nothing else holds the store open). Checkpoints keyed by the profile's workdir path are a known related gap, tracked separately, not addressed here. Tests: rekey_profile_state (all tables, routing/origin JSON, collisions, idempotent, no-op), rekey_profile_routing (namespace + origin, no-op, no overwrite), control verb param passing, and rename end-to-end for both the live-gateway (delegates, refuses unsafe fallback) and no-gateway (durable rewrite) paths.
This commit is contained in:
@@ -546,6 +546,113 @@ class SessionGatewayMixin:
|
||||
return
|
||||
self._write_sql("DELETE FROM gateway_hygiene_state WHERE session_key = ?", (session_key,))
|
||||
|
||||
def rekey_profile_state(self, old_name: str, new_name: str) -> Dict[str, int]:
|
||||
"""Atomically rewrite exact profile identity in this state database."""
|
||||
old, new = (old_name or "").strip(), (new_name or "").strip()
|
||||
counts: Dict[str, int] = {}
|
||||
if not old or not new or old == new:
|
||||
return counts
|
||||
old_ns, new_ns = f"agent:{old}:", f"agent:{new}:"
|
||||
ns_len = len(old_ns)
|
||||
|
||||
def _do(conn):
|
||||
existing = {row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||
collision = conn.execute(
|
||||
"SELECT old.scope, ? || substr(old.session_key, ?) "
|
||||
"FROM gateway_routing AS old JOIN gateway_routing AS target "
|
||||
"ON target.scope = old.scope "
|
||||
"AND target.session_key = ? || substr(old.session_key, ?) "
|
||||
"WHERE substr(old.session_key, 1, ?) = ? LIMIT 1",
|
||||
(new_ns, ns_len + 1, new_ns, ns_len + 1, ns_len, old_ns),
|
||||
).fetchone()
|
||||
if collision is not None:
|
||||
raise ValueError(
|
||||
f"profile routing collision in scope {collision[0]!r}: {collision[1]!r}")
|
||||
for table, columns in (
|
||||
("telegram_dm_topic_mode", ("chat_id",)),
|
||||
("telegram_dm_topic_bindings", ("chat_id", "thread_id")),
|
||||
):
|
||||
if table not in existing:
|
||||
continue
|
||||
equality = " AND ".join(
|
||||
f"target.{column} = old.{column}" for column in columns)
|
||||
collision = conn.execute(
|
||||
f"SELECT 1 FROM {table} AS old JOIN {table} AS target "
|
||||
f"ON target.profile_name = ? AND {equality} "
|
||||
"WHERE old.profile_name = ? LIMIT 1", (new, old)).fetchone()
|
||||
if collision is not None:
|
||||
raise ValueError(f"profile identity collision in {table}")
|
||||
|
||||
counts["sessions_profile_name"] = conn.execute(
|
||||
"UPDATE sessions SET profile_name = ? WHERE profile_name = ?", (new, old)).rowcount
|
||||
counts["gateway_heartbeats_profile"] = conn.execute(
|
||||
"UPDATE gateway_heartbeats SET profile = ? WHERE profile = ?", (new, old)).rowcount
|
||||
counts["sessions_session_key"] = conn.execute(
|
||||
"UPDATE sessions SET session_key = ? || substr(session_key, ?) "
|
||||
"WHERE substr(session_key, 1, ?) = ?",
|
||||
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
|
||||
|
||||
origin_count = 0
|
||||
for session_id, origin_json in conn.execute(
|
||||
"SELECT id, origin_json FROM sessions WHERE origin_json IS NOT NULL").fetchall():
|
||||
try:
|
||||
payload = json.loads(origin_json)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if isinstance(payload, dict) and payload.get("profile") == old:
|
||||
payload["profile"] = new
|
||||
conn.execute("UPDATE sessions SET origin_json = ? WHERE id = ?",
|
||||
(json.dumps(payload, ensure_ascii=False), session_id))
|
||||
origin_count += 1
|
||||
counts["sessions_origin_json"] = origin_count
|
||||
|
||||
if "delivery_obligations" in existing:
|
||||
counts["delivery_obligations_adapter_profile"] = conn.execute(
|
||||
"UPDATE delivery_obligations SET adapter_profile = ? WHERE adapter_profile = ?",
|
||||
(new, old)).rowcount
|
||||
counts["delivery_obligations_session_key"] = conn.execute(
|
||||
"UPDATE delivery_obligations SET session_key = ? || substr(session_key, ?) "
|
||||
"WHERE substr(session_key, 1, ?) = ?",
|
||||
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
|
||||
for table in ("telegram_dm_topic_mode", "telegram_dm_topic_bindings"):
|
||||
if table in existing:
|
||||
counts[f"{table}_profile_name"] = conn.execute(
|
||||
f"UPDATE {table} SET profile_name = ? WHERE profile_name = ?",
|
||||
(new, old)).rowcount
|
||||
if "telegram_dm_topic_bindings" in existing:
|
||||
counts["telegram_dm_topic_bindings_session_key"] = conn.execute(
|
||||
"UPDATE telegram_dm_topic_bindings "
|
||||
"SET session_key = ? || substr(session_key, ?) "
|
||||
"WHERE substr(session_key, 1, ?) = ?",
|
||||
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
|
||||
|
||||
routing = conn.execute(
|
||||
"SELECT rowid, session_key, entry_json FROM gateway_routing "
|
||||
"WHERE substr(session_key, 1, ?) = ?", (ns_len, old_ns)).fetchall()
|
||||
for rowid, session_key, entry_json in routing:
|
||||
new_session_key = new_ns + session_key[ns_len:]
|
||||
new_json = entry_json
|
||||
if entry_json:
|
||||
try:
|
||||
payload = json.loads(entry_json)
|
||||
except (ValueError, TypeError):
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("session_key"), str) and payload["session_key"].startswith(old_ns):
|
||||
payload["session_key"] = new_ns + payload["session_key"][ns_len:]
|
||||
origin = payload.get("origin")
|
||||
if isinstance(origin, dict) and origin.get("profile") == old:
|
||||
origin["profile"] = new
|
||||
new_json = json.dumps(payload, ensure_ascii=False)
|
||||
conn.execute(
|
||||
"UPDATE gateway_routing SET session_key = ?, entry_json = ? WHERE rowid = ?",
|
||||
(new_session_key, new_json, rowid))
|
||||
counts["gateway_routing"] = len(routing)
|
||||
|
||||
self._execute_write(_do)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def session_gateway_runtime(session_meta: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Read the persisted runtime route off a session row dict (``model_config`` as
|
||||
|
||||
Reference in New Issue
Block a user