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:
Chuck
2026-09-30 10:48:14 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7ab6fb1aff
commit b09434a418
38 changed files with 1893 additions and 940 deletions
+7 -39
View File
@@ -1,48 +1,16 @@
"""plugin_state.json and the operation history file are replaced atomically.
"""The operation history file is replaced atomically.
Both were written with a plain ``open(path, 'w')`` + ``json.dump`` outside
their lock. The open truncates first, so a value json can't encode (or a
crash, or a second Flask thread saving at the same moment) left a partial
file, and the next load dropped every saved state. They now serialise first
and go through a temp file + rename while holding the lock.
It was written with a plain ``open(path, 'w')`` + ``json.dump`` outside its
lock. The open truncates first, so a value json can't encode (or a crash, or
a second Flask thread saving at the same moment) left a partial file, and the
next load dropped every saved record. It now serialises first and goes
through a temp file + rename while holding the lock. (plugin_state.json had
the same fix; it is retired now -- nothing writes it.)
"""
import json
import threading
from src.plugin_system.operation_history import OperationHistory
from src.plugin_system.state_manager import PluginStateManager
def test_state_file_survives_a_failed_save(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
mgr.set_plugin_enabled("clock", True)
before = json.loads(state_file.read_text())
# Not JSON-serialisable: the save fails (and is logged, not raised).
mgr.update_plugin_state("clock", {"metadata": {"bad": object()}})
assert json.loads(state_file.read_text()) == before
assert [p.name for p in tmp_path.iterdir()] == ["plugin_state.json"]
def test_state_file_is_valid_after_concurrent_saves(tmp_path):
state_file = tmp_path / "plugin_state.json"
mgr = PluginStateManager(state_file=str(state_file))
def worker(n):
for i in range(15):
mgr.set_plugin_enabled(f"plugin-{n}", i % 2 == 0)
threads = [threading.Thread(target=worker, args=(n,)) for n in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
data = json.loads(state_file.read_text())
assert set(data["states"]) == {f"plugin-{n}" for n in range(6)}
def test_history_file_survives_a_failed_save(tmp_path):