mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor(plugins): the display publishes plugin runtime state; retire plugin_state.json (#690)
Stage 2 of the web plugin catalog, after #688. - The display publishes a plugin runtime snapshot (plugin_runtime.py) to the shared cache: per plugin loaded, lifecycle state, a short redacted error summary, the version it loaded and when, plus published_at / stale_after / running. Written on change (throttled to 10 s; the RUNNING/ENABLED flip of an ordinary update is not a change) and once a minute otherwise; cleanup() publishes running: false. - The web reads it back and restores loaded / state / error_info in /api/v3/plugins/installed (plus loaded_version, loaded_at and data.runtime). Only a live snapshot counts; stale, stopped or missing answers null and says which. - data/plugin_state.json is retired: every reader and writer moved to config + disk (desired) or the snapshot (observed). Nothing in it was non-derivable, so nothing is migrated and an existing file is left unread. The web-side PluginStateManager (state_manager.py) is removed; the display's plugin_state.PluginStateManager is the only state machine. - StateReconciliation compares config + disk with the snapshot, reporting enabled-but-not-loaded and older-version-loaded as no_action findings. - Backups list installed manifests with enabled from config.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -522,7 +522,13 @@ class PluginManager:
|
||||
raise
|
||||
else:
|
||||
self.state_manager.set_state(plugin_id, PluginState.DISABLED)
|
||||
|
||||
|
||||
# The version this instance runs, for the runtime snapshot the
|
||||
# web UI reads: the manifest on disk can move on after an update.
|
||||
version = manifest.get('version')
|
||||
self.state_manager.record_loaded(
|
||||
plugin_id, version if isinstance(version, str) else None)
|
||||
|
||||
self.logger.info("Loaded plugin: %s", plugin_id)
|
||||
|
||||
return True
|
||||
@@ -774,6 +780,9 @@ class PluginManager:
|
||||
except Exception as e:
|
||||
self.logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
if plugin_id not in self.plugins:
|
||||
# Failed after the instance was dropped: it is not loaded.
|
||||
self.state_manager.record_unloaded(plugin_id)
|
||||
return False
|
||||
|
||||
def reload_plugin(self, plugin_id: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""The display's plugin runtime snapshot, shared with the web interface.
|
||||
|
||||
Only the display process runs plugins, so only it knows which ones it has
|
||||
loaded, where each is in its lifecycle (``plugin_state.PluginStateManager``),
|
||||
why one failed and which version it is running. It publishes that to the
|
||||
shared cache directory -- the channel, and the file permissions, that the
|
||||
error snapshot, plugin health and ``display_current_state`` already use --
|
||||
and the web interface reads it back for ``/api/v3/plugins/installed``,
|
||||
``/api/v3/plugins/state`` and state reconciliation.
|
||||
|
||||
PLUGIN_RUNTIME_KEY written by the display service only
|
||||
|
||||
Writes. The cache lives on disk, usually the SD card, so the snapshot is
|
||||
written when something a reader would see changes, at most once every
|
||||
``MIN_INTERVAL`` seconds, and otherwise once every ``REFRESH_INTERVAL``
|
||||
seconds as a heartbeat. An ordinary plugin update is not a change: the
|
||||
RUNNING state it passes through is published as ENABLED
|
||||
(``plugin_state.published_state``). A display with nothing changing writes
|
||||
this one small file once a minute.
|
||||
|
||||
Staleness. Every snapshot carries ``published_at`` (wall clock) and
|
||||
``stale_after``. A reader treats a snapshot older than that as unknown, not
|
||||
as the truth: a display that died without cleaning up leaves its last
|
||||
snapshot behind. A display that stops cleanly publishes ``running: false``
|
||||
on the way out, so readers see "stopped" at once rather than after the
|
||||
stale window. Nothing on the reading side reports a runtime fact from a
|
||||
snapshot that is not live.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
PLUGIN_RUNTIME_KEY = "plugin_runtime_snapshot"
|
||||
SNAPSHOT_SCHEMA = 1
|
||||
|
||||
#: Shortest gap, in seconds, between two change-driven writes. Startup loads
|
||||
#: every plugin in a burst, and a plugin failing each update cycle changes its
|
||||
#: error info each time; either is written at most this often.
|
||||
MIN_INTERVAL = 10.0
|
||||
|
||||
#: An unchanged snapshot is rewritten this often so readers can tell a quiet
|
||||
#: display from a dead one.
|
||||
REFRESH_INTERVAL = 60.0
|
||||
|
||||
#: How often the publisher thread looks for changes: an in-memory comparison.
|
||||
TICK_INTERVAL = 5.0
|
||||
|
||||
#: A snapshot older than this is stale: three missed refreshes.
|
||||
STALE_AFTER = 3 * REFRESH_INTERVAL
|
||||
|
||||
#: Bounds on a published ``stale_after``, so a corrupt value can make a
|
||||
#: reader neither trust a dead display for hours nor distrust a live one.
|
||||
_STALE_AFTER_MIN = 30.0
|
||||
_STALE_AFTER_MAX = 3600.0
|
||||
|
||||
_ERROR_MESSAGE_CHARS = 200
|
||||
_ERROR_TYPE_CHARS = 80
|
||||
_ID_CHARS = 100
|
||||
_VERSION_CHARS = 40
|
||||
|
||||
#: Reader statuses. Only LIVE carries runtime facts.
|
||||
LIVE = "live"
|
||||
STALE = "stale"
|
||||
STOPPED = "stopped"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
def _clip(value: Any, limit: int) -> str:
|
||||
text = value if isinstance(value, str) else str(value)
|
||||
return text if len(text) <= limit else text[:limit - 3] + "..."
|
||||
|
||||
|
||||
def _epoch(value: Any) -> Optional[float]:
|
||||
"""Seconds since the epoch for a float or a datetime; None otherwise."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
number = float(value)
|
||||
return number if math.isfinite(number) else None
|
||||
timestamp = getattr(value, "timestamp", None)
|
||||
if callable(timestamp):
|
||||
try:
|
||||
number = float(timestamp())
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
return number if math.isfinite(number) else None
|
||||
return None
|
||||
|
||||
|
||||
def summarize_error(error_info: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""A short, redacted summary of the state machine's error info.
|
||||
|
||||
``message`` is redacted before it is clipped: clipping first could cut a
|
||||
``token=`` marker off and keep the secret after it. No stack trace: the
|
||||
full error, with its trace, is in the error snapshot (/api/v3/errors).
|
||||
"""
|
||||
if not isinstance(error_info, dict):
|
||||
return None
|
||||
message = error_info.get("error")
|
||||
error_type = error_info.get("error_type")
|
||||
return {
|
||||
"type": _clip(error_type, _ERROR_TYPE_CHARS) if error_type else None,
|
||||
"message": _clip(redact_credentials(message if isinstance(message, str)
|
||||
else str(message or "")),
|
||||
_ERROR_MESSAGE_CHARS),
|
||||
"at": _epoch(error_info.get("timestamp")),
|
||||
"recoverable": bool(error_info.get("recoverable", False)),
|
||||
}
|
||||
|
||||
|
||||
def build_runtime_snapshot(state_manager: Any, *, started_at: float,
|
||||
now: Optional[float] = None,
|
||||
running: bool = True) -> Dict[str, Any]:
|
||||
"""The snapshot for ``state_manager`` (a plugin_state.PluginStateManager).
|
||||
|
||||
A stopped snapshot (``running=False``) lists no plugins: nothing is
|
||||
loaded once the display has gone.
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
if running:
|
||||
for plugin_id, record in state_manager.runtime_records().items():
|
||||
version = record.get("version")
|
||||
plugins[_clip(plugin_id, _ID_CHARS)] = {
|
||||
"loaded": bool(record.get("loaded")),
|
||||
"state": record.get("state"),
|
||||
"error": summarize_error(record.get("error_info")),
|
||||
"version": _clip(version, _VERSION_CHARS) if version else None,
|
||||
"loaded_at": _epoch(record.get("loaded_at")),
|
||||
}
|
||||
return {
|
||||
"schema": SNAPSHOT_SCHEMA,
|
||||
"running": running,
|
||||
"published_at": time.time() if now is None else now,
|
||||
"started_at": started_at,
|
||||
"refresh_interval": REFRESH_INTERVAL,
|
||||
"stale_after": STALE_AFTER,
|
||||
"pid": os.getpid(),
|
||||
"plugins": plugins,
|
||||
}
|
||||
|
||||
|
||||
class PluginRuntimePublisher:
|
||||
"""Publishes the display's plugin state machine to the shared cache.
|
||||
|
||||
Runs in the display service only. tick() is the whole job; start() calls
|
||||
it from a daemon thread every TICK_INTERVAL seconds. Nothing here raises:
|
||||
a failed write is logged at debug and retried on a later tick, at the
|
||||
throttled rate.
|
||||
"""
|
||||
|
||||
def __init__(self, cache_manager: Any, state_manager: Any,
|
||||
min_interval: float = MIN_INTERVAL,
|
||||
refresh_interval: float = REFRESH_INTERVAL,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
wall_clock: Callable[[], float] = time.time) -> None:
|
||||
self.cache_manager = cache_manager
|
||||
self.state_manager = state_manager
|
||||
self.min_interval = min_interval
|
||||
self.refresh_interval = refresh_interval
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self.started_at = wall_clock()
|
||||
# None forces a first publish, which replaces whatever a previous run
|
||||
# of the service left behind.
|
||||
self._published_change: Optional[int] = None
|
||||
self._last_attempt: Optional[float] = None
|
||||
self._tick_lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
def _write(self, running: bool) -> None:
|
||||
snapshot = build_runtime_snapshot(self.state_manager, started_at=self.started_at,
|
||||
now=self._wall_clock(), running=running)
|
||||
self.cache_manager.set(PLUGIN_RUNTIME_KEY, snapshot)
|
||||
|
||||
def tick(self) -> bool:
|
||||
"""Publish if something changed (throttled) or the refresh is due.
|
||||
True if a snapshot was written."""
|
||||
with self._tick_lock:
|
||||
try:
|
||||
change = self.state_manager.change_count
|
||||
now = self._clock()
|
||||
since = None if self._last_attempt is None else now - self._last_attempt
|
||||
if since is not None:
|
||||
if change == self._published_change:
|
||||
if since < self.refresh_interval:
|
||||
return False
|
||||
elif since < self.min_interval:
|
||||
return False
|
||||
# Stamp the attempt before writing: a cache that keeps failing
|
||||
# is retried at the throttled rate, not on every tick.
|
||||
self._last_attempt = now
|
||||
self._write(running=True)
|
||||
self._published_change = change
|
||||
return True
|
||||
except Exception as err: # never let reporting break the display
|
||||
logger.debug("Could not publish the plugin runtime snapshot: %s",
|
||||
err, exc_info=True)
|
||||
return False
|
||||
|
||||
def start(self, interval: float = TICK_INTERVAL) -> None:
|
||||
"""Tick from a daemon thread until stop(). A no-op while running."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
|
||||
def run() -> None:
|
||||
self.tick()
|
||||
while not self._stop.wait(interval):
|
||||
self.tick()
|
||||
|
||||
self._thread = threading.Thread(target=run, name="plugin-runtime-publisher",
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, publish_stopped: bool = True) -> None:
|
||||
"""Stop ticking and, by default, publish ``running: false`` so readers
|
||||
see the display as stopped now rather than after the stale window."""
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2)
|
||||
self._thread = None
|
||||
if publish_stopped:
|
||||
with self._tick_lock:
|
||||
try:
|
||||
self._write(running=False)
|
||||
except Exception as err:
|
||||
logger.debug("Could not publish the stopped plugin runtime snapshot: %s",
|
||||
err, exc_info=True)
|
||||
|
||||
|
||||
def start_plugin_runtime_publisher(cache_manager: Any,
|
||||
state_manager: Any) -> Optional[PluginRuntimePublisher]:
|
||||
"""Start publishing the display's plugin runtime state. Display service
|
||||
only: whichever process calls it becomes the source readers trust.
|
||||
Never raises."""
|
||||
try:
|
||||
publisher = PluginRuntimePublisher(cache_manager, state_manager)
|
||||
publisher.start()
|
||||
return publisher
|
||||
except Exception as err:
|
||||
logger.warning("Plugin runtime reporting to the web interface is unavailable: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
# --- Reading side (web interface) -------------------------------------------
|
||||
|
||||
#: What a reader reports for a plugin when it does not know.
|
||||
_UNKNOWN_PLUGIN: Dict[str, Any] = {
|
||||
"loaded": None,
|
||||
"state": None,
|
||||
"error_info": None,
|
||||
"loaded_version": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
#: A plugin a live snapshot does not list: the display has not loaded it
|
||||
#: (never enabled, or unloaded since), which is what its state machine
|
||||
#: reports for an id it has no record of.
|
||||
_NOT_LOADED_PLUGIN: Dict[str, Any] = {
|
||||
"loaded": False,
|
||||
"state": "unloaded",
|
||||
"error_info": None,
|
||||
"loaded_version": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginRuntimeView:
|
||||
"""What a reader may say about the display's plugins right now.
|
||||
|
||||
``status``: ``live`` (a fresh snapshot from a running display),
|
||||
``stale`` (the last snapshot is older than its ``stale_after``: the
|
||||
display is hung or died without cleaning up), ``stopped`` (the display
|
||||
said so on its way out) or ``unknown`` (no readable snapshot). Only a
|
||||
live view reports per-plugin facts; every other status answers None for
|
||||
them, so a caller cannot pass stale truth on by accident.
|
||||
"""
|
||||
|
||||
status: str
|
||||
published_at: Optional[float] = None
|
||||
age_seconds: Optional[float] = None
|
||||
stale_after: float = STALE_AFTER
|
||||
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self.status == LIVE
|
||||
|
||||
def plugin(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""``loaded``, ``state``, ``error_info``, ``loaded_version`` and
|
||||
``loaded_at`` for one plugin; all None unless the view is live."""
|
||||
if not self.live:
|
||||
return dict(_UNKNOWN_PLUGIN)
|
||||
record = self.plugins.get(plugin_id)
|
||||
if not isinstance(record, dict):
|
||||
return dict(_NOT_LOADED_PLUGIN)
|
||||
error = record.get("error")
|
||||
return {
|
||||
"loaded": bool(record.get("loaded")),
|
||||
"state": record.get("state") if isinstance(record.get("state"), str) else None,
|
||||
"error_info": dict(error) if isinstance(error, dict) else None,
|
||||
"loaded_version": record.get("version"),
|
||||
"loaded_at": record.get("loaded_at"),
|
||||
}
|
||||
|
||||
def describe(self) -> Dict[str, Any]:
|
||||
"""The view's own status, for a response to carry beside the facts."""
|
||||
return {
|
||||
"status": self.status,
|
||||
"published_at": self.published_at,
|
||||
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
|
||||
"stale_after": self.stale_after,
|
||||
}
|
||||
|
||||
|
||||
def _stale_after_of(snapshot: Dict[str, Any]) -> float:
|
||||
value = snapshot.get("stale_after")
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return STALE_AFTER
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
return STALE_AFTER
|
||||
return min(max(number, _STALE_AFTER_MIN), _STALE_AFTER_MAX)
|
||||
|
||||
|
||||
def view_from_snapshot(snapshot: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
||||
"""Judge a snapshot read from the cache; never raises."""
|
||||
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
published_at = _epoch(snapshot.get("published_at"))
|
||||
if published_at is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
stale_after = _stale_after_of(snapshot)
|
||||
age = (time.time() if now is None else now) - published_at
|
||||
if snapshot.get("running") is not True:
|
||||
return PluginRuntimeView(status=STOPPED, published_at=published_at,
|
||||
age_seconds=max(age, 0.0), stale_after=stale_after)
|
||||
# A snapshot from the future is trusted a little: the Pi has no RTC and
|
||||
# its clock steps when NTP syncs. Far in the future, it cannot be dated.
|
||||
if age > stale_after or age < -stale_after:
|
||||
return PluginRuntimeView(status=STALE, published_at=published_at,
|
||||
age_seconds=age, stale_after=stale_after)
|
||||
plugins = snapshot.get("plugins")
|
||||
return PluginRuntimeView(
|
||||
status=LIVE, published_at=published_at, age_seconds=max(age, 0.0),
|
||||
stale_after=stale_after,
|
||||
plugins={k: v for k, v in plugins.items() if isinstance(v, dict)}
|
||||
if isinstance(plugins, dict) else {},
|
||||
)
|
||||
|
||||
|
||||
def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
||||
"""The display's latest snapshot, judged for staleness. Never raises; a
|
||||
missing cache manager or an unreadable snapshot is ``unknown``.
|
||||
|
||||
memory_ttl=0: the key is written by the other process, so only the file
|
||||
is current.
|
||||
"""
|
||||
if cache_manager is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
try:
|
||||
snapshot = cache_manager.get(PLUGIN_RUNTIME_KEY, max_age=None, memory_ttl=0)
|
||||
except Exception as err:
|
||||
logger.debug("Could not read the plugin runtime snapshot: %s", err, exc_info=True)
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
return view_from_snapshot(snapshot, now=now)
|
||||
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Plugin State Management
|
||||
|
||||
Manages plugin state machine (loaded → enabled → running → error)
|
||||
with state transitions and queries.
|
||||
The display process's plugin state machine (loaded → enabled → running →
|
||||
error), with state transitions and queries. It is the only record of plugin
|
||||
lifecycle state: the web process runs no plugins, and reads this state as the
|
||||
snapshot ``plugin_runtime.PluginRuntimePublisher`` publishes from it.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -24,8 +27,27 @@ class PluginState(Enum):
|
||||
DISABLED = "disabled" # Plugin is disabled in config
|
||||
|
||||
|
||||
def published_state(state: PluginState) -> PluginState:
|
||||
"""The state as readers outside the scheduler see it.
|
||||
|
||||
RUNNING is the scheduler's claim on a plugin for one update() call: every
|
||||
update flips ENABLED -> RUNNING -> ENABLED. Published as is, that would be
|
||||
a change -- and a cache write to the SD card -- on every plugin update, and
|
||||
a reader would see a plugin blink between two states that mean the same
|
||||
thing to it (loaded and taking part). Readers get ENABLED for both.
|
||||
"""
|
||||
return PluginState.ENABLED if state == PluginState.RUNNING else state
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""Manages plugin state transitions and queries."""
|
||||
"""Manages plugin state transitions and queries.
|
||||
|
||||
Owned by the display process's PluginManager. ``change_count`` moves
|
||||
whenever something a reader of the published snapshot would see changes
|
||||
(published state, error info, the loaded record) and stays put across the
|
||||
RUNNING/ENABLED flip of an ordinary update, so a publisher can tell
|
||||
"nothing new" without diffing.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: Optional[logging.Logger] = None) -> None:
|
||||
"""
|
||||
@@ -41,6 +63,20 @@ class PluginStateManager:
|
||||
self._state_transition_counts: Dict[str, int] = {}
|
||||
self._error_info: Dict[str, Dict[str, Any]] = {}
|
||||
self._last_update: Dict[str, datetime] = {}
|
||||
# What load_plugin() registered: {'version', 'loaded_at'} per plugin
|
||||
# whose instance is live. Cleared with the rest of its state on unload.
|
||||
self._loaded: Dict[str, Dict[str, Any]] = {}
|
||||
self._change_count = 0
|
||||
|
||||
@property
|
||||
def change_count(self) -> int:
|
||||
"""Moves on every change a published snapshot would show."""
|
||||
with self._lock:
|
||||
return self._change_count
|
||||
|
||||
def _note_change(self) -> None:
|
||||
"""Count a reader-visible change. Callers must already hold ``_lock``."""
|
||||
self._change_count += 1
|
||||
|
||||
def _record_transition(self, plugin_id: str) -> None:
|
||||
"""Count a state transition. Callers must already hold ``_lock``."""
|
||||
@@ -63,9 +99,12 @@ class PluginStateManager:
|
||||
error: Optional error if transitioning to ERROR state
|
||||
"""
|
||||
with self._lock:
|
||||
known = plugin_id in self._states
|
||||
old_state = self._states.get(plugin_id, PluginState.UNLOADED)
|
||||
self._states[plugin_id] = state
|
||||
self._record_transition(plugin_id)
|
||||
if not known or published_state(old_state) != published_state(state):
|
||||
self._note_change()
|
||||
|
||||
# Store error info if transitioning to ERROR state
|
||||
if state == PluginState.ERROR and error:
|
||||
@@ -74,9 +113,11 @@ class PluginStateManager:
|
||||
'error_type': type(error).__name__,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
self._note_change()
|
||||
elif state != PluginState.ERROR:
|
||||
# Clear error info when leaving ERROR state
|
||||
self._error_info.pop(plugin_id, None)
|
||||
if self._error_info.pop(plugin_id, None) is not None:
|
||||
self._note_change()
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s",
|
||||
@@ -147,6 +188,7 @@ class PluginStateManager:
|
||||
self._states[plugin_id] = state
|
||||
self._record_transition(plugin_id)
|
||||
self._error_info[plugin_id] = dict(error_info)
|
||||
self._note_change()
|
||||
|
||||
self.logger.debug(
|
||||
"Plugin %s state transition: %s → %s (recoverable error stored)",
|
||||
@@ -173,6 +215,52 @@ class PluginStateManager:
|
||||
info = self._error_info.get(plugin_id)
|
||||
return dict(info) if info is not None else None
|
||||
|
||||
def record_loaded(self, plugin_id: str, version: Optional[str],
|
||||
loaded_at: Optional[float] = None) -> None:
|
||||
"""Record that ``plugin_id``'s instance is live, and which version.
|
||||
|
||||
Called by PluginManager.load_plugin() once the instance is registered;
|
||||
clear_state() (unload) forgets it. ``version`` is the manifest's at
|
||||
load time, which is what the display keeps running until it reloads
|
||||
the plugin -- the version on disk can move on after a store update.
|
||||
"""
|
||||
with self._lock:
|
||||
self._loaded[plugin_id] = {
|
||||
'version': version,
|
||||
'loaded_at': time.time() if loaded_at is None else loaded_at,
|
||||
}
|
||||
self._note_change()
|
||||
|
||||
def record_unloaded(self, plugin_id: str) -> None:
|
||||
"""Forget the loaded record alone, keeping state and error info: for
|
||||
an unload that failed after the instance was already dropped."""
|
||||
with self._lock:
|
||||
if self._loaded.pop(plugin_id, None) is not None:
|
||||
self._note_change()
|
||||
|
||||
def runtime_records(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Every known plugin's reader-visible state, taken in one critical
|
||||
section so a concurrent load or unload is seen whole or not at all.
|
||||
|
||||
Per plugin: ``state`` (published_state()'s value), ``loaded``,
|
||||
``version`` and ``loaded_at`` (None unless loaded) and ``error_info``
|
||||
(a copy, or None).
|
||||
"""
|
||||
with self._lock:
|
||||
records: Dict[str, Dict[str, Any]] = {}
|
||||
for plugin_id in set(self._states) | set(self._loaded):
|
||||
loaded = self._loaded.get(plugin_id)
|
||||
info = self._error_info.get(plugin_id)
|
||||
records[plugin_id] = {
|
||||
'state': published_state(
|
||||
self._states.get(plugin_id, PluginState.UNLOADED)).value,
|
||||
'loaded': loaded is not None,
|
||||
'version': loaded['version'] if loaded else None,
|
||||
'loaded_at': loaded['loaded_at'] if loaded else None,
|
||||
'error_info': dict(info) if info is not None else None,
|
||||
}
|
||||
return records
|
||||
|
||||
def record_update(self, plugin_id: str) -> None:
|
||||
"""Record that plugin update() was called."""
|
||||
self._last_update[plugin_id] = datetime.now()
|
||||
@@ -221,8 +309,13 @@ class PluginStateManager:
|
||||
state.
|
||||
"""
|
||||
with self._lock:
|
||||
had = (plugin_id in self._states or plugin_id in self._loaded
|
||||
or plugin_id in self._error_info)
|
||||
self._states.pop(plugin_id, None)
|
||||
self._state_transition_counts.pop(plugin_id, None)
|
||||
self._error_info.pop(plugin_id, None)
|
||||
self._last_update.pop(plugin_id, None)
|
||||
self._loaded.pop(plugin_id, None)
|
||||
if had:
|
||||
self._note_change()
|
||||
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
"""
|
||||
Centralized plugin state management.
|
||||
|
||||
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
|
||||
with persistence.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, asdict
|
||||
from enum import Enum
|
||||
|
||||
from src.config_manager_atomic import atomic_write_text
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
class PluginStateStatus(Enum):
|
||||
"""Status of a plugin."""
|
||||
INSTALLED = "installed"
|
||||
ENABLED = "enabled"
|
||||
DISABLED = "disabled"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginState:
|
||||
"""Represents the state of a plugin."""
|
||||
plugin_id: str
|
||||
status: PluginStateStatus
|
||||
enabled: bool
|
||||
version: Optional[str] = None
|
||||
installed_at: Optional[datetime] = None
|
||||
last_updated: Optional[datetime] = None
|
||||
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
|
||||
# plugin_state.json keeps the shape older releases load with cls(**data).
|
||||
config_version: int = 1
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert state to dictionary for serialization."""
|
||||
result = asdict(self)
|
||||
# Convert enum to string
|
||||
result['status'] = self.status.value
|
||||
# Convert datetime to ISO string
|
||||
if result.get('installed_at'):
|
||||
result['installed_at'] = self.installed_at.isoformat()
|
||||
if result.get('last_updated'):
|
||||
result['last_updated'] = self.last_updated.isoformat()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
|
||||
"""Create state from dictionary."""
|
||||
# Parse enum
|
||||
if isinstance(data.get('status'), str):
|
||||
data['status'] = PluginStateStatus(data['status'])
|
||||
|
||||
# Parse datetime
|
||||
if data.get('installed_at') and isinstance(data['installed_at'], str):
|
||||
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
|
||||
if data.get('last_updated') and isinstance(data['last_updated'], str):
|
||||
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
|
||||
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class PluginStateManager:
|
||||
"""
|
||||
Centralized plugin state manager.
|
||||
|
||||
Provides:
|
||||
- Single source of truth for plugin state
|
||||
- State persistence
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_file: Optional[str] = None,
|
||||
auto_save: bool = True,
|
||||
lazy_load: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize state manager.
|
||||
|
||||
Args:
|
||||
state_file: Path to file for persisting state
|
||||
auto_save: Whether to automatically save state on changes
|
||||
lazy_load: If True, defer loading state file until first access
|
||||
"""
|
||||
self.logger = get_logger(__name__)
|
||||
self.state_file = Path(state_file) if state_file else None
|
||||
self.auto_save = auto_save
|
||||
self._lazy_load = lazy_load
|
||||
self._state_loaded = False
|
||||
|
||||
# State storage
|
||||
self._states: Dict[str, PluginState] = {}
|
||||
# The file's top-level "version", written back as read. Nothing
|
||||
# checks it yet; it is there for a future format change to branch on.
|
||||
self._state_version = 1
|
||||
|
||||
# Threading
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Load state from file if it exists (unless lazy loading)
|
||||
if not self._lazy_load and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
"""Ensure state is loaded (for lazy loading)."""
|
||||
if not self._state_loaded and self.state_file and self.state_file.exists():
|
||||
self._load_state()
|
||||
self._state_loaded = True
|
||||
|
||||
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
|
||||
"""
|
||||
Get state for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
PluginState if found, None otherwise
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.get(plugin_id)
|
||||
|
||||
def get_all_states(self) -> Dict[str, PluginState]:
|
||||
"""
|
||||
Get all plugin states.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping plugin_id to PluginState
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
return self._states.copy()
|
||||
|
||||
def update_plugin_state(
|
||||
self,
|
||||
plugin_id: str,
|
||||
updates: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Update plugin state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
updates: Dictionary of state updates
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
# Get current state or create new
|
||||
current_state = self._states.get(plugin_id)
|
||||
if not current_state:
|
||||
current_state = PluginState(
|
||||
plugin_id=plugin_id,
|
||||
status=PluginStateStatus.UNKNOWN,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# Apply updates
|
||||
if 'status' in updates:
|
||||
if isinstance(updates['status'], str):
|
||||
current_state.status = PluginStateStatus(updates['status'])
|
||||
else:
|
||||
current_state.status = updates['status']
|
||||
|
||||
if 'enabled' in updates:
|
||||
current_state.enabled = bool(updates['enabled'])
|
||||
|
||||
if 'version' in updates:
|
||||
current_state.version = updates['version']
|
||||
|
||||
if 'installed_at' in updates:
|
||||
current_state.installed_at = updates['installed_at']
|
||||
|
||||
if 'last_updated' in updates:
|
||||
current_state.last_updated = updates['last_updated']
|
||||
else:
|
||||
current_state.last_updated = datetime.now()
|
||||
|
||||
if 'metadata' in updates:
|
||||
if current_state.metadata is None:
|
||||
current_state.metadata = {}
|
||||
current_state.metadata.update(updates['metadata'])
|
||||
|
||||
current_state.config_version += 1
|
||||
|
||||
# Store updated state
|
||||
self._states[plugin_id] = current_state
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
|
||||
return True
|
||||
|
||||
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
|
||||
"""
|
||||
Set plugin enabled/disabled state.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
enabled: Whether plugin is enabled
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'enabled': enabled,
|
||||
'status': status
|
||||
}
|
||||
)
|
||||
|
||||
def set_plugin_installed(
|
||||
self,
|
||||
plugin_id: str,
|
||||
version: Optional[str] = None,
|
||||
installed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Mark plugin as installed.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
version: Plugin version
|
||||
installed_at: Installation timestamp
|
||||
|
||||
Returns:
|
||||
True if update successful
|
||||
"""
|
||||
return self.update_plugin_state(
|
||||
plugin_id,
|
||||
{
|
||||
'status': PluginStateStatus.INSTALLED,
|
||||
'version': version,
|
||||
'installed_at': installed_at or datetime.now()
|
||||
}
|
||||
)
|
||||
|
||||
def remove_plugin_state(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Remove plugin state (e.g., after uninstall).
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
True if removal successful
|
||||
"""
|
||||
self._ensure_loaded()
|
||||
with self._lock:
|
||||
if plugin_id in self._states:
|
||||
del self._states[plugin_id]
|
||||
|
||||
# Auto-save if enabled
|
||||
if self.auto_save:
|
||||
self._save_state()
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _save_state(self) -> None:
|
||||
"""Save state to file."""
|
||||
if not self.state_file:
|
||||
return
|
||||
|
||||
try:
|
||||
# The write stays under the lock and goes through a temp file:
|
||||
# Flask serves requests on threads, and two saves racing on a
|
||||
# plain open('w') could interleave or leave a truncated file
|
||||
# that _load_state then drops wholesale.
|
||||
with self._lock:
|
||||
# Convert states to dicts
|
||||
states_data = {
|
||||
plugin_id: state.to_dict()
|
||||
for plugin_id, state in self._states.items()
|
||||
}
|
||||
|
||||
state_data = {
|
||||
'version': self._state_version,
|
||||
'states': states_data,
|
||||
'last_updated': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Ensure directory exists with proper permissions
|
||||
from src.common.permission_utils import (
|
||||
ensure_directory_permissions,
|
||||
get_config_dir_mode
|
||||
)
|
||||
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
|
||||
|
||||
# Write to file
|
||||
atomic_write_text(self.state_file, json.dumps(state_data, indent=2))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Load state from file."""
|
||||
if not self.state_file or not self.state_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.state_file, 'r', encoding='utf-8') as f:
|
||||
state_data = json.load(f)
|
||||
|
||||
with self._lock:
|
||||
# Load state version
|
||||
self._state_version = state_data.get('version', 1)
|
||||
|
||||
# Load states
|
||||
states_data = state_data.get('states', {})
|
||||
for plugin_id, state_dict in states_data.items():
|
||||
try:
|
||||
self._states[plugin_id] = PluginState.from_dict(state_dict)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Error loading state for plugin {plugin_id}: {e}"
|
||||
)
|
||||
|
||||
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)
|
||||
@@ -1,22 +1,34 @@
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Detects and fixes inconsistencies between:
|
||||
- Config file state
|
||||
- Plugin manager state
|
||||
- Disk state (installed plugins)
|
||||
- State manager state
|
||||
Compares what the user wants with what is there and what runs:
|
||||
|
||||
- desired: config.json (which plugins are configured, and enabled) plus the
|
||||
plugins directory on disk (which are installed, at which version);
|
||||
- observed: the runtime snapshot the display publishes
|
||||
(src/plugin_system/plugin_runtime.py) -- which plugins it has loaded, at
|
||||
which version, and why one failed. Only a live snapshot is compared; a
|
||||
stale, stopped or missing one is unknown and yields no findings.
|
||||
|
||||
Desired-state gaps (on disk but not in config, in config but not on disk)
|
||||
are fixed here. Observed-state gaps (enabled but not loaded, loaded at an
|
||||
older version) are reported, never "fixed": the display reconciles its own
|
||||
loaded set against config, and a version gap needs a display restart.
|
||||
|
||||
There is no third, persisted record any more. ``data/plugin_state.json``
|
||||
held a copy of config's enabled flags and the disk's versions, and this
|
||||
module mostly synced it back to config; it is no longer read or written.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List, Set, cast
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
||||
from src.plugin_system.plugin_dirs import PluginDirectoryIndex
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.plugin_runtime import PluginRuntimeView, UNKNOWN
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
@@ -160,36 +172,40 @@ def still_unresolved(entries: List[Dict[str, Any]],
|
||||
return live
|
||||
|
||||
|
||||
RuntimeSource = Callable[[], PluginRuntimeView]
|
||||
|
||||
|
||||
class StateReconciliation:
|
||||
"""
|
||||
State reconciliation system.
|
||||
|
||||
Compares state from multiple sources and detects/fixes inconsistencies.
|
||||
|
||||
Compares desired state (config + disk) with observed state (the
|
||||
display's runtime snapshot) and fixes what can safely be fixed.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_manager: PluginStateManager,
|
||||
*,
|
||||
config_manager,
|
||||
plugin_manager,
|
||||
plugins_dir: Path,
|
||||
store_manager=None
|
||||
store_manager=None,
|
||||
runtime_source: Optional[RuntimeSource] = None,
|
||||
):
|
||||
"""
|
||||
Initialize reconciliation system.
|
||||
|
||||
Args:
|
||||
state_manager: PluginStateManager instance
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: PluginManager instance
|
||||
plugins_dir: Path to plugins directory
|
||||
store_manager: Optional PluginStoreManager for auto-repair
|
||||
runtime_source: Returns the display's runtime snapshot as a
|
||||
PluginRuntimeView (plugin_runtime.read_plugin_runtime bound to
|
||||
a cache manager). None: observed state is unknown.
|
||||
"""
|
||||
self.state_manager = state_manager
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.store_manager = store_manager
|
||||
self.runtime_source = runtime_source
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Plugin IDs that failed auto-repair and should NOT be retried this
|
||||
@@ -230,30 +246,28 @@ class StateReconciliation:
|
||||
manual_fix_required = []
|
||||
|
||||
try:
|
||||
# Get state from all sources
|
||||
# Desired: config + disk. Observed: the display's snapshot.
|
||||
config_state = self._get_config_state()
|
||||
disk_state = self._get_disk_state()
|
||||
manager_state = self._get_manager_state()
|
||||
state_manager_state = self._get_state_manager_state()
|
||||
|
||||
# Find all unique plugin IDs
|
||||
observed = self._get_observed_state()
|
||||
|
||||
# Plugins the display reports but neither config nor disk knows
|
||||
# (removed while it still runs them) are not a finding of their
|
||||
# own: the display unloads them when their section goes.
|
||||
all_plugin_ids: Set[str] = set()
|
||||
all_plugin_ids.update(config_state.keys())
|
||||
all_plugin_ids.update(disk_state.keys())
|
||||
all_plugin_ids.update(manager_state.keys())
|
||||
all_plugin_ids.update(state_manager_state.keys())
|
||||
|
||||
|
||||
# Check each plugin for inconsistencies
|
||||
for plugin_id in all_plugin_ids:
|
||||
plugin_inconsistencies = self._check_plugin_consistency(
|
||||
plugin_id,
|
||||
config_state,
|
||||
disk_state,
|
||||
manager_state,
|
||||
state_manager_state
|
||||
observed,
|
||||
)
|
||||
inconsistencies.extend(plugin_inconsistencies)
|
||||
|
||||
|
||||
# Attempt to fix auto-fixable inconsistencies
|
||||
for inconsistency in inconsistencies:
|
||||
if inconsistency.can_auto_fix and inconsistency.fix_action == FixAction.AUTO_FIX:
|
||||
@@ -309,8 +323,9 @@ class StateReconciliation:
|
||||
for plugin_id in config_plugin_ids(config, ignored):
|
||||
plugin_config = config[plugin_id]
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_config.get('enabled', True),
|
||||
'version': plugin_config.get('version'),
|
||||
# The display's rule: it runs a plugin only when its
|
||||
# section says "enabled": true.
|
||||
'enabled': bool(plugin_config.get('enabled', False)),
|
||||
'exists_in_config': True
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -339,45 +354,51 @@ class StateReconciliation:
|
||||
self.logger.warning(f"Error reading disk state: {e}")
|
||||
return state
|
||||
|
||||
def _get_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from plugin manager."""
|
||||
state = {}
|
||||
def _get_observed_state(self) -> PluginRuntimeView:
|
||||
"""The display's runtime snapshot; unknown when there is no source or
|
||||
it cannot be read. Only a live view is compared."""
|
||||
if self.runtime_source is None:
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
try:
|
||||
if self.plugin_manager:
|
||||
# Get discovered plugins
|
||||
if hasattr(self.plugin_manager, 'plugin_manifests'):
|
||||
for plugin_id in self.plugin_manager.plugin_manifests.keys():
|
||||
state[plugin_id] = {
|
||||
'exists_in_manager': True,
|
||||
'loaded': plugin_id in getattr(self.plugin_manager, 'plugins', {})
|
||||
}
|
||||
return self.runtime_source()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading manager state: {e}")
|
||||
return state
|
||||
|
||||
def _get_state_manager_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from state manager."""
|
||||
state = {}
|
||||
try:
|
||||
all_states = self.state_manager.get_all_states()
|
||||
for plugin_id, plugin_state in all_states.items():
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_state.enabled,
|
||||
'status': plugin_state.status.value,
|
||||
'version': plugin_state.version,
|
||||
'exists_in_state_manager': True
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading state manager state: {e}")
|
||||
return state
|
||||
|
||||
self.logger.warning(f"Error reading the display's runtime state: {e}")
|
||||
return PluginRuntimeView(status=UNKNOWN)
|
||||
|
||||
def plugin_states(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Desired and observed state for every plugin config or disk knows.
|
||||
|
||||
Per plugin: ``installed`` and ``version`` (disk), ``in_config`` and
|
||||
``enabled`` (config, by the display's rule), and the display's
|
||||
``loaded`` / ``state`` / ``error_info`` / ``loaded_version`` /
|
||||
``loaded_at`` (None unless its snapshot is live). What
|
||||
/api/v3/plugins/state serves, in place of plugin_state.json.
|
||||
"""
|
||||
config_state = self._get_config_state()
|
||||
disk_state = self._get_disk_state()
|
||||
observed = self._get_observed_state()
|
||||
states: Dict[str, Dict[str, Any]] = {}
|
||||
for plugin_id in sorted(set(config_state) | set(disk_state)):
|
||||
if plugin_id in CORE_CONFIG_KEYS:
|
||||
continue
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
states[plugin_id] = {
|
||||
'plugin_id': plugin_id,
|
||||
'installed': bool(disk.get('exists_on_disk')),
|
||||
'version': disk.get('version'),
|
||||
'in_config': bool(config.get('exists_in_config')),
|
||||
'enabled': bool(config.get('enabled', False)),
|
||||
**observed.plugin(plugin_id),
|
||||
}
|
||||
return states
|
||||
|
||||
def _check_plugin_consistency(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_state: Dict[str, Dict[str, Any]],
|
||||
disk_state: Dict[str, Dict[str, Any]],
|
||||
manager_state: Dict[str, Dict[str, Any]],
|
||||
state_manager_state: Dict[str, Dict[str, Any]]
|
||||
observed: PluginRuntimeView,
|
||||
) -> List[Inconsistency]:
|
||||
"""Check consistency for a single plugin."""
|
||||
inconsistencies: List[Inconsistency] = []
|
||||
@@ -397,7 +418,6 @@ class StateReconciliation:
|
||||
|
||||
config = config_state.get(plugin_id, {})
|
||||
disk = disk_state.get(plugin_id, {})
|
||||
state_mgr = state_manager_state.get(plugin_id, {})
|
||||
|
||||
# Check: Plugin exists on disk but not in config
|
||||
if disk.get('exists_on_disk') and not config.get('exists_in_config'):
|
||||
@@ -442,21 +462,47 @@ class StateReconciliation:
|
||||
can_auto_fix=can_repair
|
||||
))
|
||||
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
# Observed checks: only against a live snapshot, and only for a plugin
|
||||
# that is both configured and installed (the checks above cover the
|
||||
# rest). Reported, never fixed here: the display loads and unloads by
|
||||
# config on its own, so a gap is either transient (it is catching up)
|
||||
# or something only the user can act on (a failed load, a restart).
|
||||
if (observed.live and config.get('exists_in_config')
|
||||
and disk.get('exists_on_disk')):
|
||||
runtime = observed.plugin(plugin_id)
|
||||
config_enabled = bool(config.get('enabled', False))
|
||||
loaded = bool(runtime.get('loaded'))
|
||||
if config_enabled != loaded:
|
||||
error = runtime.get('error_info') or {}
|
||||
why = f" ({error.get('message')})" if error.get('message') else ""
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=(
|
||||
f"Plugin {plugin_id} is {'enabled' if config_enabled else 'disabled'} "
|
||||
f"in config but the display has it "
|
||||
f"{'loaded' if loaded else 'not loaded'} "
|
||||
f"(state {runtime.get('state')}){why}"),
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'loaded': loaded, 'state': runtime.get('state')},
|
||||
expected_state={'loaded': config_enabled},
|
||||
can_auto_fix=False
|
||||
))
|
||||
loaded_version = runtime.get('loaded_version')
|
||||
disk_version = disk.get('version')
|
||||
if loaded and loaded_version and disk_version and loaded_version != disk_version:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_VERSION_MISMATCH,
|
||||
description=(
|
||||
f"Plugin {plugin_id} {disk_version} is installed but the display "
|
||||
f"is running {loaded_version}; restart the display to run it"),
|
||||
fix_action=FixAction.NO_ACTION,
|
||||
current_state={'version': loaded_version},
|
||||
expected_state={'version': disk_version},
|
||||
can_auto_fix=False
|
||||
))
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
return inconsistencies
|
||||
|
||||
def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool:
|
||||
@@ -490,26 +536,6 @@ class StateReconciliation:
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
# Always set for this type (see _check_plugin_consistency).
|
||||
config_enabled = cast(bool, inconsistency.expected_state.get('enabled'))
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id} to match config"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Failed to sync state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
Reference in New Issue
Block a user