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
+231 -226
View File
@@ -1,338 +1,352 @@
"""
Tests for state reconciliation system.
Desired state is config.json plus the plugins on disk; observed state is
the runtime snapshot the display publishes (plugin_runtime). There is no
third, persisted record: data/plugin_state.json is retired.
"""
import unittest
import tempfile
import shutil
import json
import time
from pathlib import Path
from unittest.mock import Mock, patch
from src.plugin_system.plugin_runtime import (
PluginRuntimeView, SNAPSHOT_SCHEMA, view_from_snapshot,
)
from src.plugin_system.state_reconciliation import (
StateReconciliation,
InconsistencyType,
FixAction,
ReconciliationResult
)
from src.plugin_system.state_manager import PluginStateManager, PluginStateStatus
def live_view(plugins):
"""A live runtime view, as read from a fresh snapshot of a running display."""
now = time.time()
return view_from_snapshot({
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now,
"stale_after": 180, "plugins": plugins,
}, now=now)
def stale_view(plugins):
"""The same snapshot, read long after the display stopped refreshing it."""
now = time.time()
return view_from_snapshot({
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now - 3600,
"stale_after": 180, "plugins": plugins,
}, now=now)
class TestStateReconciliation(unittest.TestCase):
"""Test state reconciliation system."""
def setUp(self):
"""Set up test fixtures."""
self.temp_dir = Path(tempfile.mkdtemp())
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
# Create mock managers
self.state_manager = Mock(spec=PluginStateManager)
self.config_manager = Mock()
self.plugin_manager = Mock()
# What the display reports; tests replace it.
self.observed = PluginRuntimeView(status="unknown")
# Initialize reconciliation system
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir
plugins_dir=self.plugins_dir,
runtime_source=lambda: self.observed,
)
def tearDown(self):
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir)
def _install(self, plugin_id, version="1.0.0"):
plugin_dir = self.plugins_dir / plugin_id
plugin_dir.mkdir()
with open(plugin_dir / "manifest.json", 'w') as f:
json.dump({"id": plugin_id, "version": version, "name": plugin_id}, f)
def test_reconcile_no_inconsistencies(self):
"""Test reconciliation with no inconsistencies."""
# Setup: All states are consistent
"""Config, disk and the display agree."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {"plugin1": Mock()}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": time.time()}})
result = self.reconciler.reconcile_state()
# Verify
self.assertIsInstance(result, ReconciliationResult)
self.assertEqual(len(result.inconsistencies_found), 0)
self.assertTrue(result.reconciliation_successful)
def test_plugin_missing_in_config(self):
"""Test detection of plugin missing in config."""
# Setup: Plugin exists on disk but not in config
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
def test_plugin_missing_on_disk(self):
"""Test detection of plugin missing on disk."""
# Setup: Plugin in config but not on disk
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Don't create plugin directory
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
self.assertFalse(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
def test_enabled_state_mismatch(self):
"""Test detection of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
"""Enabled in config, installed, but the display reports a failed load:
a finding that names the error, left alone (the display loads by
config on its own) and not counted as needing manual attention."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
self._install("plugin1")
self.observed = live_view({"plugin1": {
"loaded": False, "state": "error",
"error": {"type": "ImportError", "message": "No module named 'x'",
"at": time.time(), "recoverable": False},
"version": None, "loaded_at": None}})
self.config_manager.save_config = Mock()
result = self.reconciler.reconcile_state()
# Verify inconsistency detected
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.plugin_id, "plugin1")
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
self.assertTrue(inconsistency.can_auto_fix)
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
self.assertFalse(inconsistency.can_auto_fix)
self.assertIn("No module named", inconsistency.description)
self.assertEqual(result.inconsistencies_fixed, [])
self.assertEqual(result.inconsistencies_manual, [])
self.assertTrue(result.reconciliation_successful)
self.config_manager.save_config.assert_not_called()
def test_enabled_and_absent_from_live_snapshot_is_not_loaded(self):
"""A plugin a live snapshot does not list is not loaded."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
self.observed = live_view({})
result = self.reconciler.reconcile_state()
types = [i.inconsistency_type for i in result.inconsistencies_found]
self.assertEqual(types, [InconsistencyType.PLUGIN_ENABLED_MISMATCH])
def test_disabled_and_not_loaded_agrees(self):
"""Missing "enabled" is disabled (the display's rule), and a plugin
the display has not loaded matches it."""
self.config_manager.load_config.return_value = {"plugin1": {}}
self._install("plugin1")
self.observed = live_view({})
result = self.reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [])
def test_loaded_at_an_older_version_is_reported(self):
"""The display runs 1.0.0 while 2.0.0 is on disk: restart needed."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1", version="2.0.0")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": time.time()}})
result = self.reconciler.reconcile_state()
self.assertEqual(len(result.inconsistencies_found), 1)
inconsistency = result.inconsistencies_found[0]
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_VERSION_MISMATCH)
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
self.assertIn("restart", inconsistency.description)
self.assertTrue(result.reconciliation_successful)
def test_stale_or_missing_snapshot_is_not_compared(self):
"""Observed state that is not live says nothing: no findings from it."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
dead = {"plugin1": {"loaded": False, "state": "error", "error": None,
"version": None, "loaded_at": None}}
for observed in (stale_view(dead), PluginRuntimeView(status="stopped"),
PluginRuntimeView(status="unknown")):
self.observed = observed
result = self.reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [], observed.status)
def test_no_runtime_source_is_unknown(self):
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
reconciler = StateReconciliation(config_manager=self.config_manager,
plugins_dir=self.plugins_dir)
self.assertEqual(reconciler.reconcile_state().inconsistencies_found, [])
def test_runtime_source_that_raises_is_unknown(self):
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self._install("plugin1")
def boom():
raise RuntimeError("cache unreadable")
reconciler = StateReconciliation(config_manager=self.config_manager,
plugins_dir=self.plugins_dir,
runtime_source=boom)
result = reconciler.reconcile_state()
self.assertEqual(result.inconsistencies_found, [])
self.assertTrue(result.reconciliation_successful)
def test_old_constructor_arguments_are_refused(self):
"""state_manager / plugin_manager are gone; passing them is an error,
not something silently ignored."""
with self.assertRaises(TypeError):
StateReconciliation(state_manager=Mock(), config_manager=self.config_manager,
plugin_manager=Mock(), plugins_dir=self.plugins_dir)
def test_plugin_states_combines_desired_and_observed(self):
"""What /plugins/state serves in place of plugin_state.json."""
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True},
"plugin2": {"enabled": False},
"ghost": {"enabled": True},
}
self._install("plugin1", version="1.2.0")
self._install("plugin2")
self._install("plugin3")
self.observed = live_view({"plugin1": {
"loaded": True, "state": "enabled", "error": None,
"version": "1.2.0", "loaded_at": 1234.0}})
states = self.reconciler.plugin_states()
self.assertEqual(set(states), {"plugin1", "plugin2", "plugin3", "ghost"})
self.assertEqual(states["plugin1"], {
"plugin_id": "plugin1", "installed": True, "version": "1.2.0",
"in_config": True, "enabled": True, "loaded": True,
"state": "enabled", "error_info": None,
"loaded_version": "1.2.0", "loaded_at": 1234.0,
})
self.assertFalse(states["plugin2"]["enabled"])
self.assertIs(states["plugin2"]["loaded"], False)
self.assertEqual(states["plugin2"]["state"], "unloaded")
self.assertFalse(states["plugin3"]["in_config"])
self.assertFalse(states["ghost"]["installed"])
def test_plugin_states_without_a_live_snapshot_reports_unknown(self):
self.config_manager.load_config.return_value = {"plugin1": {"enabled": True}}
self._install("plugin1")
self.observed = stale_view({"plugin1": {"loaded": True, "state": "enabled"}})
record = self.reconciler.plugin_states()["plugin1"]
self.assertTrue(record["enabled"])
self.assertIsNone(record["loaded"])
self.assertIsNone(record["state"])
self.assertIsNone(record["error_info"])
def test_auto_fix_plugin_missing_in_config(self):
"""Test auto-fix of plugin missing in config."""
# Setup
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to track calls
self._install("plugin1")
saved_configs = []
def save_config(config):
saved_configs.append(config)
self.config_manager.save_config = save_config
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify fix was attempted
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.assertEqual(len(saved_configs), 1)
self.assertIn("plugin1", saved_configs[0])
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
def test_auto_fix_enabled_state_mismatch(self):
"""Test auto-fix of enabled state mismatch."""
# Setup: Config says enabled=True, state manager says enabled=False
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=False,
status=PluginStateStatus.DISABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {"plugin1": {}}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Run reconciliation
result = self.reconciler.reconcile_state()
# config.json is the source of truth for enabled state. The fix syncs
# the state manager to match config (config says True → state set True),
# rather than overwriting the config with the stale state value.
self.assertEqual(len(result.inconsistencies_fixed), 1)
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
def test_multiple_inconsistencies(self):
"""Test reconciliation with multiple inconsistencies."""
# Setup: Multiple plugins with different issues
self.config_manager.load_config.return_value = {
"plugin1": {"enabled": True}, # Exists in config but not on disk
# plugin2 exists on disk but not in config
}
self.state_manager.get_all_states.return_value = {
"plugin1": Mock(
enabled=True,
status=PluginStateStatus.ENABLED,
version="1.0.0"
)
}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin2 directory (exists on disk but not in config)
plugin2_dir = self.plugins_dir / "plugin2"
plugin2_dir.mkdir()
manifest_path = plugin2_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 2"}, f)
# Run reconciliation
self._install("plugin2")
result = self.reconciler.reconcile_state()
# Verify multiple inconsistencies found
self.assertGreaterEqual(len(result.inconsistencies_found), 2)
# Check types
inconsistency_types = [inc.inconsistency_type for inc in result.inconsistencies_found]
self.assertIn(InconsistencyType.PLUGIN_MISSING_ON_DISK, inconsistency_types)
self.assertIn(InconsistencyType.PLUGIN_MISSING_IN_CONFIG, inconsistency_types)
def test_reconciliation_with_exception(self):
"""Test reconciliation handles exceptions gracefully."""
# Setup: State manager raises exception when getting states
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.side_effect = Exception("State manager error")
# Run reconciliation
self.config_manager.load_config.side_effect = Exception("Config error")
result = self.reconciler.reconcile_state()
# Verify error is handled - reconciliation may still succeed if other sources work
self.assertIsInstance(result, ReconciliationResult)
# Note: Reconciliation may still succeed if other sources provide valid state
def test_fix_failure_handling(self):
"""Test that fix failures are handled correctly."""
# Setup: Plugin missing in config, but save fails
self.config_manager.load_config.return_value = {}
self.state_manager.get_all_states.return_value = {}
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Create plugin directory
plugin_dir = self.plugins_dir / "plugin1"
plugin_dir.mkdir()
manifest_path = plugin_dir / "manifest.json"
with open(manifest_path, 'w') as f:
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
# Mock save_config to raise exception
self._install("plugin1")
self.config_manager.save_config.side_effect = Exception("Save failed")
# Run reconciliation
result = self.reconciler.reconcile_state()
# Verify inconsistency detected but not fixed
self.assertEqual(len(result.inconsistencies_found), 1)
self.assertEqual(len(result.inconsistencies_fixed), 0)
self.assertEqual(len(result.inconsistencies_manual), 1)
def test_get_config_state_handles_exception(self):
"""Test that _get_config_state handles exceptions."""
# Setup: Config manager raises exception
self.config_manager.load_config.side_effect = Exception("Config error")
# Call method directly
state = self.reconciler._get_config_state()
# Verify empty state returned
self.assertEqual(state, {})
def test_get_disk_state_handles_exception(self):
"""Test that _get_disk_state handles exceptions."""
# Setup: Make plugins_dir inaccessible
with patch.object(self.reconciler, 'plugins_dir', create=True) as mock_dir:
mock_dir.exists.side_effect = Exception("Disk error")
mock_dir.iterdir.side_effect = Exception("Disk error")
# Call method directly
state = self.reconciler._get_disk_state()
# Verify empty state returned
self.assertEqual(state, {})
@@ -352,15 +366,10 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
self.plugins_dir = self.temp_dir / "plugins"
self.plugins_dir.mkdir()
self.state_manager = Mock(spec=PluginStateManager)
self.state_manager.get_all_states.return_value = {}
self.config_manager = Mock()
self.config_manager.load_config.return_value = {
"ghost": {"enabled": True}
}
self.plugin_manager = Mock()
self.plugin_manager.plugin_manifests = {}
self.plugin_manager.plugins = {}
# Store manager with an empty registry — install_plugin always fails
self.store_manager = Mock()
@@ -372,9 +381,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
self.store_manager.is_plugin_uninstalled.return_value = False
self.reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=self.store_manager,
)
@@ -483,9 +490,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
)
reconciler = StateReconciliation(
state_manager=self.state_manager,
config_manager=self.config_manager,
plugin_manager=self.plugin_manager,
plugins_dir=self.plugins_dir,
store_manager=real_store,
)