From 3ef45508b205cc89b73528a7fb2a9f448324b469 Mon Sep 17 00:00:00 2001 From: Ethan Morchy Date: Sat, 8 Aug 2026 02:04:20 -0700 Subject: [PATCH] Let agents temporarily override native map layer visibility. --- backend/routers/ai_intel.py | 44 +++++++++++++++++++ backend/routers/data.py | 33 +++++++++++++-- backend/services/fetchers/_store.py | 66 ++++++++++++++++++++++++++++- frontend/src/app/page.tsx | 33 +++++++++++++-- 4 files changed, 168 insertions(+), 8 deletions(-) diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index 60ea84d..0220249 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -381,6 +381,50 @@ async def api_refresh_layer_feed(request: Request, layer_id: str): return {"ok": True, "layer": updated} +# --------------------------------------------------------------------------- +# Native map layer overrides — additive, transient, agent-driven. +# +# These are the DATA LAYERS toggles, not the pin layers above. An override +# switches an overlay on without touching the operator's own toggle state, and +# lapses on its own after the TTL. Agents holding an overlay open should re-PUT +# on their refresh tick rather than sending a long TTL. +# --------------------------------------------------------------------------- + +class LayerOverrideUpdate(BaseModel): + layers: dict[str, bool] + ttl_seconds: float = 300.0 + + +@router.put("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)]) +@limiter.limit("30/minute") +async def put_layer_overrides(request: Request, body: LayerOverrideUpdate): + """Replace the override map. Returns which keys were accepted and ignored.""" + from services.fetchers._store import set_layer_overrides + + accepted = set_layer_overrides(body.layers, body.ttl_seconds) + ignored = sorted(set(body.layers) - set(accepted)) + return {"ok": True, "overrides": accepted, "ignored": ignored} + + +@router.get("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)]) +@limiter.limit("60/minute") +async def read_layer_overrides(request: Request): + """Return the overrides that are currently live.""" + from services.fetchers._store import get_layer_overrides + + return {"ok": True, "overrides": get_layer_overrides()} + + +@router.delete("/api/ai/layer-overrides", dependencies=[Depends(require_openclaw_or_local)]) +@limiter.limit("30/minute") +async def delete_layer_overrides(request: Request): + """Drop all overrides, restoring the operator's own layer state.""" + from services.fetchers._store import clear_layer_overrides + + clear_layer_overrides() + return {"ok": True} + + # --------------------------------------------------------------------------- # Agent Actions endpoint — frontend polls this for UI commands from the agent # --------------------------------------------------------------------------- diff --git a/backend/routers/data.py b/backend/routers/data.py index 9ba0a16..9a5e131 100644 --- a/backend/routers/data.py +++ b/backend/routers/data.py @@ -566,6 +566,18 @@ def _run_prediction_markets_disable() -> None: logger.warning("Prediction markets disable cleanup failed: %s", e) +@router.get("/api/layers", dependencies=[Depends(require_local_operator)]) +async def get_layers(request: Request): + """Report operator layer state plus any live overrides. + + The UI polls this so the DATA LAYERS toggles can show an overlay that an + agent switched on. ``layers`` is the operator's own state and is what the + browser persists; ``overrides`` is transient and must not be saved. + """ + from services.fetchers._store import active_layers, get_layer_overrides + return {"layers": dict(active_layers), "overrides": get_layer_overrides()} + + @router.post("/api/layers", dependencies=[Depends(require_local_operator)]) @limiter.limit("30/minute") async def update_layers(update: LayerUpdate, request: Request): @@ -668,11 +680,15 @@ async def bootstrap_critical(request: Request): if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) from services.fetchers._store import ( - active_layers, + effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot, ) + # Rebind the local name to the override-merged map. Every layer filter + # below reads this name, so one merge here covers all of them. + active_layers = effective_layers() + def _build() -> dict: d = get_latest_data_subset_refs( "last_updated", "commercial_flights", "military_flights", "private_flights", @@ -769,14 +785,17 @@ def _try_build_fast_delta( ) -> dict | None: """Return a delta payload, or None to fall back to a full snapshot.""" from services.fetchers._store import ( - active_layers, compute_layer_row_delta, + effective_layers, get_data_version, get_layer_versions, get_latest_data_subset_refs, get_source_timestamps_snapshot, ) + # Rebind the local name to the override-merged map (see bootstrap_critical). + active_layers = effective_layers() + server_lv = get_layer_versions() deltas: dict[str, Any] = {} for key in _DELTA_FAST_KEYS: @@ -894,7 +913,10 @@ async def live_data_fast( etag = _current_etag(prefix=("fast|initial|" if initial else "fast|full|") + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else "")) if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) - from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot) + from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot) + + # Rebind the local name to the override-merged map (see bootstrap_critical). + active_layers = effective_layers() def _build() -> dict: d = get_latest_data_subset_refs( @@ -959,7 +981,10 @@ async def live_data_slow( etag = _current_etag(prefix="slow|full|" + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else "")) if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"}) - from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot) + from services.fetchers._store import (effective_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot) + + # Rebind the local name to the override-merged map (see bootstrap_critical). + active_layers = effective_layers() def _build() -> dict: d = get_latest_data_subset_refs( diff --git a/backend/services/fetchers/_store.py b/backend/services/fetchers/_store.py index 135dd8f..20a9c43 100644 --- a/backend/services/fetchers/_store.py +++ b/backend/services/fetchers/_store.py @@ -7,6 +7,7 @@ Every fetcher imports from here instead of maintaining its own copy. import copy import threading import logging +import time from datetime import datetime from typing import Any, Dict, List, Optional, TypedDict @@ -505,6 +506,69 @@ active_layers: dict[str, bool] = { } +# --------------------------------------------------------------------------- +# Layer overrides — additive, agent-driven, never persisted. +# An automation (e.g. a hotspot daemon) can switch an overlay on for a while +# without touching the operator's own toggles. Overrides merge ON TOP of +# active_layers for reads; active_layers itself is only ever written by the +# operator via POST /api/layers. The whole map shares one expiry, evaluated +# lazily on read so no background task is needed. When it lapses the operator's +# own view returns with no save/restore bookkeeping. +# --------------------------------------------------------------------------- +_MAX_OVERRIDE_TTL_S = 3600.0 + +layer_overrides: dict[str, bool] = {} +_layer_overrides_expires_at: float = 0.0 + + +def get_layer_overrides() -> dict[str, bool]: + """Return the live overrides, or {} once the TTL has lapsed.""" + global _layer_overrides_expires_at + if not layer_overrides: + return {} + if time.monotonic() >= _layer_overrides_expires_at: + layer_overrides.clear() + _layer_overrides_expires_at = 0.0 + bump_active_layers_version() + return {} + return dict(layer_overrides) + + +def set_layer_overrides(overrides: dict[str, bool], ttl_seconds: float) -> dict[str, bool]: + """Replace the override map, returning the entries that were accepted. + + Keys that are not real layers are dropped so a typo cannot silently do + nothing — the caller compares the return value against what it sent. + """ + global _layer_overrides_expires_at + ttl = max(0.0, min(float(ttl_seconds), _MAX_OVERRIDE_TTL_S)) + accepted = {k: bool(v) for k, v in overrides.items() if k in active_layers} + layer_overrides.clear() + layer_overrides.update(accepted) + _layer_overrides_expires_at = time.monotonic() + ttl if accepted else 0.0 + bump_active_layers_version() + return accepted + + +def clear_layer_overrides() -> None: + """Drop all overrides, restoring the operator's own layer state.""" + global _layer_overrides_expires_at + if layer_overrides: + layer_overrides.clear() + _layer_overrides_expires_at = 0.0 + bump_active_layers_version() + + +def effective_layers() -> dict[str, bool]: + """Operator layer state merged with any live overrides.""" + overrides = get_layer_overrides() + return {**active_layers, **overrides} if overrides else dict(active_layers) + + def is_any_active(*layer_names: str) -> bool: """Return True if any of the given layer names is currently active.""" - return any(active_layers.get(name, True) for name in layer_names) + overrides = get_layer_overrides() + return any( + overrides.get(name, active_layers.get(name, True)) + for name in layer_names + ) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index cbaf31e..95ed68a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -191,6 +191,9 @@ export default function Dashboard() { }, []); const [activeLayers, setActiveLayers] = useState(getDefaultActiveLayers); + // Backend-driven layer overrides. Additive on top of activeLayers, never + // persisted and never pushed back — the operator's own toggles stay theirs. + const [layerOverrides, setLayerOverrides] = useState>({}); const [activeStyle, setActiveStyle] = useState(getDefaultMapStyle); const [activeFilters, setActiveFilters] = useState>({}); const [layerPrefsHydrated, setLayerPrefsHydrated] = useState(false); @@ -323,6 +326,27 @@ export default function Dashboard() { }; }, [activeLayers, secondaryBootReady, layerPrefsHydrated]); + // Poll for backend layer overrides so an agent can switch an overlay on + // without the operator reloading. Overrides carry a TTL server-side, so a + // missed poll self-corrects and a dropped backend just lets them lapse. + useEffect(() => { + if (!secondaryBootReady) return; + let cancelled = false; + const pollOverrides = () => + fetch(`${API_BASE}/api/layers`) + .then((r) => r.json()) + .then((d) => { + if (!cancelled) setLayerOverrides(d?.overrides ?? {}); + }) + .catch(() => {}); + void pollOverrides(); + const id = setInterval(pollOverrides, 5000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [secondaryBootReady]); + // Left panel accordion state const [leftDataMinimized, setLeftDataMinimized] = useState(false); const [leftMeshExpanded, setLeftMeshExpanded] = useState(true); @@ -457,8 +481,10 @@ export default function Dashboard() { }); }; + // Overrides merge last so they win over both the operator's toggles and the + // first-paint suppressions below. const firstPaintActiveLayers = useMemo(() => { - if (secondaryBootReady) return activeLayers; + if (secondaryBootReady) return { ...activeLayers, ...layerOverrides }; return { ...activeLayers, cctv: false, @@ -472,8 +498,9 @@ export default function Dashboard() { tinygs: false, datacenters: false, power_plants: false, + ...layerOverrides, }; - }, [activeLayers, secondaryBootReady]); + }, [activeLayers, layerOverrides, secondaryBootReady]); // Agent fly_to handler (sar_focus_aoi etc.) — wired here now that // setFlyToLocation is in scope. show_image is routed through // useAgentActions at the top of Dashboard. @@ -587,7 +614,7 @@ export default function Dashboard() { {secondaryBootReady ? (