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
@@ -9,7 +9,6 @@ from pathlib import Path
from src.plugin_system.operation_queue import PluginOperationQueue
from src.plugin_system.operation_types import OperationType
from src.plugin_system.state_manager import PluginStateManager
from src.plugin_system.operation_history import OperationHistory
@@ -22,12 +21,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
# Initialize components
self.operation_queue = PluginOperationQueue(max_history=100)
self.state_manager = PluginStateManager(
state_file=str(self.temp_dir / "state.json"),
auto_save=True
)
# No state manager: installed / enabled / version are read from disk
# and config.json, not from a plugin_state.json record.
self.operation_history = OperationHistory(
history_file=str(self.temp_dir / "history.json"),
max_records=100
@@ -64,22 +61,15 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
self.assertIsNotNone(history_id)
# Update state manager
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertIsNotNone(state)
self.assertEqual(state.version, "1.0.0")
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["install"])
def test_update_operation_flow(self):
"""Test complete update operation flow."""
plugin_id = "test-plugin"
# First, mark as installed
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Enqueue update operation
operation_id = self.operation_queue.enqueue_operation(
OperationType.UPDATE,
@@ -97,20 +87,14 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
# Update state
self.state_manager.update_plugin_state(plugin_id, {"version": "2.0.0"})
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertEqual(state.version, "2.0.0")
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["update"])
def test_uninstall_operation_flow(self):
"""Test complete uninstall operation flow."""
plugin_id = "test-plugin"
# First, mark as installed
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
# Enqueue uninstall operation
operation_id = self.operation_queue.enqueue_operation(
OperationType.UNINSTALL,
@@ -127,13 +111,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
operation_id=operation_id
)
# Update state - remove plugin state
self.state_manager.remove_plugin_state(plugin_id)
# Verify state
state = self.state_manager.get_plugin_state(plugin_id)
self.assertIsNone(state)
# Verify history
history = self.operation_history.get_history(plugin_id=plugin_id)
self.assertEqual([r.operation_type for r in history], ["uninstall"])
def test_operation_history_tracking(self):
"""Test that operations are tracked in history."""
plugin_id = "test-plugin"
@@ -30,7 +30,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
_MANAGER_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
@@ -33,7 +33,7 @@ URL = "/api/v3/backup/restore"
_MANAGER_ATTRS = (
'config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager',
)
_SENTINEL = object()
+1 -1
View File
@@ -44,7 +44,7 @@ def env(tmp_path):
_SENTINEL = object()
attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager',
'saved_repositories_manager',
'schema_manager', 'operation_queue', 'operation_history',
'cache_manager')
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
@@ -86,7 +86,6 @@ def env(tmp_path):
api_v3.plugin_store_manager = MagicMock()
api_v3.saved_repositories_manager = MagicMock()
api_v3.operation_queue = MagicMock()
api_v3.plugin_state_manager = MagicMock()
api_v3.operation_history = MagicMock()
api_v3.cache_manager = MagicMock()
@@ -41,7 +41,7 @@ EXPECTED = {
MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
"saved_repositories_manager", "schema_manager", "operation_queue",
"plugin_state_manager", "operation_history", "cache_manager")
"operation_history", "cache_manager")
class Boom:
@@ -73,7 +73,7 @@ STORED = {
}
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
'saved_repositories_manager', 'schema_manager',
'operation_queue', 'operation_history', 'cache_manager')
@@ -0,0 +1,166 @@
"""data/plugin_state.json is retired; /plugins/state and reconciliation read
config + disk (desired) and the display's runtime snapshot (observed).
The file held, per plugin, an enabled flag (a copy of config.json's), a
version (a copy of the manifest's, when anything set it at all), a status
derived from those two, and install/update timestamps that only
GET /api/v3/plugins/state ever returned -- the operation history records the
same events. Nothing in it was needed that cannot be derived, so it is not
migrated: nothing reads or writes it any more, and an existing file is left
in place, unread. These tests hold that line and check the replacements.
"""
import ast
import json
import time
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.operation_history import OperationRecord
from src.plugin_system.plugin_runtime import SNAPSHOT_SCHEMA, view_from_snapshot
from src.plugin_system.state_reconciliation import StateReconciliation
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
REPO = Path(__file__).resolve().parents[2]
def _code_strings(path):
"""String constants in a module, docstrings excluded."""
tree = ast.parse(path.read_text(encoding="utf-8"))
docstrings = {id(node.value) for node in ast.walk(tree)
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)}
return [node.value for node in ast.walk(tree)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
and id(node) not in docstrings]
def test_no_code_reads_or_writes_the_state_file():
offenders = []
for root in ("src", "web_interface", "scripts"):
for path in (REPO / root).rglob("*.py"):
if any("plugin_state.json" in s for s in _code_strings(path)):
offenders.append(str(path.relative_to(REPO)))
assert offenders == []
def test_the_web_side_state_manager_is_gone():
import importlib.util
assert importlib.util.find_spec("src.plugin_system.state_manager") is None
from web_interface.blueprints.api_v3 import api_v3
import web_interface.app as app_module
assert not hasattr(app_module, "plugin_state_manager")
assert getattr(api_v3, "plugin_state_manager", None) is None
def _install(plugins_dir, plugin_id, version):
d = plugins_dir / plugin_id
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": version}),
encoding="utf-8")
def _live(plugins):
now = time.time()
return view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": True,
"published_at": now, "plugins": plugins}, now=now)
def test_an_existing_state_file_changes_nothing(tmp_path):
"""A device upgraded with a plugin_state.json that disagrees with config
and disk: reconciliation answers exactly as on a device without one."""
plugins_dir = tmp_path / "plugin-repos"
_install(plugins_dir, "clock", "1.0.0")
config = MagicMock()
config.load_config.return_value = {"clock": {"enabled": True}}
config.get_secrets_path.return_value = str(tmp_path / "none.json")
def run():
r = StateReconciliation(config_manager=config, plugins_dir=plugins_dir)
result = r.reconcile_state()
return ([i.inconsistency_type for i in result.inconsistencies_found],
r.plugin_states())
without = run()
(tmp_path / "data").mkdir()
(tmp_path / "data" / "plugin_state.json").write_text(json.dumps({
"version": 1, "states": {
"clock": {"plugin_id": "clock", "status": "disabled", "enabled": False,
"version": "0.0.1"},
"gone": {"plugin_id": "gone", "status": "installed", "enabled": True}}}),
encoding="utf-8")
assert run() == without
assert without[1]["clock"]["enabled"] is True
assert "gone" not in without[1]
@pytest.fixture
def state_api(api_v3_module, api_v3_client, tmp_path, monkeypatch): # noqa: F811
api = api_v3_module.api_v3
plugins_dir = tmp_path / "plugin-repos"
_install(plugins_dir, "clock", "1.1.0")
_install(plugins_dir, "weather", "2.0.0")
api.plugin_catalog.plugins_dir = str(plugins_dir)
api.config_manager.load_config = MagicMock(return_value={
"clock": {"enabled": True}, "weather": {"enabled": False},
"ghost": {"enabled": True}})
api.config_manager.get_secrets_path = MagicMock(return_value=str(tmp_path / "s.json"))
api.operation_history.get_history = MagicMock(return_value=[
OperationRecord("3", "update", "clock", datetime(2026, 9, 3), "success"),
OperationRecord("2", "update", "clock", datetime(2026, 9, 2), "failed"),
OperationRecord("1", "install", "clock", datetime(2026, 9, 1), "success"),
])
observed = {"view": _live({"clock": {"loaded": True, "state": "enabled", "error": None,
"version": "1.0.0", "loaded_at": 50.0}})}
import web_interface.blueprints.api_v3 as pkg
monkeypatch.setattr(pkg, "_plugin_runtime_view", lambda: observed["view"])
return api_v3_client, observed
class TestPluginStateRoute:
def test_every_plugin_desired_and_observed(self, state_api):
client, _ = state_api
body = client.get("/api/v3/plugins/state").get_json()
assert body["status"] == "success"
assert body["runtime"]["status"] == "live"
data = body["data"]
assert set(data) == {"clock", "weather", "ghost"}
clock = data["clock"]
assert clock["status"] == "enabled"
assert (clock["installed"], clock["enabled"], clock["version"]) == (True, True, "1.1.0")
assert (clock["loaded"], clock["loaded_version"]) == (True, "1.0.0")
assert clock["installed_at"] == "2026-09-01T00:00:00"
assert clock["last_updated"] == "2026-09-03T00:00:00"
assert data["weather"]["status"] == "disabled"
assert data["weather"]["loaded"] is False
assert data["weather"]["installed_at"] is None
assert data["ghost"]["status"] == "unknown" # configured, not installed
def test_one_plugin(self, state_api):
client, _ = state_api
body = client.get("/api/v3/plugins/state?plugin_id=clock").get_json()
assert body["data"]["plugin_id"] == "clock"
response = client.get("/api/v3/plugins/state?plugin_id=nope")
assert response.status_code == 404
def test_display_not_running_leaves_observed_fields_null(self, state_api):
client, observed = state_api
observed["view"] = view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": False,
"published_at": time.time()})
body = client.get("/api/v3/plugins/state").get_json()
assert body["runtime"]["status"] == "stopped"
assert body["data"]["clock"]["loaded"] is None
assert body["data"]["clock"]["enabled"] is True
def test_reconcile_reports_what_the_display_runs(self, state_api):
client, _ = state_api
body = client.post("/api/v3/plugins/state/reconcile", json={}).get_json()
found = {(i["plugin_id"], i["type"], i["fix_action"])
for i in body["data"]["inconsistencies"]}
assert ("clock", "plugin_version_mismatch", "no_action") in found
assert ("ghost", "plugin_missing_on_disk", "manual_fix_required") in found
# Reported only; nothing the user must fix beyond the missing plugin.
assert [i["plugin_id"] for i in body["data"]["manual_fix_required"]] == ["ghost"]
+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,
)
@@ -43,7 +43,6 @@ def store(tmp_path):
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
patch.object(api_v3, 'plugin_catalog', None, create=True), \
patch.object(api_v3, 'schema_manager', None, create=True), \
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
patch.object(api_v3, 'operation_history', None, create=True):
yield sm
@@ -194,8 +194,11 @@ class TestTheWebProcessNeverRunsAPlugin:
entry = next(p for p in body["data"]["plugins"] if p["id"] == PLUGIN_ID)
assert entry["version"] == "1.0.0"
assert entry["enabled"] is True
# Not published by the display, so not invented here.
# No display has published a runtime snapshot here, so these are
# unknown rather than invented (test_plugin_runtime_snapshot.py
# covers a live one).
assert entry["loaded"] is None and entry["state"] is None
assert body["data"]["runtime"]["status"] == "unknown"
# Nothing in its files declares a participation; the display derives
# one from its hooks, which are not called here.
assert (entry["vegas_participation"], entry["vegas_participation_source"]) == (