Newer
Older
navi-1 / navi / overlay / state.py
"""Process-wide overlay state — what was fetched, what applied, what failed.

One object, written under a lock by the fetch/apply path and read by /health,
the swarm announce payload, the notify endpoint and the admin panel. It is a
plain summary: the document itself lives in ``merged`` only for as long as the
process runs, and in the cache file between runs.
"""

from __future__ import annotations

import threading
from dataclasses import asdict, dataclass, field

# One refresh at a time. A hive notification and an announce hint can arrive
# together; stacking two applies would let an older document overwrite a newer
# one, and the second validate against a half-updated base.
lock = threading.Lock()


@dataclass
class OverlayState:
    configured: bool = False   # HIVE_URL + swarm key present
    enabled: bool = True       # HIVE_CONFIG_ENABLED and not NAVI_OVERLAY=off
    source: str = "local"      # local | hive | cache
    version: int = 0           # document version assigned by the hive
    hash: str = ""             # effective hash for this instance
    layers: list[str] = field(default_factory=list)
    status: str = "disabled"   # disabled|applied|staged|rejected|unreachable|not_targeted
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    unknown_fields: list[str] = field(default_factory=list)
    forbidden_fields: list[str] = field(default_factory=list)
    restart_required: list[str] = field(default_factory=list)
    hot_applied: list[str] = field(default_factory=list)
    last_fetch: str | None = None
    last_error: str | None = None
    boot_guard: bool = False

    # The validated, cleaned overlay currently in force. Sections:
    # settings / profiles / llm / mcp_servers. Each is the merge-patch to
    # apply over the *local* base, never over a previous overlay.
    merged: dict = field(default_factory=dict, repr=False)
    # Base settings and profiles as loaded from disk, captured once, so a
    # refresh can rebuild from the local config instead of stacking.
    base_settings: object | None = field(default=None, repr=False)


_state = OverlayState()


def get_state() -> OverlayState:
    return _state


def reset_state() -> None:
    """Tests only: forget everything, including the captured base."""
    global _state
    _state = OverlayState()


def status_dict() -> dict:
    data = asdict(_state)
    data.pop("merged", None)
    data.pop("base_settings", None)
    return data


def status_summary() -> dict:
    """Compact form for the swarm announce payload (stored in hive.meta)."""
    return {
        "version": _state.version,
        "hash": _state.hash,
        "status": _state.status,
        "restart_required": list(_state.restart_required),
        "hot_applied": list(_state.hot_applied),
        # Validation errors first (a rejected document), then the reason a
        # fetch failed — an "unreachable" machine that does not say why is
        # useless in /health and in the hive's status view.
        "error": (_state.errors[0] if _state.errors else None) or _state.last_error,
    }