fix(web): auto_update and other core settings are not orphaned plugins (#589)

v3.4.0 shows "Plugin Config Warning - In config but not installed:
auto_update. Reinstall via the Plugin Store, or remove these entries from
config.json." auto_update is the core weekly-update setting from #581.
Reconciliation treated every top-level dict not in its private
_SYSTEM_CONFIG_KEYS list as a plugin id, and #581 could not know to extend
that list.

- Move core top-level keys into src/core_config_keys.py (CORE_CONFIG_KEYS)
  and use it in reconciliation. Tests fail if a config.template.json key or
  a key written by the general-settings save is missing from it.
- A secrets-file key only counts as a non-plugin when no installed plugin
  has that id. Plugin secrets are namespaced by id, so installed plugins
  with secrets were reported as missing from config on every run.
- still_unresolved() drops "not on disk" findings whose id is no longer a
  plugin entry in config, so a stored verdict clears without a restart.
- A plugin whose id is a core key is skipped with a warning, and the fix
  never writes a plugin stub over or in place of a core setting.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-15 18:19:39 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent c200b5837d
commit 9616a5a054
7 changed files with 392 additions and 41 deletions
+7
View File
@@ -47,6 +47,13 @@ Web interface:
JSON API saves are unaffected. Lets plugins keep deprecated or internal keys
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
See `docs/widget-guide.md`.
- The Plugin Config Warning no longer lists core settings as plugins that are
"in config but not installed" (seen as `auto_update` on 3.4.0, where the
advice would have deleted the weekly-update setting). Core top-level config
keys now live in one list, `src/core_config_keys.py`, which reconciliation
uses and tests pin to `config.template.json` and the settings save endpoint.
A stored warning is also dropped once its entry is no longer a plugin in
config, so an old verdict clears without a restart.
- **Check & Update All** no longer sends installed Starlark apps
(`starlark:<app_id>` entries in `/plugins/installed`) to the plugin updater,
which answered each with a 500 "plugin not found". `POST /plugins/update`
+43
View File
@@ -0,0 +1,43 @@
"""Top-level config.json keys that belong to the LEDMatrix core, not to plugins.
config.json mixes two kinds of top-level section: core settings (``display``,
``schedule``, ``auto_update``, ...) and one section per plugin, keyed by plugin
id. Anything that needs to tell them apart -- plugin-state reconciliation above
all -- must use this list, not a private copy of it.
A private copy is what went wrong: #581 added a top-level ``auto_update``
section, reconciliation's own list did not know about it, and every device with
the new setting was told "In config but not installed: auto_update. Reinstall
via the Plugin Store, or remove these entries from config.json" -- advice that
deletes a real core setting.
When you add a top-level core setting, add its key here. The tests in
``test/test_core_config_keys.py`` fail if a top-level key in
``config/config.template.json``, or one written by the general-settings save
endpoint, is missing from this list.
"""
CORE_CONFIG_KEYS = frozenset({
# config/config.template.json
'web_display_autostart',
'auto_update',
'schedule',
'dim_schedule',
'timezone',
'target_fps',
'location',
'display',
'sync',
'plugin_system',
# Older or optional core sections still found in existing config files.
'logging',
'network',
'system',
'vegas_scroll_speed',
'vegas_separator_width',
'vegas_target_fps',
'vegas_buffer_ahead',
'vegas_plugin_order',
'vegas_excluded_plugins',
'vegas_scroll_enabled',
})
+49 -32
View File
@@ -14,6 +14,7 @@ from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from src.core_config_keys import CORE_CONFIG_KEYS
from src.plugin_system.state_manager import PluginStateManager
from src.logging_config import get_logger
@@ -72,9 +73,16 @@ def secrets_top_level_keys(config_manager) -> Set[str]:
return set(secrets) if isinstance(secrets, dict) else set()
def ignored_config_keys(config_manager) -> Set[str]:
"""Config keys that are not plugin ids: system keys plus secrets keys."""
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_top_level_keys(config_manager)
def ignored_config_keys(config_manager, installed_ids=frozenset()) -> Set[str]:
"""Config keys that are not plugin ids: core keys plus secrets keys.
A secrets key that is also an installed plugin's id is not ignored: plugin
secrets are namespaced by plugin id, so that key is the plugin's own entry.
Ignoring it made every installed plugin with secrets read as "installed but
missing from config" on each run.
"""
secrets_only = secrets_top_level_keys(config_manager) - set(installed_ids)
return set(StateReconciliation._SYSTEM_CONFIG_KEYS) | secrets_only
def config_plugin_ids(config: Dict[str, Any], ignored_keys: Set[str]) -> Set[str]:
@@ -132,6 +140,13 @@ def still_unresolved(entries: List[Dict[str, Any]],
plugins were all present in config kept being told, for hours, that four of
them were missing and should be removed from config.json.
An "in config but not on disk" finding is stale once the plugin is
installed, and equally once the id is no longer a plugin entry in config --
whether the user removed it or it was never a plugin (a core key such as
``auto_update`` that an older build misread). Without the second check a
verdict written by such a build kept telling users to delete a real core
setting until the next full reconciliation run.
Entry kinds this cannot re-check are kept, so filtering only ever removes
findings that are provably stale.
"""
@@ -143,7 +158,7 @@ def still_unresolved(entries: List[Dict[str, Any]],
if plugin_id not in config_keys:
live.append(entry)
elif kind == InconsistencyType.PLUGIN_MISSING_ON_DISK.value:
if plugin_id not in installed_ids:
if plugin_id in config_keys and plugin_id not in installed_ids:
live.append(entry)
else:
live.append(entry)
@@ -280,39 +295,22 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}"
)
# Top-level config keys that are NOT plugins.
# Includes both config.json structural keys and config_secrets.json top-level
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
# Multi-display sync config (config.json structural key)
'sync',
# Secrets file top-level keys (merged in by load_config)
'github', 'youtube',
})
def _secrets_top_level_keys(self) -> Set[str]:
"""Top-level keys that load_config() merges in from the secrets file.
load_config() merges config_secrets.json into the config it returns, so
those keys sit alongside plugin ids. _SYSTEM_CONFIG_KEYS named them
individually ('github', 'youtube'), which broke the moment anything else
was written there: a 'data' key became a phantom plugin, permanently
reported as "in config but not on disk". Reading the file keeps this
correct no matter what it holds.
"""
return secrets_top_level_keys(self.config_manager)
# Top-level config keys that are NOT plugins. The core keys come from the
# shared list in src/core_config_keys.py -- a private copy here missed
# #581's 'auto_update' and reported it as a plugin missing from disk.
# 'github'/'youtube' are the historical secrets-file keys. The secrets file
# itself is read at run time too (ignored_config_keys): load_config() merges
# it in, and naming its keys one by one let a 'data' key become a phantom
# plugin permanently reported as "in config but not on disk".
_SYSTEM_CONFIG_KEYS = CORE_CONFIG_KEYS | frozenset({'github', 'youtube'})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file."""
state = {}
try:
config = self.config_manager.load_config()
ignored = self._SYSTEM_CONFIG_KEYS | self._secrets_top_level_keys()
ignored = ignored_config_keys(self.config_manager,
disk_plugin_ids(self.plugins_dir))
for plugin_id in config_plugin_ids(config, ignored):
plugin_config = config[plugin_id]
state[plugin_id] = {
@@ -389,7 +387,20 @@ class StateReconciliation:
) -> List[Inconsistency]:
"""Check consistency for a single plugin."""
inconsistencies = []
if plugin_id in CORE_CONFIG_KEYS:
# A plugin whose id is a core setting's key ('display', 'sync',
# ...) can never have a config section of its own: that section
# is the core setting. Every check below would misfire -- "not in
# config" forever, and an auto-fix that writes {'enabled': False}
# where a core setting belongs -- so report it in the log instead.
if disk_state.get(plugin_id, {}).get('exists_on_disk'):
self.logger.warning(
"Plugin id %r is reserved for a core config setting; "
"the plugin cannot be configured and is skipped by "
"reconciliation. Rename the plugin.", plugin_id)
return inconsistencies
config = config_state.get(plugin_id, {})
disk = disk_state.get(plugin_id, {})
state_mgr = state_manager_state.get(plugin_id, {})
@@ -468,6 +479,12 @@ class StateReconciliation:
"""Attempt to fix an inconsistency."""
try:
if inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_IN_CONFIG:
if inconsistency.plugin_id in CORE_CONFIG_KEYS:
# Never create or replace a core setting with a plugin stub.
self.logger.warning(
"Refusing to add plugin entry %r: that key is a core "
"config setting", inconsistency.plugin_id)
return False
config = self.config_manager.load_config()
if inconsistency.plugin_id in config:
# Detection said "not in config" but it is there -- the
+270
View File
@@ -0,0 +1,270 @@
"""Core settings in config.json must never be reported as orphaned plugins.
Observed on a v3.4.0 device: the web UI showed "Plugin Config Warning -- In
config but not installed: auto_update. Reinstall via the Plugin Store, or
remove these entries from config.json." ``auto_update`` is the top-level core
section #581 added for weekly automatic updates. Reconciliation kept its own
private list of non-plugin keys, which #581 did not know to extend, so any
top-level dict it did not list was treated as a plugin id. Following the
warning's advice deletes a real core setting.
These tests pin the shared list in src/core_config_keys.py to the sources that
actually define top-level keys, so the next new core setting cannot regress it.
"""
import json
import re
import sys
from pathlib import Path
from unittest.mock import MagicMock, Mock
import pytest
from flask import Flask
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
from src.core_config_keys import CORE_CONFIG_KEYS # noqa: E402
from src.plugin_system.state_reconciliation import ( # noqa: E402
InconsistencyType,
StateReconciliation,
)
ON_DISK = InconsistencyType.PLUGIN_MISSING_ON_DISK
IN_CONFIG = InconsistencyType.PLUGIN_MISSING_IN_CONFIG
TEMPLATE = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
BUNDLED_PLUGINS = {p.parent.name for p in (REPO / "plugin-repos").glob("*/manifest.json")}
# Top-level keys of the hdpi device's config.json (v3.4.0 fddb0e06), keys only.
DEVICE_CONFIG_KEYS = [
'web_display_autostart', 'schedule', 'dim_schedule', 'timezone', 'target_fps',
'location', 'display', 'sync', 'plugin_system', 'web-ui-info', 'starlark-apps',
'football-scoreboard', 'hockey-scoreboard', 'lacrosse-scoreboard', 'afl-scoreboard',
'nrl-scoreboard', 'soccer-scoreboard', 'baseball-scoreboard', 'ledmatrix-flights',
'birdnet-go', 'geochron', 'incoming-packages', 'jellyfin-now-playing', 'tide-display',
'ledmatrix-leaderboard', 'ledmatrix-music', 'odds-ticker', 'stock-news',
'basketball-scoreboard', 'ledmatrix-stocks', 'ledmatrix-weather', 'news',
'clock-simple', 'christmas-countdown', '7-segment-clock', 'calendar', 'on-air',
'static-image', 'ledmatrix-dresden-departures', 'olympics', 'ledmatrix-elections',
'f1-scoreboard', 'hello-world', 'tidbyt-baseball-scoreboard', 'youtube-stats',
'mqtt-notifications', 'march-madness', 'pga-tour-leaderboard', 'nfl-draft',
'pomodoro-timer', 'countdown', 'gif-player', 'of-the-day', 'text-display',
'masters-tournament', 'cricket-scoreboard', 'ufc-scoreboard', 'auto_update',
]
DEVICE_SECRETS_KEYS = ['github', 'incoming-packages', 'jellyfin-now-playing']
DEVICE_INSTALLED = [k for k in DEVICE_CONFIG_KEYS if k not in CORE_CONFIG_KEYS] + [
'ledmatrix-flights.standalone-backup-migrating']
def _device_config():
"""Device-shaped config: real key set, placeholder values."""
cfg = {}
for key in DEVICE_CONFIG_KEYS:
template_value = TEMPLATE.get(key)
cfg[key] = template_value if template_value is not None else {"enabled": True}
return cfg
class _ConfigManager:
def __init__(self, config, secrets_path=None):
self._config = config
self._secrets_path = secrets_path
self.saved = []
def load_config(self):
return dict(self._config)
def save_config(self, config):
self.saved.append(dict(config))
self._config = dict(config)
def get_secrets_path(self):
return self._secrets_path
def _install(plugins_dir, plugin_id):
d = plugins_dir / plugin_id
d.mkdir(parents=True, exist_ok=True)
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": "1.0.0"}),
encoding="utf-8")
def _reconcile(tmp_path, config, installed=(), secrets=None):
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir(exist_ok=True)
for pid in installed:
_install(plugins_dir, pid)
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
state_manager = Mock()
state_manager.get_all_states.return_value = {}
plugin_manager = Mock()
plugin_manager.plugin_manifests = {}
reconciler = StateReconciliation(
state_manager=state_manager,
config_manager=_ConfigManager(config, str(secrets_path)),
plugin_manager=plugin_manager,
plugins_dir=plugins_dir,
)
return reconciler, reconciler.reconcile_state()
def _ids(result, kind):
return sorted(i.plugin_id for i in result.inconsistencies_found
if i.inconsistency_type == kind)
class TestTheListCoversEveryCoreKey:
def test_every_template_top_level_key_is_core_or_a_bundled_plugin(self):
unaccounted = [k for k in TEMPLATE
if k not in CORE_CONFIG_KEYS and k not in BUNDLED_PLUGINS]
assert unaccounted == [], (
"config.template.json has top-level keys that are neither in "
"src/core_config_keys.py nor a plugin under plugin-repos/; "
"reconciliation will report them as plugins missing from disk")
def test_the_template_keys_this_bug_was_about_are_covered(self):
for key in ("auto_update", "sync", "schedule", "dim_schedule", "location",
"display", "plugin_system", "timezone", "target_fps",
"web_display_autostart"):
assert key in CORE_CONFIG_KEYS, key
def test_keys_written_by_the_general_settings_save_are_core(self):
"""A new setting can be added to the save endpoint without touching
the template; catch that too."""
source = (REPO / "web_interface" / "blueprints" / "api_v3" / "config.py").read_text(
encoding="utf-8")
written = set(re.findall(r"current_config\[['\"]([A-Za-z0-9_-]+)['\"]\]\s*=", source))
assert "auto_update" in written, "pattern no longer finds the writes; update the test"
assert written - CORE_CONFIG_KEYS == set()
def test_no_core_key_is_also_a_bundled_plugin(self):
assert CORE_CONFIG_KEYS & BUNDLED_PLUGINS == set()
class TestReconciliationIgnoresCoreKeys:
def test_auto_update_is_not_reported(self, tmp_path):
_, result = _reconcile(tmp_path, {"auto_update": {"enabled": False}})
assert "auto_update" not in [i.plugin_id for i in result.inconsistencies_found]
def test_the_whole_template_yields_only_its_bundled_plugins(self, tmp_path):
template_plugins = {k for k in TEMPLATE if k in BUNDLED_PLUGINS}
reconciler, result = _reconcile(tmp_path, dict(TEMPLATE), installed=template_plugins)
assert result.inconsistencies_found == []
assert set(reconciler._get_config_state()) == template_plugins
@pytest.mark.parametrize("key", sorted(CORE_CONFIG_KEYS))
def test_no_core_key_is_ever_a_plugin_missing_from_disk(self, tmp_path, key):
_, result = _reconcile(tmp_path, {key: {"enabled": True}})
assert _ids(result, ON_DISK) == []
def test_a_genuinely_orphaned_plugin_is_still_reported(self, tmp_path):
config = {"auto_update": {"enabled": False}, "display": {"hardware": {}},
"ghost-plugin": {"enabled": True}, "odds-ticker": {"enabled": True}}
_, result = _reconcile(tmp_path, config, installed=["odds-ticker"])
assert _ids(result, ON_DISK) == ["ghost-plugin"]
assert [i.plugin_id for i in result.inconsistencies_manual] == ["ghost-plugin"]
def test_the_device_config_reports_nothing(self, tmp_path):
_, result = _reconcile(
tmp_path, _device_config(), installed=DEVICE_INSTALLED,
secrets={k: {"api_key": "x"} for k in DEVICE_SECRETS_KEYS})
assert result.inconsistencies_found == []
assert result.reconciliation_successful
class TestPluginSecretsDoNotHideThePlugin:
"""Plugin secrets are namespaced by plugin id in config_secrets.json. The
device had 'incoming-packages' and 'jellyfin-now-playing' there; ignoring
every secrets key reported both installed plugins as missing from config on
every run (auto-"fixed" as a no-op, so it never reached the banner)."""
def test_installed_plugin_with_secrets_is_a_plugin(self, tmp_path):
config = {"incoming-packages": {"enabled": True, "api_key": "x"}}
reconciler, result = _reconcile(tmp_path, config, installed=["incoming-packages"],
secrets={"incoming-packages": {"api_key": "x"}})
assert result.inconsistencies_found == []
assert "incoming-packages" in reconciler._get_config_state()
def test_a_secrets_only_key_is_still_not_a_plugin(self, tmp_path):
# The #557 phantom: a non-plugin 'data' key in the secrets file.
_, result = _reconcile(tmp_path, {"data": {"mode": "nfl_recent"}},
secrets={"data": {"mode": "nfl_recent"}})
assert result.inconsistencies_found == []
class TestAPluginNamedLikeACoreKey:
"""A plugin whose id collides with a core key has no config section of its
own. Reconciliation must neither nag about it nor write a stub over (or
in place of) the core setting."""
def test_installed_plugin_named_like_a_core_key_is_not_reported(self, tmp_path):
config = {"sync": {"role": "standalone"}}
reconciler, result = _reconcile(tmp_path, config, installed=["sync"])
assert result.inconsistencies_found == []
assert reconciler.config_manager.saved == []
assert reconciler.config_manager.load_config() == config
def test_core_key_absent_from_config_is_not_stubbed(self, tmp_path):
reconciler, result = _reconcile(tmp_path, {}, installed=["display"])
assert result.inconsistencies_found == []
assert reconciler.config_manager.saved == []
def test_fix_refuses_to_write_a_core_key(self, tmp_path):
reconciler, _ = _reconcile(tmp_path, {})
inc = Mock(inconsistency_type=IN_CONFIG, plugin_id="auto_update")
assert reconciler._fix_inconsistency(inc) is False
assert reconciler.config_manager.saved == []
class TestTheStatusEndpoint:
@pytest.fixture
def get_status(self, tmp_path, monkeypatch):
import tempfile
from web_interface.blueprints.api_v3 import api_v3
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
def _get(verdict, config, installed=(), secrets=None):
(tmp_path / "ledmatrix_reconciliation.json").write_text(
json.dumps(verdict), encoding="utf-8")
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir(exist_ok=True)
for pid in installed:
_install(plugins_dir, pid)
secrets_path = tmp_path / "config_secrets.json"
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
cm = MagicMock()
cm.load_config.return_value = config
cm.get_secrets_path.return_value = str(secrets_path)
pm = MagicMock()
pm.plugins_dir = str(plugins_dir)
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
resp = app.test_client().get("/api/v3/plugins/reconciliation-status")
return resp.get_json()["data"]
return _get
def test_the_verdict_the_device_served_now_clears(self, get_status):
"""The exact stored verdict from the device, written by the old build,
is dropped against the device's config -- no restart needed."""
verdict = {"done": True, "fixed_count": 3, "successful": False, "unresolved": [
{"description": "Plugin auto_update in config but not on disk",
"plugin_id": "auto_update", "type": ON_DISK.value}]}
data = get_status(verdict, _device_config(), installed=DEVICE_INSTALLED,
secrets={k: {} for k in DEVICE_SECRETS_KEYS})
assert data["unresolved"] == []
def test_a_real_orphan_still_reaches_the_banner(self, get_status):
verdict = {"done": True, "unresolved": [
{"plugin_id": "auto_update", "type": ON_DISK.value},
{"plugin_id": "ghost-plugin", "type": ON_DISK.value}]}
config = _device_config()
config["ghost-plugin"] = {"enabled": True}
data = get_status(verdict, config, installed=DEVICE_INSTALLED)
assert [e["plugin_id"] for e in data["unresolved"]] == ["ghost-plugin"]
+11 -6
View File
@@ -142,8 +142,14 @@ class TestStaleFindingsAreDropped:
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
def test_genuinely_missing_on_disk_is_kept(self):
entries = [{"plugin_id": "data", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == entries
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
assert still_unresolved(entries, {"ghost-plugin"}, {"odds-ticker"}) == entries
def test_missing_on_disk_no_longer_in_config_is_dropped(self):
# Removed from config.json (or never a plugin entry at all, like a
# core key an older build misread): nothing left to reinstall.
entries = [{"plugin_id": "ghost-plugin", "type": self.ON_DISK}]
assert still_unresolved(entries, set(), {"odds-ticker"}) == []
def test_unrecheckable_kinds_are_kept(self):
# Filtering must only ever remove what it can prove stale.
@@ -164,7 +170,6 @@ class TestStaleFindingsAreDropped:
live = still_unresolved(entries, installed, installed)
# All four "installed but not in config" findings were false and clear.
# 'data' legitimately is not installed, so this filter keeps it; it stops
# being reported because _get_config_state() no longer invents it from the
# secrets file, which takes effect on the next reconciliation run.
assert [e["plugin_id"] for e in live] == ["data"]
# 'data' is not a plugin entry in config (it came from the secrets
# file), so its "not on disk" finding is provably stale and clears too.
assert live == []
+10 -1
View File
@@ -94,9 +94,16 @@ class TestStaleFindingsAreDroppedByTheEndpoint:
def test_a_finding_that_still_holds_is_kept(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
config={"ghost-plugin": {"enabled": True}})
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
def test_a_plugin_removed_from_config_is_no_longer_reported(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "ghost-plugin", "type": ON_DISK}]},
config={"other-plugin": {"enabled": True}})
assert _get(c)["unresolved"] == []
def test_the_reported_device_verdict_clears(self, client):
"""The five findings the live device served, against its real state."""
installed = ["football-scoreboard", "ledmatrix-weather",
@@ -157,11 +164,13 @@ class TestTheFilterUsesTheReconcilersOwnRules:
# finding on the strength of something nothing can read.
c = client({"done": True, "unresolved": [
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
config={"broken-plugin": {"enabled": True}},
corrupt=["broken-plugin"])
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
c = client({"done": True, "unresolved": [
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
config={"weather.standalone-backup-20260101": {"enabled": True}},
installed=["weather.standalone-backup-20260101"])
assert len(_get(c)["unresolved"]) == 1
+2 -2
View File
@@ -735,10 +735,10 @@ def _drop_stale_reconciliation_findings(unresolved):
)
cm = api_v3.config_manager
config_keys = config_plugin_ids(cm.load_config() or {},
ignored_config_keys(cm))
plugins_dir = getattr(api_v3.plugin_manager, 'plugins_dir', None)
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
config_keys = config_plugin_ids(cm.load_config() or {},
ignored_config_keys(cm, installed))
return still_unresolved(unresolved, config_keys, installed)
except Exception:
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)