468 lines
17 KiB
Python
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
|