diff --git a/work/dashboard-frontend/src/pages/Config.tsx b/work/dashboard-frontend/src/pages/Config.tsx index 8f08c7d..336bc9c 100644 --- a/work/dashboard-frontend/src/pages/Config.tsx +++ b/work/dashboard-frontend/src/pages/Config.tsx @@ -918,11 +918,11 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d: here as general context knobs. */}
onChange({ ...data, max_age: v })} - min={0} - helper="Ignore messages older than this" + label="Chat context retention (days)" + value={Math.round((data.max_age ?? 1209600) / 86400)} + onChange={(v) => onChange({ ...data, max_age: v * 86400 })} + min={1} + helper="How long the bot remembers recent channel chat for context (applies to both meshes). Default 14 days." /> None: + """Apply config changes to the live store without a restart. + + Mirrors the normalization rules of __init__ exactly: + - observe_channels: set(v) if v else None (empty list → None = observe all) + - ignore_nodes: set(v) if v else set() (empty list/None → empty set) + - max_age: stored as-is + + Only parameters that are not None are updated; omit a parameter to + leave its current value unchanged. + """ + if max_age is not None: + self._max_age = max_age + if observe_channels is not None: + self._observe_channels = set(observe_channels) if observe_channels else None + if ignore_nodes is not None: + self._ignore_nodes = set(ignore_nodes) if ignore_nodes else set() + @property def count(self) -> int: """Number of observations in buffer.""" diff --git a/work/meshai/dashboard/api/config_routes.py b/work/meshai/dashboard/api/config_routes.py index af85c64..660983b 100644 --- a/work/meshai/dashboard/api/config_routes.py +++ b/work/meshai/dashboard/api/config_routes.py @@ -172,6 +172,8 @@ async def update_config_section(section: str, request: Request): setattr(request.app.state.config, section, new_value) except Exception: pass + if section == "context": + _refresh_mesh_context(request.app, new_value) logger.info( "Config section %r updated, restart_required=%s changed_keys=%s", @@ -251,6 +253,26 @@ def _refresh_toggle_filter(app) -> bool: return False +def _refresh_mesh_context(app, new_ctx_cfg) -> bool: + """Best-effort live refresh of the running MeshContext after a context + config PUT. Returns True when the refresh actually fired, False if the + context instance is absent/None (passive context disabled, or early + startup). Never raises.""" + try: + ctx = getattr(app.state, "mesh_context", None) + if ctx is None: + return False + ctx.update_settings( + max_age=new_ctx_cfg.max_age, + observe_channels=new_ctx_cfg.observe_channels, + ignore_nodes=new_ctx_cfg.ignore_nodes, + ) + return True + except Exception: + logger.exception("mesh_context refresh failed") + return False + + @router.post("/notifications/refresh-toggles") async def refresh_toggles(request: Request): """Explicit refresh endpoint (kept for backwards-compat with the diff --git a/work/meshai/dashboard/server.py b/work/meshai/dashboard/server.py index 0f4a16c..071926d 100644 --- a/work/meshai/dashboard/server.py +++ b/work/meshai/dashboard/server.py @@ -123,6 +123,7 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster: app.state.connector = meshai_instance.connector app.state.bus = getattr(meshai_instance, "event_bus", None) app.state.danger_correlator = getattr(meshai_instance, "danger_correlator", None) + app.state.mesh_context = getattr(meshai_instance, "context", None) # Create broadcaster and attach to app state broadcaster = DashboardBroadcaster() diff --git a/work/tests/test_context_hot_reload.py b/work/tests/test_context_hot_reload.py new file mode 100644 index 0000000..3e00ba6 --- /dev/null +++ b/work/tests/test_context_hot_reload.py @@ -0,0 +1,125 @@ +"""Tests for MeshContext.update_settings — live hot-reload without restart. + +Covers: + - update_settings applies max_age, observe_channels, ignore_nodes in place. + - Normalization mirrors __init__ exactly (empty list → None for + observe_channels; empty list/None → set() for ignore_nodes). + - An observation older than a newly-shortened max_age is pruned and + subsequently excluded by get_context_block. + - Omitting a parameter leaves the current value unchanged. +""" + +import time + +from meshai.context import MeshContext, MeshObservation + + +def _make_context(**kwargs) -> MeshContext: + return MeshContext(**kwargs) + + +class TestUpdateSettingsNormalization: + """update_settings mirrors __init__ normalization rules.""" + + def test_max_age_updated(self): + ctx = _make_context(max_age=3600) + ctx.update_settings(max_age=7200) + assert ctx._max_age == 7200 + + def test_observe_channels_non_empty_becomes_set(self): + ctx = _make_context() + ctx.update_settings(observe_channels=[1, 2, 3]) + assert ctx._observe_channels == {1, 2, 3} + + def test_observe_channels_empty_list_becomes_none(self): + """Empty list → None (observe all), mirroring constructor behaviour.""" + ctx = _make_context(observe_channels=[1, 2]) + ctx.update_settings(observe_channels=[]) + assert ctx._observe_channels is None + + def test_observe_channels_none_arg_leaves_unchanged(self): + """Passing None as argument leaves _observe_channels unchanged.""" + ctx = _make_context(observe_channels=[5]) + ctx.update_settings(observe_channels=None) + assert ctx._observe_channels == {5} + + def test_ignore_nodes_non_empty_becomes_set(self): + ctx = _make_context() + ctx.update_settings(ignore_nodes=["!abc", "!def"]) + assert ctx._ignore_nodes == {"!abc", "!def"} + + def test_ignore_nodes_empty_list_becomes_empty_set(self): + """Empty list → set(), mirroring constructor behaviour.""" + ctx = _make_context(ignore_nodes=["!abc"]) + ctx.update_settings(ignore_nodes=[]) + assert ctx._ignore_nodes == set() + + def test_ignore_nodes_none_arg_leaves_unchanged(self): + """Passing None as argument leaves _ignore_nodes unchanged.""" + ctx = _make_context(ignore_nodes=["!abc"]) + ctx.update_settings(ignore_nodes=None) + assert ctx._ignore_nodes == {"!abc"} + + def test_omitted_params_unchanged(self): + """Only supplied params are modified; others stay as-is.""" + ctx = _make_context(max_age=100, observe_channels=[3], ignore_nodes=["!x"]) + ctx.update_settings(max_age=999) + assert ctx._max_age == 999 + assert ctx._observe_channels == {3} + assert ctx._ignore_nodes == {"!x"} + + +class TestPruneAfterMaxAgeShortened: + """After update_settings shortens max_age, prune() removes old entries + and get_context_block returns only the surviving observations.""" + + def _insert_obs(self, ctx: MeshContext, timestamp: float, text: str = "msg"): + """Bypass observe() to inject an observation with an arbitrary timestamp.""" + obs = MeshObservation( + timestamp=timestamp, + sender_name="TestNode", + sender_id="!test1", + channel=0, + is_dm=False, + text=text, + ) + ctx._buffer.append(obs) + + def test_old_observation_pruned_after_max_age_shortened(self): + now = time.time() + + # Start with a generous max_age (1 hour). + ctx = _make_context(max_age=3600) + + # Insert one observation that is 30 minutes old. + self._insert_obs(ctx, now - 1800, text="old message") + assert ctx.count == 1 + assert "old message" in ctx.get_context_block() + + # Shorten max_age to 10 minutes — the 30-minute-old observation is now stale. + ctx.update_settings(max_age=600) + assert ctx._max_age == 600 + + # prune() is the documented mechanism to remove expired observations. + pruned = ctx.prune() + assert pruned == 1 + assert ctx.count == 0 + assert ctx.get_context_block() == "" + + def test_recent_observation_survives_after_max_age_shortened(self): + now = time.time() + + ctx = _make_context(max_age=3600) + + # Insert one old and one recent observation. + self._insert_obs(ctx, now - 1800, text="old message") + self._insert_obs(ctx, now - 60, text="recent message") + + # Shorten max_age to 10 minutes — only the old one is stale. + ctx.update_settings(max_age=600) + ctx.prune() + + assert ctx.count == 1 + block = ctx.get_context_block() + assert "recent message" in block + assert "old message" not in block