Files
hermes-agent/plugins/plugin_storage.py
teknium1 576accd92b refactor(sqlite): one open_db/transaction layer for every small store; plugin DBs use the WAL fallback
Twelve modules each carried their own sqlite3.connect + PRAGMA + `with conn:`
stack. The #69567 fd-leak fix (a `with conn:` commits but never closes, so each
call leaked a connection and its WAL/SHM fds until GC) was pasted as code plus
docstring into six of them and hosted_room_policy_checkpoint never received
it; plugins/plugin_storage.plugin_db was the only production caller issuing a
raw `PRAGMA journal_mode=WAL`, bypassing the network-FS fallback, the
WAL-reset-bug gate and the never-live-downgrade invariant that
hermes_state_wal.apply_wal_with_fallback carries.

hermes_cli/sqlite_util.py (already home to add_column_if_missing/write_txn,
imported by cron, gateway and hermes_cli alike) gains `open_db(path, *,
db_label, busy_timeout_ms, wal, foreign_keys, synchronous_full, row_factory,
check_same_thread, wal_lock_retries, initialize)` and `transaction(conn,
immediate=)`; cron/ledger.py is deleted and hosted_rooms_common's
open_sqlite/connect/transaction become 1-3 line forwarders. Migrated:
agent/verification_evidence, cron/{executions,incidents,notepad,
delivery_queue}, gateway/{delivery_ledger,hosted_room_policy_checkpoint,
hosted_rooms_common (-> hosted_rooms, hosted_room_driver)}, hermes_cli/
projects_db, tools/async_delegation, plugins/plugin_storage.

Behavior changes (each module keeps its effective PRAGMA set otherwise):
- hosted_room_policy_checkpoint: connection now closed after every use and
  on init failure (was leaked per call), busy_timeout PRAGMA set explicitly.
- projects_db: gains busy_timeout=5000 (was the sqlite3 default 5 s connect
  timeout with no PRAGMA); explicit and observable.
- delivery_ledger / async_delegation: busy_timeout PRAGMA now mirrors the
  10 s connect timeout they already had.
- plugin_storage.plugin_db: WAL through apply_wal_with_fallback (DELETE on
  network filesystems / WAL-reset-vulnerable builds instead of raw WAL);
  busy_timeout=5000.
- cron/incidents._redact_error: redact_sensitive_text(force=True) — the
  error text is persisted to disk.
- delivery_ledger's private duplicate-column guard and the unguarded
  `ALTER TABLE ADD COLUMN` sites (shared_metrics, api_server_run_idempotency,
  holographic store, kanban model_override) go through add_column_if_missing.
- hermes_state.py::_scrub_surrogates: dead byte-copy of
  hermes_state_messages._scrub_surrogates (0 callers) deleted.
2026-09-13 05:08:29 -07:00

47 lines
2.1 KiB
Python

"""Per-plugin persistent storage: ``<hermes home>/plugin-data/<name>/``.
Plugins must NOT park state in ``<hermes home>/plugins/<name>/`` (the install dir, deleted by
``remove`` and git-pulled by ``update``). Secrets are deliberately NOT part of this convention —
credential reads go through ``agent.secret_scope`` / ``.env``.
Usage: ``plugin_data_dir("my-plugin") / "state.json"``; ``plugin_db("my-plugin")`` → ``data.db``.
"""
from __future__ import annotations
import re
import sqlite3
from pathlib import Path
__all__ = ["plugin_data_dir", "plugin_db"]
# Mirrors the plugin-name shape `hermes plugins install` accepts (no separators/traversal).
_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
def _validate_name(name: str) -> str:
if not _NAME_RE.fullmatch(name) or ".." in name:
raise ValueError(f"invalid plugin name for storage: {name!r}")
return name
def plugin_data_dir(name: str) -> Path:
"""Return (and create) ``<hermes home>/plugin-data/<name>/``; resolves ``get_hermes_home()`` on
every call so it follows the active profile — don't cache across profile switches."""
from hermes_constants import get_hermes_home
root = get_hermes_home() / "plugin-data" / _validate_name(name)
root.mkdir(parents=True, exist_ok=True)
return root
def plugin_db(name: str, filename: str = "data.db") -> sqlite3.Connection:
"""Open ``<data dir>/<filename>``. WAL so a dashboard reader and a tool writer coexist;
``check_same_thread=False`` for the threaded FastAPI/tool env — caller owns transactions."""
if Path(filename).name != filename or not filename:
raise ValueError(f"invalid plugin db filename: {filename!r}")
from hermes_cli.sqlite_util import open_db
# WAL via the shared fallback helper: network filesystems degrade to DELETE and WAL-reset-bug
# builds never enable it, instead of every plugin DB bypassing those rules with a raw PRAGMA.
return open_db(plugin_data_dir(name) / filename, db_label=f"plugin-data/{name}/{filename}",
foreign_keys=True, row_factory=None, check_same_thread=False)