The session activity heartbeat restamps last_activity_at and last_activity_description mid-turn and on idle ticks. With those columns in the digest, every heartbeat window still broadcast sessions.changed and the Desktop Sessions panel kept refreshing on its own (#98005). Drop them from the signature; message_count, title, pin/archive and row create/delete still move it. Open the probe through the tracked read-only connect every other live state.db reader uses, and keep the last digest when a read fails after a good one so a busy moment cannot flip the signature to mtime and back. Ports the heartbeat / row-change / busy-store regression tests from #102987. Fixes #98005 Co-authored-by: 小象 <247313108+shorehefei-ship-it@users.noreply.github.com> Co-authored-by: salch-cred <salch-cred@users.noreply.github.com> Co-authored-by: ajbmachon <ajbmachon2@gmail.com>
357 lines
16 KiB
Python
357 lines
16 KiB
Python
"""Skin + config-change watcher: on-disk signatures for skin/pet/cron/sessions/platforms/
|
|
pairing/bot-relay state and the broadcast loop that pushes *.changed events. Bodies are
|
|
rebound onto server.py's globals at install time (method_ctx.bind_module)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from .method_ctx import HandlerRegistry, bind_module
|
|
|
|
_registry = HandlerRegistry()
|
|
|
|
|
|
def resolve_skin() -> dict:
|
|
try:
|
|
from hermes_cli.skin_engine import init_skin_from_config, get_active_skin
|
|
init_skin_from_config(_load_cfg())
|
|
skin = get_active_skin()
|
|
# light/dark are paired palettes: the TUI prefers the block matching terminal polarity.
|
|
return {
|
|
"name": skin.name, "colors": skin.colors,
|
|
"light_colors": skin.light_colors, "dark_colors": skin.dark_colors,
|
|
"branding": skin.branding, "banner_logo": skin.banner_logo,
|
|
"banner_hero": skin.banner_hero, "tool_prefix": skin.tool_prefix,
|
|
"help_header": (skin.branding or {}).get("help_header", ""),
|
|
# Raw user CSS for the desktop GUI's <style> tag (32 KiB cap in the skin engine).
|
|
"customCSS": skin.custom_css}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
# (name, user-file mtime) of the last skin broadcast: ``skin.changed`` fires on a name
|
|
# switch OR a live color edit of the active skin, and nothing else.
|
|
_last_skin_sig: tuple[str, float | None] | None = None
|
|
|
|
|
|
def _watcher_home() -> Path:
|
|
"""Active profile home for the change watcher's signature probes."""
|
|
override = get_hermes_home_override()
|
|
return Path(override if isinstance(override, str) and override else _hermes_home)
|
|
|
|
|
|
def _watcher_mtime_ns(path: Path):
|
|
"""``st_mtime_ns`` of ``path``, or None when it cannot be stat'ed."""
|
|
try:
|
|
return path.stat().st_mtime_ns
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _home_mtime_ns(*parts: str):
|
|
return _watcher_mtime_ns(_watcher_home().joinpath(*parts))
|
|
|
|
|
|
def _newest_mtime_ns(paths) -> int | None:
|
|
"""Max ``st_mtime_ns`` across ``paths`` (unstat-able ignored); None when none stat'ed."""
|
|
return max((m for m in map(_watcher_mtime_ns, paths) if m is not None), default=None)
|
|
|
|
|
|
def _skin_sig() -> tuple[str, float | None]:
|
|
"""(active skin name, its user-file mtime). Built-ins have no file, so only
|
|
their name moves; a user skin's mtime lets an in-place color edit repaint too."""
|
|
name = str((_load_cfg().get("display") or {}).get("skin") or "default")
|
|
try:
|
|
return name, (_watcher_home() / "skins" / f"{name}.yaml").stat().st_mtime
|
|
except OSError:
|
|
return name, None
|
|
|
|
|
|
def _note_skin_broadcast() -> None:
|
|
"""Sync the baseline after the /skin RPC emits so the watcher doesn't re-broadcast it."""
|
|
global _last_skin_sig
|
|
with contextlib.suppress(Exception):
|
|
_last_skin_sig = _skin_sig()
|
|
|
|
|
|
def _broadcast_skin_if_changed() -> None:
|
|
"""Emit ``skin.changed`` when the active skin moved, via the SAME live path as
|
|
``/skin`` so every surface repaints. The check is a dict lookup + one stat."""
|
|
global _last_skin_sig
|
|
with contextlib.suppress(Exception):
|
|
sig = _skin_sig()
|
|
if sig == _last_skin_sig:
|
|
return
|
|
_last_skin_sig = sig
|
|
_broadcast_global_event("skin.changed", resolve_skin())
|
|
|
|
|
|
def _active_pet():
|
|
"""(pet, scale) when an enabled pet with an existing sheet is selected, else None."""
|
|
enabled, pet, scale = _pet_active_selection()
|
|
return (pet, scale) if enabled and pet is not None and pet.exists else None
|
|
|
|
|
|
def _pet_sig() -> tuple:
|
|
"""(slug, spritesheet revision, scale) of the active pet — ("off",) when none."""
|
|
display = _load_cfg().get("display") or {}
|
|
pet_cfg = display.get("pet") if isinstance(display.get("pet"), dict) else {}
|
|
if not pet_cfg or not is_truthy_value(pet_cfg.get("enabled"), default=False):
|
|
return ("off",)
|
|
try:
|
|
if active := _active_pet():
|
|
pet, scale = active
|
|
return (pet.slug, _pet_sheet_revision(pet.spritesheet), scale)
|
|
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
|
|
pass
|
|
return ("off",)
|
|
|
|
|
|
def _pet_changed_payload() -> dict:
|
|
"""``pet.info.meta``-shaped payload so the renderer can decide whether to refetch sprites."""
|
|
try:
|
|
if active := _active_pet():
|
|
pet, scale = active
|
|
return {"enabled": True, "slug": pet.slug, "displayName": pet.display_name,
|
|
"scale": scale, "spritesheetRevision": _pet_sheet_revision(pet.spritesheet)}
|
|
except Exception: # noqa: BLE001 - cosmetic, never break the watcher
|
|
pass
|
|
return {"enabled": False}
|
|
|
|
|
|
# last_activity_at / _description are left out on purpose: the session activity heartbeat restamps
|
|
# them mid-turn and on idle ticks, so hashing them re-fired sessions.changed every heartbeat window
|
|
# (#98005). Liveness comes from session.active_list, and a real turn still moves message_count.
|
|
_SESSION_SIGNATURE_FIELDS = (
|
|
"id", "source", "session_key", "display_name", "model", "parent_session_id",
|
|
"started_at", "ended_at", "end_reason", "message_count", "tool_call_count",
|
|
"cwd", "git_branch", "git_repo_root", "title", "title_source", "profile_name",
|
|
"archived", "pinned", "hidden", "last_read_at", "handoff_state",
|
|
)
|
|
# path -> (database/WAL mtime, session-table digest). The mtime guard keeps the
|
|
# normal 0.5 s watch pass stat-only; SQLite is read only after another process
|
|
# actually commits.
|
|
_sessions_db_sig_cache: dict[str, tuple[int | None, tuple | None]] = {}
|
|
|
|
|
|
def _session_db_content_sig(db_path: Path):
|
|
"""Digest list/transcript-relevant session rows, excluding unrelated tables.
|
|
|
|
``gateway_heartbeats`` shares state.db and writes every minute. Using the
|
|
database mtime directly therefore emits sessions.changed while no session
|
|
changed (#98005). Cache behind the DB/WAL mtime, then inspect only the
|
|
sessions columns that drive Desktop projections. Legacy stores safely use
|
|
the subset of columns they have.
|
|
"""
|
|
mtime = _newest_mtime_ns((db_path, db_path.with_name(f"{db_path.name}-wal")))
|
|
cache_key = str(db_path)
|
|
cached = _sessions_db_sig_cache.get(cache_key)
|
|
|
|
if cached is not None and cached[0] == mtime:
|
|
return cached[1]
|
|
if not db_path.exists():
|
|
_sessions_db_sig_cache[cache_key] = (mtime, None)
|
|
|
|
return None
|
|
|
|
conn = None
|
|
try:
|
|
import hashlib
|
|
from hermes_state import _connect_tracked_db
|
|
from hermes_state_holders import read_only_db_uri
|
|
|
|
conn = _connect_tracked_db(read_only_db_uri(db_path), tracking_path=db_path,
|
|
uri=True, timeout=0.05)
|
|
available = {row[1] for row in conn.execute("PRAGMA table_info(sessions)")}
|
|
fields = tuple(field for field in _SESSION_SIGNATURE_FIELDS if field in available)
|
|
if not fields:
|
|
# A state.db without a readable sessions table (foreign schema, legacy
|
|
# or transient file): keep the old mtime contract so any move still
|
|
# wakes the sidebar instead of silently never broadcasting.
|
|
signature = ("mtime-fallback", mtime)
|
|
else:
|
|
order = " ORDER BY id" if "id" in available else ""
|
|
rows = conn.execute(f"SELECT {', '.join(fields)} FROM sessions{order}")
|
|
digest = hashlib.blake2b(digest_size=16)
|
|
for row in rows:
|
|
digest.update(repr(tuple(row)).encode("utf-8", "backslashreplace"))
|
|
digest.update(b"\0")
|
|
signature = (fields, digest.digest())
|
|
except Exception: # noqa: BLE001 - preserve the old wake-up signal if the read probe cannot run
|
|
# A busy/locked read after a good one keeps the last digest and leaves the cached mtime
|
|
# stale so the next pass re-reads: digest -> mtime -> digest would broadcast twice.
|
|
if cached is not None and cached[1] is not None and cached[1][0] != "mtime-fallback":
|
|
return cached[1]
|
|
signature = ("mtime-fallback", mtime)
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
|
|
_sessions_db_sig_cache[cache_key] = (mtime, signature)
|
|
|
|
return signature
|
|
|
|
|
|
def _sessions_sig():
|
|
"""Session-table content across the active and served profile stores.
|
|
|
|
Messaging-gateway turns and cron runs are written by other processes that
|
|
never touch this gateway's transports, so their session rows are the shared
|
|
change signal. Hashing only those rows avoids false Desktop refreshes from
|
|
unrelated state.db writes such as gateway heartbeats.
|
|
"""
|
|
return tuple(
|
|
_session_db_content_sig(root / "state.db")
|
|
for root in (_watcher_home(), *_served_profile_homes)
|
|
)
|
|
|
|
|
|
def _projects_sig():
|
|
"""Newest mtime across projects.db (+ WAL) for the watcher home and every served
|
|
sibling profile. The CLI and other windows write projects.db directly — nothing in
|
|
their process touches this gateway's transports — so the file is the only shared
|
|
signal, exactly like state.db (#53046, #56757)."""
|
|
return _newest_mtime_ns(
|
|
root / name
|
|
for root in (_watcher_home(), *_served_profile_homes)
|
|
for name in ("projects.db", "projects.db-wal"))
|
|
|
|
|
|
def _pairing_sig():
|
|
"""Newest mtime across every profile's pairing ledgers (legacy ``pairing/`` and
|
|
``platforms/pairing/``): the gateway process writes pending codes, so the files are the only
|
|
shared signal (a pairing request moves nothing in gateway_state.json)."""
|
|
entries = []
|
|
for root in _pairing_roots(_watcher_home()):
|
|
with contextlib.suppress(OSError):
|
|
# Only the ledgers: _rate_limits.json moves on every unauthorized DM.
|
|
entries += [
|
|
e for e in root.iterdir() if e.name.endswith(("-pending.json", "-approved.json"))]
|
|
return _newest_mtime_ns(entries)
|
|
|
|
|
|
# Live-profile pairing roots, cached on (home, ``profiles/`` dir mtime) with a TTL. The liveness
|
|
# probe costs ~14 stats per profile; on the 2 s tick that was the watcher's share of the idle
|
|
# profile-tree burn (#114041 §2/§3). The profile SET only moves when a dir is added or removed —
|
|
# which bumps the parent's mtime — while a marker/tombstone landing inside one is caught by the TTL.
|
|
_PAIRING_ROOTS_TTL_S = 30.0
|
|
_pairing_roots_cache: tuple[Path, int | None, float, list] | None = None
|
|
|
|
|
|
def _pairing_roots(home: Path) -> list:
|
|
global _pairing_roots_cache
|
|
profiles_dir = home / "profiles"
|
|
dir_mtime, now = _watcher_mtime_ns(profiles_dir), time.monotonic()
|
|
cached = _pairing_roots_cache
|
|
if cached is not None and cached[0] == home and cached[1] == dir_mtime and now - cached[2] < _PAIRING_ROOTS_TTL_S:
|
|
return cached[3]
|
|
from hermes_constants import named_profile_is_live
|
|
roots = [home / "pairing", home / "platforms" / "pairing"]
|
|
with contextlib.suppress(OSError):
|
|
for profile_dir in profiles_dir.iterdir():
|
|
if named_profile_is_live(profile_dir):
|
|
roots += [profile_dir / "pairing", profile_dir / "platforms" / "pairing"]
|
|
_pairing_roots_cache = (home, dir_mtime, now, roots)
|
|
return roots
|
|
|
|
|
|
# Newest outbox-envelope mtime EVER seen (monotone): a drain empties the outbox,
|
|
# and falling back to None would fire a spurious pending event after every drain.
|
|
_bot_relay_outbox_seen = 0
|
|
|
|
|
|
def _bot_relay_outbox_sig():
|
|
"""Newest mtime across pending bot-relay outbox envelopes (monotone). Written by the AGENT
|
|
process, so the files are the only shared signal; the Desktop reacts with a debounced drain.
|
|
|
|
Envelopes are written by the AGENT process (``message_agent`` → ``tools.bot_relay.enqueue_envelope``) —
|
|
a different process that never touches this gateway's transports — so the files are the only shared
|
|
signal, exactly like the pairing store. See #92760, #93091.
|
|
"""
|
|
global _bot_relay_outbox_seen
|
|
home = _watcher_home()
|
|
root = home.parent.parent if home.parent.name == "profiles" else home
|
|
with contextlib.suppress(OSError):
|
|
for entry in (root / "bot_relay" / "outbox").iterdir():
|
|
if entry.name.endswith(".json"):
|
|
_bot_relay_outbox_seen = max(_bot_relay_outbox_seen, _watcher_mtime_ns(entry) or 0)
|
|
return _bot_relay_outbox_seen or None
|
|
|
|
|
|
# event → (check interval, signature fn, payload fn). Signatures are stat-cheap; the interval
|
|
# keeps pricier probes (pet resolves the sheet off disk) off the 0.5s tick. cron/jobs.json
|
|
# moves on edits AND scheduler ticks; gateway_state.json is where the messaging gateway
|
|
# persists platform connect/disconnect/health (the Messaging page's status signal).
|
|
_CHANGE_WATCHES: dict[str, tuple[float, Any, Any]] = {
|
|
"pet.changed": (2.0, _pet_sig, _pet_changed_payload),
|
|
"cron.changed": (1.0, lambda: _home_mtime_ns("cron", "jobs.json"), lambda: {}),
|
|
"sessions.changed": (0.5, _sessions_sig, lambda: {}),
|
|
# Projects created/switched by CLI or agent tooling write projects.db without any
|
|
# state.db movement, so sessions.changed never fires and the desktop Projects
|
|
# sidebar goes stale until a manual refresh (#56757).
|
|
"platforms.changed": (2.0, lambda: _home_mtime_ns("gateway_state.json"), lambda: {}),
|
|
"projects.changed": (2.0, _projects_sig, lambda: {}),
|
|
"pairing.changed": (2.0, _pairing_sig, lambda: {}),
|
|
# 1s so a queued DM envelope reaches the Desktop's push-triggered drain fast.
|
|
"bot_relay.outbox.pending": (1.0, _bot_relay_outbox_sig, lambda: {})}
|
|
|
|
# state.db moves on every append of a streaming turn and gateway_state.json on
|
|
# in-flight bookkeeping; the floor coalesces bursts to one broadcast per window,
|
|
# trailing edge included (a floored change keeps its old signature, re-fires later).
|
|
_CHANGE_BROADCAST_FLOOR_S = {"sessions.changed": 2.0, "platforms.changed": 5.0}
|
|
|
|
_change_sigs: dict[str, Any] = {}
|
|
_change_checked_at: dict[str, float] = {}
|
|
_change_broadcast_at: dict[str, float] = {}
|
|
|
|
|
|
def _broadcast_watched_changes(now: float | None = None) -> None:
|
|
"""One pass: recompute due signatures, broadcast events whose signature moved.
|
|
First sighting seeds silently so a gateway boot never fires a refresh storm."""
|
|
now = time.monotonic() if now is None else now
|
|
for event, (interval, sig_fn, payload_fn) in _CHANGE_WATCHES.items():
|
|
if now - _change_checked_at.get(event, -interval) < interval:
|
|
continue
|
|
_change_checked_at[event] = now
|
|
try:
|
|
sig = sig_fn()
|
|
except Exception: # noqa: BLE001 - a broken probe must not kill the loop
|
|
continue
|
|
if event not in _change_sigs:
|
|
_change_sigs[event] = sig
|
|
continue
|
|
floor = _CHANGE_BROADCAST_FLOOR_S.get(event, 0.0)
|
|
if sig == _change_sigs[event]:
|
|
continue
|
|
if floor and now - _change_broadcast_at.get(event, -floor) < floor:
|
|
continue # floored: old signature stays so it re-fires when the window opens
|
|
_change_sigs[event] = sig
|
|
_change_broadcast_at[event] = now
|
|
with contextlib.suppress(Exception):
|
|
_broadcast_global_event(event, payload_fn())
|
|
|
|
|
|
_skin_watcher_started = False
|
|
|
|
|
|
def _ensure_skin_watcher() -> None:
|
|
"""Start the process's one change watcher (named for its original skin-only duty): cheap
|
|
on-disk signatures → broadcast events, so changes go live without client polling. Idempotent."""
|
|
global _skin_watcher_started
|
|
if _skin_watcher_started:
|
|
return
|
|
_skin_watcher_started = True
|
|
_note_skin_broadcast() # seed the baseline so only a real change repaints
|
|
|
|
def _loop() -> None:
|
|
while True:
|
|
time.sleep(0.5)
|
|
_broadcast_skin_if_changed()
|
|
_broadcast_watched_changes()
|
|
threading.Thread(target=_loop, name="hermes-change-watcher", daemon=True).start()
|
|
|
|
|
|
def register(server) -> None:
|
|
"""Publish this module's helpers + handlers onto ``server``, rebound to its globals."""
|
|
bind_module(globals(), server, skip=("_",))
|