"""Notification subscriptions consumed by the gateway kanban-notifier: per-(task, platform, chat, thread) rows with delivery metadata, unseen-event cursors and purge of stale done-task subs. Split out of ``hermes_cli.kanban_db``; every name is re-exported there, and origin-resident helpers are reached late-bound via ``_kb`` so monkeypatching ``kanban_db.`` keeps working. """ from __future__ import annotations import json import sqlite3 import time from pathlib import Path from typing import Any from typing import Iterable from typing import Mapping from typing import Optional from typing import TYPE_CHECKING if TYPE_CHECKING: from hermes_cli.kanban_db import Event # --------------------------------------------------------------------------- # Notification subscriptions (used by the gateway kanban-notifier) # --------------------------------------------------------------------------- # Notifier reaction to a terminal event: "notify" = passive adapter.send only # (default); "notify+wake" = send AND wake the destination agent; "wake" = wake only. _NOTIFY_DELIVERY_MODES = ("notify", "notify+wake", "wake") def _encode_notify_delivery_metadata( metadata: Optional[Mapping[str, Any]], ) -> Optional[str]: """Serialize platform send metadata stored on notification subscriptions.""" if not isinstance(metadata, Mapping): return None clean: dict[str, Any] = {} for key, value in metadata.items(): if value is None: continue if isinstance(value, (str, int, float, bool)): clean[str(key)] = value if not clean: return None return json.dumps(clean, sort_keys=True, separators=(",", ":")) def _decode_notify_delivery_metadata(raw: Any) -> dict[str, Any]: if isinstance(raw, Mapping): return dict(raw) if not raw: return {} try: data = json.loads(str(raw)) except Exception: return {} if not isinstance(data, dict): return {} return { str(key): value for key, value in data.items() if isinstance(value, (str, int, float, bool)) } def add_notify_sub( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, user_id: Optional[str] = None, user_id_alt: Optional[str] = None, chat_type: Optional[str] = None, notifier_profile: Optional[str] = None, delivery_mode: Optional[str] = None, delivery_metadata: Optional[Mapping[str, Any]] = None, ) -> None: """Register a gateway source wanting terminal-state notifications for ``task_id``. Idempotent on (task, platform, chat, thread). ``user_id_alt`` (Signal UUID, Feishu union_id, ...) must be replayed on active wake: ``build_session_key`` prefers it over ``user_id``, so replaying only ``user_id`` would key the wake into a different session when they diverge. ``chat_type`` is likewise replayed so the woken turn resolves the operator's real channel; ``None`` keeps an existing row's value. ``delivery_mode`` (``_NOTIFY_DELIVERY_MODES``): ``None`` leaves an existing row untouched (fresh rows get ``"notify"``); an explicit value is last-write-wins so re-subscribing can change the mode; unknown values fall back to ``"notify"``. New subs start "caught up" (``last_event_id`` = current ``MAX(task_events.id)``, not 0) — otherwise the notifier replays every historical terminal event on its next tick (boot-time bursts). """ insert_mode = delivery_mode if delivery_mode in _NOTIFY_DELIVERY_MODES else ( # api_server is stateless: the adapter has no send(), the wake self-post # IS the delivery. A plain 'notify' default would leave those subs with # no delivery mechanism at all. Explicit modes still win. "notify+wake" if platform == "api_server" else "notify" ) insert_chat_type = chat_type or "dm" now = int(time.time()) metadata_json = _encode_notify_delivery_metadata(delivery_metadata) with _kb.write_txn(conn): conn.execute( """ INSERT OR IGNORE INTO kanban_notify_subs (task_id, platform, chat_id, thread_id, user_id, user_id_alt, chat_type, notifier_profile, delivery_mode, delivery_metadata, created_at, last_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT MAX(id) FROM task_events WHERE task_id = ?), 0)) """, ( task_id, platform, chat_id, thread_id or "", user_id, user_id_alt, insert_chat_type, notifier_profile, insert_mode, metadata_json, now, task_id, ), ) # chat_type / delivery_mode / delivery_metadata are last-write-wins; # user_id_alt and notifier_profile only self-heal legacy rows lacking one. key = (task_id, platform, chat_id, thread_id or "") for column, value, fill_only in ( ("chat_type", chat_type, False), ("user_id_alt", user_id_alt, True), ("notifier_profile", notifier_profile, True), ("delivery_mode", delivery_mode if delivery_mode in _NOTIFY_DELIVERY_MODES else None, False), ("delivery_metadata", metadata_json, False), ): if not value: continue guard = f" AND ({column} IS NULL OR {column} = '')" if fill_only else "" conn.execute( f"UPDATE kanban_notify_subs SET {column} = ? " "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?" + guard, (value, *key), ) def _notify_profile_filter( notifier_profiles: Optional[Iterable[str]], *, include_unowned: bool, ) -> tuple[str, list[str]]: """Build an optional SQL predicate for notification profile ownership.""" if notifier_profiles is None: return "", [] profiles = sorted( { str(profile).strip() for profile in notifier_profiles if str(profile).strip() } ) clauses: list[str] = [] params: list[str] = [] if profiles: clauses.append( "notifier_profile IN (" + ",".join("?" for _ in profiles) + ")" ) params.extend(profiles) if include_unowned: clauses.append("notifier_profile IS NULL OR notifier_profile = ''") if not clauses: return "0", [] return "(" + ") OR (".join(clauses) + ")", params def list_notify_subs( conn: sqlite3.Connection, task_id: Optional[str] = None, *, notifier_profiles: Optional[Iterable[str]] = None, include_unowned: bool = False, ) -> list[dict]: """List subscriptions, optionally restricted to notifier profile owners. No ``notifier_profiles`` -> all subscriptions. Gateway notifiers pass the profiles they own so they cannot claim another gateway's events; ``include_unowned`` (dispatch owner) covers legacy rows without a stamp. """ owner_where, owner_params = _notify_profile_filter( notifier_profiles, include_unowned=include_unowned, ) where: list[str] = [] params: list[Any] = [] if task_id is not None: where.append("task_id = ?") params.append(task_id) if owner_where: where.append(owner_where) params.extend(owner_params) sql = "SELECT * FROM kanban_notify_subs" if where: sql += " WHERE " + " AND ".join(f"({clause})" for clause in where) rows = conn.execute(sql, params).fetchall() out: list[dict] = [] for row in rows: item = dict(row) if "delivery_metadata" in item: item["delivery_metadata"] = _decode_notify_delivery_metadata( item.get("delivery_metadata") ) out.append(item) return out def count_notify_subs( db_path: Optional[Path] = None, *, board: Optional[str] = None, notifier_profiles: Optional[Iterable[str]] = None, include_unowned: bool = False, platform: Optional[str] = None, chat_id: Optional[str] = None, thread_id: Optional[str] = None, ) -> int: """Count ``kanban_notify_subs`` rows via a read-only connection. Cheap probe for the notifier's zero-subscription early exit: unlike :func:`connect` it never creates the DB file, runs schema init/migration, or opens writable (a read-only WAL open may still create ``-shm``/``-wal`` sidecars but cannot write table content). Rows in a not-yet-checkpointed WAL are visible, so a fresh subscription is never missed. A missing DB or a legacy DB without the table counts as zero. Platform matching is case-insensitive (matching notifier routing); chat/thread are exact. Path resolution matches :func:`connect`. Raises :class:`sqlite3.Error` when the DB exists but cannot be read; callers choose their own fallback. """ path = db_path if db_path is not None else _kb.kanban_db_path(board=board) if not path.exists(): return 0 conn = sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) try: try: owner_where, owner_params = _notify_profile_filter( notifier_profiles, include_unowned=include_unowned, ) clauses: list[str] = [] params: list[Any] = [] if owner_where: clauses.append(f"({owner_where})") params.extend(owner_params) if platform is not None: clauses.append("LOWER(platform) = LOWER(?)") params.append(platform) if chat_id is not None: clauses.append("chat_id = ?") params.append(chat_id) if thread_id is not None: clauses.append("thread_id = ?") params.append(thread_id) query = "SELECT COUNT(*) FROM kanban_notify_subs" if clauses: query += " WHERE " + " AND ".join(clauses) row = conn.execute(query, params).fetchone() except sqlite3.OperationalError as exc: if "no such table" in str(exc).lower(): return 0 raise return int(row[0]) if row else 0 finally: conn.close() def remove_notify_sub( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, ) -> bool: with _kb.write_txn(conn): cur = conn.execute( "DELETE FROM kanban_notify_subs WHERE task_id = ? " "AND platform = ? AND chat_id = ? AND thread_id = ?", (task_id, platform, chat_id, thread_id or ""), ) return cur.rowcount > 0 def purge_stale_done_notify_subs( conn: sqlite3.Connection, *, max_age_days: int = 30, ) -> int: """Delete notify subs whose task has sat in ``done``/``blocked`` untouched for longer than ``max_age_days``. Subs survive ``done`` because a completed task can be reopened and must still notify its origin; on never-archiving boards that would accumulate rows forever, each scanned every notifier tick. ``blocked`` tasks are reaped on the same clock — they are abandoned, not merely waiting like ``backlog``/``ready``. Age is measured from the most recent event (falling back to ``completed_at`` then ``created_at``), so ANY activity — including a reopen — resets or exempts it. ``max_age_days <= 0`` disables the sweep. Returns the number of rows deleted. """ try: days = int(max_age_days) except (TypeError, ValueError): days = 30 if days <= 0: return 0 cutoff = int(time.time()) - days * 86400 with _kb.write_txn(conn): cur = conn.execute( "DELETE FROM kanban_notify_subs WHERE task_id IN (" " SELECT t.id FROM tasks t" " WHERE t.status IN ('done', 'blocked')" " AND COALESCE(" " (SELECT MAX(e.created_at) FROM task_events e" " WHERE e.task_id = t.id)," " t.completed_at, t.created_at, 0" " ) < ?)", (cutoff,), ) return int(cur.rowcount or 0) def _notify_cursor( conn: sqlite3.Connection, task_id: str, platform: str, chat_id: str, thread_id: Optional[str], ) -> Optional[int]: """``last_event_id`` of one subscription row, or ``None`` when unsubscribed.""" row = conn.execute( "SELECT last_event_id FROM kanban_notify_subs " "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?", (task_id, platform, chat_id, thread_id or ""), ).fetchone() return None if row is None else int(row["last_event_id"]) def unseen_events_for_sub( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, kinds: Optional[Iterable[str]] = None, ) -> tuple[int, list[Event]]: """Return ``(new_cursor, events)`` with ``id > last_event_id``. The cursor is NOT advanced here; call :func:`advance_notify_cursor` after delivery. """ cursor = _notify_cursor(conn, task_id, platform, chat_id, thread_id) if cursor is None: return 0, [] kind_list = list(kinds) if kinds else None q = ( "SELECT * FROM task_events WHERE task_id = ? AND id > ? " + ("AND kind IN (" + ",".join("?" * len(kind_list)) + ") " if kind_list else "") + "ORDER BY id ASC" ) params: list[Any] = [task_id, cursor] if kind_list: params.extend(kind_list) rows = conn.execute(q, params).fetchall() out: list[Event] = [] max_id = cursor for r in rows: out.append(_kb.Event.from_row(r)) max_id = max(max_id, int(r["id"])) return max_id, out def claim_unseen_events_for_sub( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, kinds: Optional[Iterable[str]] = None, ) -> tuple[int, int, list[Event]]: """Atomically claim unseen events for one subscription. Returns ``(old_cursor, new_cursor, events)``; when events are returned the row's ``last_event_id`` has already been advanced inside ``BEGIN IMMEDIATE``, so concurrent gateway watchers on the same board DB serialize on SQLite's writer lock and only the first claims a given event range. Callers send the events, then leave the cursor or call :func:`rewind_notify_cursor` on delivery failure. """ with _kb.write_txn(conn): old_cursor = _notify_cursor(conn, task_id, platform, chat_id, thread_id) if old_cursor is None: return 0, 0, [] new_cursor, events = unseen_events_for_sub( conn, task_id=task_id, platform=platform, chat_id=chat_id, thread_id=thread_id, kinds=kinds, ) if not events: return old_cursor, old_cursor, [] conn.execute( "UPDATE kanban_notify_subs SET last_event_id = ? " "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? " "AND last_event_id = ?", (int(new_cursor), task_id, platform, chat_id, thread_id or "", int(old_cursor)), ) return old_cursor, new_cursor, events def advance_notify_cursor( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, new_cursor: int, ) -> None: with _kb.write_txn(conn): conn.execute( "UPDATE kanban_notify_subs SET last_event_id = ? " "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ?", (int(new_cursor), task_id, platform, chat_id, thread_id or ""), ) def rewind_notify_cursor( conn: sqlite3.Connection, *, task_id: str, platform: str, chat_id: str, thread_id: Optional[str] = None, claimed_cursor: int, old_cursor: int, ) -> bool: """Undo a claim when delivery fails. The CAS guard only rewinds if no later notifier advanced the row, so retries never clobber newer progress. """ with _kb.write_txn(conn): cur = conn.execute( "UPDATE kanban_notify_subs SET last_event_id = ? " "WHERE task_id = ? AND platform = ? AND chat_id = ? AND thread_id = ? " "AND last_event_id = ?", ( int(old_cursor), task_id, platform, chat_id, thread_id or "", int(claimed_cursor), ), ) return cur.rowcount > 0 # Late-bound origin namespace (see module docstring). Imported LAST so this # module is fully populated before ``kanban_db`` re-exports from it. from hermes_cli import kanban_db as _kb # noqa: E402