From cb3c5aec7e91a140ec16993e4553ee7871ab851a Mon Sep 17 00:00:00 2001 From: "Matt Johnson (via Claude)" Date: Fri, 5 Jun 2026 17:06:51 +0000 Subject: [PATCH] feat(v0.6-3a): adapter_config foundation -- migration + defaults registry + typed accessor Closes the foundation slice of audit doc Section A (Rule 17). Lands two SQLite tables, the seed routine that populates them from a Python defaults registry, and a typed accessor that handler code will read in v0.6-3b. No handler changes in this commit -- ZERO behavior risk, every existing test still passes (721 / 69 skipped / 0 failed). v6.sql tables: - adapter_config(adapter, key, value_json, default_json, type, description, updated_at) PRIMARY KEY(adapter, key) -- JSON-encoded values flow through a single column uniformly. CHECK constraint on `type` closes the vocab (int/float/str/ bool/json). - adapter_meta(adapter PK, display_name, include_in_llm_context, description, updated_at) -- per-adapter metadata + the user-scopable LLM-context toggle (Matt refinement #5). meshai/adapter_config/ package: - defaults.py: REGISTRY dict mapping (adapter, key) -> {default, type, description}. Covers audit doc sections A.1-A.12: wfigs, nws, usgs_quake, swpc, usgs_nwis, incident family (tomtom_incidents, state_511_atis, itd_511, shared "incident"), central consumer, dispatcher, band_conditions, geocoder, firms, pipeline (Inhibitor + Grouper). ~85 keys total. ADAPTER_META covers 15 adapters with display_name + include_in_llm_context defaulting to True. Per Matt refinement #3, every default matches the current handler constant EXACTLY -- first deploy behavior is unchanged. - _accessor.py: AdapterConfig class with `adapter_config..` syntax. Read pipeline: in-memory cache hit -> SQL -> registry fallback (with WARNING) -> AttributeError. Process-wide cache; PUT via v0.6-3c REST API calls invalidate_cache() to drop the cache. GIL-atomic dict reads on the fast path (handlers call this hot). - __init__.py: seed_defaults(conn) -- INSERT OR IGNOREs one row per registry entry. Idempotent, never overwrites user edits. Wiring: - meshai/persistence/db.py: SCHEMA_VERSION 5 -> 6, and init_db() now calls seed_defaults() after migrations apply. - meshai/main.py: _init_components() now calls init_db() FIRST (per commit #1 lessons-learned: a startup-time migration is required when handlers will rely on the new schema; lazy-on-first-handler is fine for v4/v5 but not for v6 where handler reads start in v0.6-3b). - tests/conftest.py: autouse fixture now calls init_db() + clears the accessor cache around each test, so every test gets the v6 seed AND a clean cache without per-test boilerplate. Tests (tests/test_adapter_config_foundation.py, 24 cases): - v6 tables exist + schema_meta at 6 + type-vocabulary CHECK enforced - seed populates every REGISTRY + ADAPTER_META row, value_json == default_json on first seed, type matches - seed is idempotent + does not overwrite user edits - accessor returns correctly typed values for int/float/str/bool/ json list/json dict/json None - cache hit: second read does not touch the DB (patched _load_from_db raises, accessor still succeeds) - invalidate_cache forces a re-read; mutated DB value wins - registry fallback path triggers when a row is missing (with WARNING) - unknown key raises AttributeError - setattr blocked (writes go via the REST API in 3c) - every default JSON round-trips cleanly; every type is in vocabulary - ADAPTER_META covers every adapter in REGISTRY Test count: 697 -> 721 (+24 new, 0 regressions). v0.6-3b will wire handlers one at a time (wfigs, nws, quake, swpc, nwis, incident, central, dispatcher, band_conditions, geocoder, firms). Per the audit lock, defaults match exactly so each wiring step is a pure refactor -- bisect-safe. v0.6-3c lands the /api/adapter-config CRUD + the AdapterConfig.tsx dashboard editor + cache invalidation on PUT. Refs audit doc v0.6-phase1-audit.md Section A + finding #4. --- meshai/adapter_config/__init__.py | 91 +++ meshai/adapter_config/_accessor.py | 182 ++++++ meshai/adapter_config/defaults.py | 711 ++++++++++++++++++++++++ meshai/main.py | 10 + meshai/persistence/db.py | 16 +- meshai/persistence/migrations/v6.sql | 42 ++ tests/conftest.py | 23 +- tests/test_adapter_config_foundation.py | 289 ++++++++++ 8 files changed, 1360 insertions(+), 4 deletions(-) create mode 100644 meshai/adapter_config/__init__.py create mode 100644 meshai/adapter_config/_accessor.py create mode 100644 meshai/adapter_config/defaults.py create mode 100644 meshai/persistence/migrations/v6.sql create mode 100644 tests/test_adapter_config_foundation.py diff --git a/meshai/adapter_config/__init__.py b/meshai/adapter_config/__init__.py new file mode 100644 index 0000000..c255bf9 --- /dev/null +++ b/meshai/adapter_config/__init__.py @@ -0,0 +1,91 @@ +"""v0.6-3a meshai/adapter_config package. + +Public API: + from meshai.adapter_config import adapter_config, invalidate_cache, seed_defaults + +`adapter_config` is the typed accessor singleton. `invalidate_cache()` drops +the read-side cache (called by /api/adapter-config PUT in v0.6-3c). +`seed_defaults(conn)` is called from `meshai.persistence.db.init_db()` after +the migration runner finishes; it INSERT OR IGNOREs one row per registry +entry so first-deploy behavior matches every existing handler constant +exactly. +""" +from __future__ import annotations + +import json +import logging +import sqlite3 +import time +from typing import Any + +from meshai.adapter_config._accessor import ( + adapter_config, + invalidate_cache, +) +from meshai.adapter_config.defaults import ( + REGISTRY, + ADAPTER_META, + all_adapters, + registry_for, +) + +__all__ = [ + "adapter_config", + "invalidate_cache", + "seed_defaults", + "REGISTRY", + "ADAPTER_META", + "all_adapters", + "registry_for", +] + + +logger = logging.getLogger(__name__) + + +def seed_defaults(conn: sqlite3.Connection) -> tuple[int, int]: + """Populate adapter_config + adapter_meta from the defaults registry. + + Idempotent: INSERT OR IGNORE never overwrites a user-edited row. Run + after the v6 migration creates the tables; safe to re-run on every + init_db(). + + Returns: + (config_rows_inserted, meta_rows_inserted) -- 0/0 when fully seeded. + """ + now = time.time() + + # adapter_config rows. + cfg_inserted = 0 + for (adapter, key), spec in REGISTRY.items(): + default_json = json.dumps(spec["default"]) + cur = conn.execute( + "INSERT OR IGNORE INTO adapter_config(" + "adapter, key, value_json, default_json, type, description, updated_at) " + "VALUES (?,?,?,?,?,?,?)", + (adapter, key, default_json, default_json, + spec["type"], spec.get("description") or "", now), + ) + if cur.rowcount > 0: + cfg_inserted += 1 + + # adapter_meta rows. + meta_inserted = 0 + for adapter, meta in ADAPTER_META.items(): + cur = conn.execute( + "INSERT OR IGNORE INTO adapter_meta(" + "adapter, display_name, include_in_llm_context, description, updated_at) " + "VALUES (?,?,?,?,?)", + (adapter, meta.get("display_name") or adapter, + 1 if meta.get("include_in_llm_context", True) else 0, + meta.get("description") or "", now), + ) + if cur.rowcount > 0: + meta_inserted += 1 + + if cfg_inserted or meta_inserted: + logger.info( + "adapter_config: seed_defaults inserted %d config rows + %d meta rows", + cfg_inserted, meta_inserted, + ) + return cfg_inserted, meta_inserted diff --git a/meshai/adapter_config/_accessor.py b/meshai/adapter_config/_accessor.py new file mode 100644 index 0000000..77583c3 --- /dev/null +++ b/meshai/adapter_config/_accessor.py @@ -0,0 +1,182 @@ +"""v0.6-3a typed accessor over the adapter_config table. + +Handlers use the singleton `adapter_config` from this package: + + from meshai.adapter_config import adapter_config + + cooldown_s = adapter_config.wfigs.cooldown_seconds # int + severities = adapter_config.nws.broadcast_severities # list[str] + +Reads are dict-cached. The cache invalidates on `invalidate_cache()` +(called from the REST API's PUT handler in v0.6-3c). The cache is +per-process; meshai is single-process so there is no cross-process +coherence problem. + +Fallback ordering on read: + 1. In-memory cache hit -> return immediately. + 2. SQL `SELECT value_json, type FROM adapter_config WHERE adapter=? AND key=?` + -> decode + cache + return. + 3. Registry fallback (defaults.REGISTRY) -> return without caching, log + at WARNING level (this shouldn't happen post-seed but defends + against schema drift). + 4. Raise AttributeError -> the key is unknown to both DB and registry. +""" +from __future__ import annotations + +import json +import logging +import threading +from typing import Any + +logger = logging.getLogger(__name__) + + +# Process-wide cache. Reads use the GIL-atomic dict get/set; the lock +# guards multi-statement sequences (gen bump + clear). +_CACHE_LOCK = threading.Lock() +_cache: dict[tuple[str, str], Any] = {} + + +def invalidate_cache() -> None: + """Drop every cached value. Called by the REST API on PUT/reset.""" + with _CACHE_LOCK: + _cache.clear() + logger.debug("adapter_config: cache invalidated") + + +# ---------- internals ---------------------------------------------------- + + +def _decode(value_json: str, type_: str) -> Any: + """JSON-decoded value coerced to the declared Python type.""" + raw = json.loads(value_json) + if type_ == "int": + if raw is None: return None + return int(raw) + if type_ == "float": + if raw is None: return None + return float(raw) + if type_ == "str": + if raw is None: return None + return str(raw) + if type_ == "bool": + if raw is None: return None + return bool(raw) + if type_ == "json": + return raw + # Unknown tag -- return the JSON-decoded form and log. + logger.warning("adapter_config: unknown type tag %r; returning raw decoded", type_) + return raw + + +def _load_from_db(adapter: str, key: str) -> tuple[bool, Any]: + """Returns (found, value). found=False means no row exists.""" + try: + from meshai.persistence import get_db + conn = get_db() + row = conn.execute( + "SELECT value_json, type FROM adapter_config " + "WHERE adapter=? AND key=?", + (adapter, key), + ).fetchone() + except Exception: + logger.exception("adapter_config: DB read failed for %s.%s", adapter, key) + return (False, None) + if row is None: + return (False, None) + return (True, _decode(row["value_json"], row["type"])) + + +def _load_from_registry(adapter: str, key: str) -> tuple[bool, Any]: + """Returns (found, default_value) from defaults.REGISTRY.""" + from meshai.adapter_config.defaults import REGISTRY + spec = REGISTRY.get((adapter, key)) + if spec is None: + return (False, None) + return (True, spec["default"]) + + +# ---------- public accessor ---------------------------------------------- + + +class _AdapterSection: + """Returned by `adapter_config.`. Resolves attribute access + to the typed value via the read pipeline.""" + + __slots__ = ("_adapter",) + + def __init__(self, adapter: str): + object.__setattr__(self, "_adapter", adapter) + + def __getattr__(self, key: str) -> Any: + # Avoid recursion when Python probes for dunder attributes. + if key.startswith("__"): + raise AttributeError(key) + return _resolve(self._adapter, key) + + def __setattr__(self, key: str, value: Any) -> None: + raise AttributeError( + "adapter_config is read-only at the accessor level; " + "use the /api/adapter-config PUT endpoint to mutate." + ) + + def __repr__(self) -> str: + return f"" + + +class AdapterConfig: + """Singleton-style accessor: `adapter_config..`.""" + + __slots__ = () + + def __getattr__(self, adapter: str) -> _AdapterSection: + if adapter.startswith("__"): + raise AttributeError(adapter) + return _AdapterSection(adapter) + + def get(self, adapter: str, key: str) -> Any: + """Programmatic accessor that mirrors `..`.""" + return _resolve(adapter, key) + + def invalidate(self) -> None: + invalidate_cache() + + +def _resolve(adapter: str, key: str) -> Any: + """Read pipeline: cache -> DB -> registry.""" + cache_key = (adapter, key) + cached = _cache.get(cache_key, _SENTINEL) + if cached is not _SENTINEL: + return cached + + # DB. + found, value = _load_from_db(adapter, key) + if found: + with _CACHE_LOCK: + _cache[cache_key] = value + return value + + # Registry fallback. Don't cache this -- when the DB catches up + # (next seed_defaults / migration / write), we want the DB value to + # win without a manual invalidation. + found, default = _load_from_registry(adapter, key) + if found: + logger.warning( + "adapter_config: %s.%s missing from DB; using registry default. " + "(seed_defaults() should have populated this -- check init_db order.)", + adapter, key, + ) + return default + + raise AttributeError( + f"adapter_config: unknown key {adapter!r}.{key!r} " + f"(not in DB and not in defaults.REGISTRY)" + ) + + +# Sentinel to distinguish "cache miss" from "cache hit with value None". +_SENTINEL = object() + + +# The singleton instance used by handler code. +adapter_config = AdapterConfig() diff --git a/meshai/adapter_config/defaults.py b/meshai/adapter_config/defaults.py new file mode 100644 index 0000000..0f061a8 --- /dev/null +++ b/meshai/adapter_config/defaults.py @@ -0,0 +1,711 @@ +"""v0.6-3a single source-of-truth for adapter_config defaults. + +Every module-level magic-number constant flagged by the audit doc +(/mnt/c/Users/mtthw/OneDrive/Documents/Claude/Projects/meshai/v0.6-phase1-audit.md) +Section A.1 through A.12 lives here. The migration runs first (v6.sql +creates the empty tables); init_db() then calls seed_defaults() which +INSERT OR IGNOREs one row per (adapter, key) entry below, copying +value_json = default_json. + +**Behavior contract**: every default below MUST match the current +hardcoded value in the corresponding handler EXACTLY. v0.6-3b will +swap the handler's constant for an `adapter_config..` +read. Until that wiring lands, this registry is the documentation of +intent; nothing reads from it at runtime yet. + +Adding a new tunable in the future: + 1. Add an entry to REGISTRY below with default + type + description. + 2. The next container restart calls seed_defaults() which + INSERT OR IGNOREs the row. + 3. Wire the handler to read from adapter_config... +""" +from __future__ import annotations + +from typing import Any + + +# -------- REGISTRY -------------------------------------------------------- +# +# Schema: REGISTRY[(adapter, key)] = { +# "default": , # the seed value +# "type": "int" | "float" | "str" | "bool" | "json", +# "description": "...", +# } +# +# `json` type covers list, dict, and nested structures. Decoded as +# json.loads(value_json) by the accessor. +# +# Citations in comments point at the current source-of-truth in the +# handler code -- these MUST stay in lock-step until v0.6-3b lands. + +REGISTRY: dict[tuple[str, str], dict[str, Any]] = { + + # ================================================================= + # WFIGS -- central/wfigs_handler.py + # ================================================================= + ("wfigs", "cooldown_seconds"): { + "default": 28800, # central/wfigs_handler.py:43 (8*60*60) + "type": "int", + "description": "Per-fire broadcast cooldown in seconds (forward-only Update gate).", + }, + ("wfigs", "prefix_new"): { + "default": "New", + "type": "str", + "description": "Wire-string prefix for the first broadcast of a fire.", + }, + ("wfigs", "prefix_update"): { + "default": "Update", + "type": "str", + "description": "Wire-string prefix for subsequent Update broadcasts.", + }, + ("wfigs", "emoji"): { + "default": "đŸ”Ĩ", + "type": "str", + "description": "Wire-string lead emoji.", + }, + ("wfigs", "anchor_priority"): { + "default": ["geocoder_city", "nearest_town", "landclass", "county_state"], + "type": "json", + "description": "Ordered location-anchor fallback chain for the broadcast.", + }, + ("wfigs", "anchor_max_mi"): { + "default": 100.0, # central/wfigs_handler.py:322 (nearest_town max_distance_mi) + "type": "float", + "description": "Max distance (mi) for the nearest_town anchor fallback.", + }, + ("wfigs", "broadcast_on_acres"): { + "default": True, + "type": "bool", + "description": "Re-broadcast when acres increase (forward-only).", + }, + ("wfigs", "broadcast_on_contained"): { + "default": True, + "type": "bool", + "description": "Re-broadcast when containment percent increases (forward-only).", + }, + + # ================================================================= + # NWS -- central/nws_handler.py + # ================================================================= + ("nws", "broadcast_severities"): { + "default": ["Extreme", "Severe"], # nws_handler.py:43 + "type": "json", + "description": "CAP severity strings allowed onto the mesh.", + }, + ("nws", "tombstone_msgtypes"): { + "default": ["Cancel", "Expire"], # nws_handler.py:46 + "type": "json", + "description": "CAP msgType values that mark an alert as gone.", + }, + ("nws", "warning_suffix_promotes"): { + "default": True, # nws_handler.py:172 + "type": "bool", + "description": "Promote category-name-ending-in-_warning to Severe when CAP severity is missing.", + }, + ("nws", "event_emoji_map"): { + "default": [ # nws_handler.py:49-68 (ordered substring matches) + ["tornado", "đŸŒĒī¸"], + ["severe thunderstorm", "đŸŒŠī¸"], + ["thunderstorm", "đŸŒŠī¸"], + ["flash flood", "🌊"], + ["flood", "🌊"], + ["winter storm", "â„ī¸"], + ["blizzard", "â„ī¸"], + ["ice storm", "â„ī¸"], + ["ice", "â„ī¸"], + ["excessive heat", "đŸŒĄī¸"], + ["heat", "đŸŒĄī¸"], + ["high wind", "đŸŒŦī¸"], + ["wind", "đŸŒŦī¸"], + ["fire weather", "đŸ”Ĩ"], + ["red flag", "đŸ”Ĩ"], + ["air quality", "😷"], + ["freeze", "đŸĨļ"], + ["frost", "đŸĨļ"], + ], + "type": "json", + "description": "Ordered [substring, emoji] pairs; first case-insensitive substring match wins.", + }, + ("nws", "expires_short_thresholds_s"): { + "default": [21600, 604800], # nws_handler.py:109-113 (6h, 7d) + "type": "json", + "description": "[under_threshold_s, weekly_threshold_s] for 'until