Files
hermes-agent/hermes_cli/kanban_db_notify.py

468 lines
17 KiB
Python

"""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.<name>`` 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