From 2619c0d893553b2e648df140903a4f9a06b5298f Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 25 Mar 2026 14:49:36 -0400 Subject: [PATCH 1/8] fix: auto-repair missing plugins and graceful config fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins whose directories are missing (failed update, migration, etc.) now get automatically reinstalled from the store on startup. The config endpoint no longer returns a hard 500 when a schema is unavailable — it falls back to conservative key-name-based masking so the settings page stays functional. Co-Authored-By: Claude Opus 4.6 --- src/plugin_system/state_reconciliation.py | 73 +++++++++++++++++++---- web_interface/app.py | 32 +++++++++- web_interface/blueprints/api_v3.py | 39 +++++++----- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index 93edb86b3..f1e68b8c3 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -67,21 +67,24 @@ def __init__( state_manager: PluginStateManager, config_manager, plugin_manager, - plugins_dir: Path + plugins_dir: Path, + store_manager=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 """ 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.logger = get_logger(__name__) def reconcile_state(self) -> ReconciliationResult: @@ -160,18 +163,34 @@ def reconcile_state(self) -> ReconciliationResult: message=f"Reconciliation failed: {str(e)}" ) + # Top-level config keys that are NOT plugins + _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', + }) + def _get_config_state(self) -> Dict[str, Dict[str, Any]]: """Get plugin state from config file.""" state = {} try: config = self.config_manager.load_config() for plugin_id, plugin_config in config.items(): - if isinstance(plugin_config, dict): - state[plugin_id] = { - 'enabled': plugin_config.get('enabled', False), - 'version': plugin_config.get('version'), - 'exists_in_config': True - } + if not isinstance(plugin_config, dict): + continue + if plugin_id in self._SYSTEM_CONFIG_KEYS: + continue + if 'enabled' not in plugin_config: + continue + if '.standalone-backup-' in plugin_id: + continue + state[plugin_id] = { + 'enabled': plugin_config.get('enabled', False), + 'version': plugin_config.get('version'), + 'exists_in_config': True + } except Exception as e: self.logger.warning(f"Error reading config state: {e}") return state @@ -263,14 +282,15 @@ def _check_plugin_consistency( # Check: Plugin in config but not on disk if config.get('exists_in_config') and not disk.get('exists_on_disk'): + can_repair = self.store_manager is not None inconsistencies.append(Inconsistency( plugin_id=plugin_id, inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK, description=f"Plugin {plugin_id} in config but not on disk", - fix_action=FixAction.MANUAL_FIX_REQUIRED, + fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED, current_state={'exists_on_disk': False}, expected_state={'exists_on_disk': True}, - can_auto_fix=False + can_auto_fix=can_repair )) # Check: Enabled state mismatch @@ -303,6 +323,9 @@ def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool: self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config") return True + 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: # Sync enabled state from state manager to config expected_enabled = inconsistency.expected_state.get('enabled') @@ -317,6 +340,34 @@ def _fix_inconsistency(self, inconsistency: Inconsistency) -> bool: except Exception as e: self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True) return False - + + return False + + def _auto_repair_missing_plugin(self, plugin_id: str) -> bool: + """Attempt to reinstall a missing plugin from the store.""" + if not self.store_manager: + return False + + # Try the plugin_id as-is, then without 'ledmatrix-' prefix + candidates = [plugin_id] + if plugin_id.startswith('ledmatrix-'): + candidates.append(plugin_id[len('ledmatrix-'):]) + + for candidate_id in candidates: + try: + self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id) + result = self.store_manager.install_plugin(candidate_id) + if isinstance(result, dict): + success = result.get('success', False) + else: + success = bool(result) + + if success: + self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id) + return True + except Exception as e: + self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True) + + self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id) return False diff --git a/web_interface/app.py b/web_interface/app.py index 7b1868335..67ab420cb 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -651,12 +651,40 @@ def _initialize_health_monitor(): _health_monitor_initialized = True -# Initialize health monitor on first request (using before_request for compatibility) +_reconciliation_done = False + +def _run_startup_reconciliation(): + """Run state reconciliation on startup to auto-repair missing plugins.""" + global _reconciliation_done + if _reconciliation_done: + return + _reconciliation_done = True + + try: + from src.plugin_system.state_reconciliation import StateReconciliation + reconciler = StateReconciliation( + state_manager=plugin_state_manager, + config_manager=config_manager, + plugin_manager=plugin_manager, + plugins_dir=plugins_dir, + store_manager=plugin_store_manager + ) + result = reconciler.reconcile_state() + if result.inconsistencies_found: + print(f"[Reconciliation] {result.message}") + if result.inconsistencies_fixed: + plugin_manager.discover_plugins() + except Exception as e: + print(f"[Reconciliation] Error: {e}") + +# Initialize health monitor and run reconciliation on first request @app.before_request def check_health_monitor(): - """Ensure health monitor is initialized on first request.""" + """Ensure health monitor and reconciliation run on first request.""" if not _health_monitor_initialized: _initialize_health_monitor() + if not _reconciliation_done: + _run_startup_reconciliation() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index fc6675908..f232caf0b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -33,6 +33,23 @@ separate_secrets, ) +import re + +_SECRET_KEY_PATTERN = re.compile( + r'(api_key|api_secret|password|secret|token|auth_key|credential)', + re.IGNORECASE, +) + +def _conservative_mask_config(config): + """Mask string values whose keys look like secrets (no schema available).""" + result = dict(config) + for key, value in result.items(): + if isinstance(value, dict): + result[key] = _conservative_mask_config(value) + elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key): + result[key] = '' + return result + # Will be initialized when blueprint is registered config_manager = None plugin_manager = None @@ -2505,24 +2522,14 @@ def get_plugin_config(): } # Mask secret fields before returning to prevent exposing API keys - # Fail closed — if schema unavailable, refuse to return unmasked config schema_mgr = api_v3.schema_manager - if not schema_mgr: - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - f"Cannot safely return config for {plugin_id}: schema manager unavailable", - status_code=500 - ) - - schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) - if not schema_for_mask or 'properties' not in schema_for_mask: - return error_response( - ErrorCode.CONFIG_LOAD_FAILED, - f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking", - status_code=500 - ) + schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) if schema_mgr else None - plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties']) + if schema_for_mask and 'properties' in schema_for_mask: + plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties']) + else: + logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id) + plugin_config = _conservative_mask_config(plugin_config) return success_response(data=plugin_config) except Exception as e: From 3d96026e871eebc20fa9a55e2dabeb6492131d4e Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 25 Mar 2026 14:54:52 -0400 Subject: [PATCH 2/8] fix: handle ledmatrix- prefix in plugin updates and reconciliation The store registry uses unprefixed IDs (e.g., 'weather') while older installs used prefixed config keys (e.g., 'ledmatrix-weather'). Both update_plugin() and auto-repair now try the unprefixed ID as a fallback when the prefixed one isn't found in the registry. Also filters system config keys (schedule, display, etc.) from reconciliation to avoid false positives. Co-Authored-By: Claude Opus 4.6 --- src/plugin_system/store_manager.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 3ea386fa0..e6a65d807 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1784,6 +1784,12 @@ def update_plugin(self, plugin_id: str) -> bool: # Try to get remote info from registry (optional) self.fetch_registry(force_refresh=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True) + # Try without 'ledmatrix-' prefix (monorepo migration) + if not plugin_info_remote and plugin_id.startswith('ledmatrix-'): + alt_id = plugin_id[len('ledmatrix-'):] + plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True) + if plugin_info_remote: + self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}") remote_branch = None remote_sha = None @@ -2058,7 +2064,16 @@ def update_plugin(self, plugin_id: str) -> bool: self.logger.info(f"Plugin {plugin_id} is not a git repository, checking registry...") self.fetch_registry(force_refresh=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True) - + + # If not found, try without 'ledmatrix-' prefix (monorepo migration) + registry_id = plugin_id + if not plugin_info_remote and plugin_id.startswith('ledmatrix-'): + alt_id = plugin_id[len('ledmatrix-'):] + plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True) + if plugin_info_remote: + registry_id = alt_id + self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}") + # If not in registry but we have a repo URL, try reinstalling from that URL if not plugin_info_remote and repo_url: self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...") @@ -2111,13 +2126,13 @@ def update_plugin(self, plugin_id: str) -> bool: self.logger.debug(f"Could not compare versions for {plugin_id}: {e}") # Plugin is not a git repo but is in registry and has a newer version - reinstall - self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive") + self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})") # Remove directory and reinstall fresh if not self._safe_remove_directory(plugin_path): self.logger.error(f"Failed to remove old plugin directory for {plugin_id}") return False - return self.install_plugin(plugin_id) + return self.install_plugin(registry_id) except Exception as e: import traceback From 73109f73f73b0c88c7cf875864f5d70dbca4c0ec Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 25 Mar 2026 15:19:55 -0400 Subject: [PATCH 3/8] fix: address code review findings for plugin auto-repair - Move backup-folder filter from _get_config_state to _get_disk_state where the artifact actually lives - Run startup reconciliation in a background thread so requests aren't blocked by plugin reinstallation - Set _reconciliation_done only after success so failures allow retries - Replace print() with proper logger in reconciliation - Wrap load_schema in try/except so exceptions fall through to conservative masking instead of 500 - Handle list values in _conservative_mask_config for nested secrets - Remove duplicate import re Co-Authored-By: Claude Opus 4.6 --- src/plugin_system/state_reconciliation.py | 4 ++-- web_interface/app.py | 22 +++++++++++++--------- web_interface/blueprints/api_v3.py | 19 +++++++++++++++---- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index f1e68b8c3..e549090ad 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -184,8 +184,6 @@ def _get_config_state(self) -> Dict[str, Dict[str, Any]]: continue if 'enabled' not in plugin_config: continue - if '.standalone-backup-' in plugin_id: - continue state[plugin_id] = { 'enabled': plugin_config.get('enabled', False), 'version': plugin_config.get('version'), @@ -203,6 +201,8 @@ def _get_disk_state(self) -> Dict[str, Dict[str, Any]]: for plugin_dir in self.plugins_dir.iterdir(): if plugin_dir.is_dir(): plugin_id = plugin_dir.name + if '.standalone-backup-' in plugin_id: + continue manifest_path = plugin_dir / "manifest.json" if manifest_path.exists(): import json diff --git a/web_interface/app.py b/web_interface/app.py index 67ab420cb..96b8d1691 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -652,13 +652,13 @@ def _initialize_health_monitor(): _health_monitor_initialized = True _reconciliation_done = False +_reconciliation_started = False def _run_startup_reconciliation(): - """Run state reconciliation on startup to auto-repair missing plugins.""" + """Run state reconciliation in background to auto-repair missing plugins.""" global _reconciliation_done - if _reconciliation_done: - return - _reconciliation_done = True + from src.logging_config import get_logger + _logger = get_logger('reconciliation') try: from src.plugin_system.state_reconciliation import StateReconciliation @@ -671,20 +671,24 @@ def _run_startup_reconciliation(): ) result = reconciler.reconcile_state() if result.inconsistencies_found: - print(f"[Reconciliation] {result.message}") + _logger.info("[Reconciliation] %s", result.message) if result.inconsistencies_fixed: plugin_manager.discover_plugins() + _reconciliation_done = True except Exception as e: - print(f"[Reconciliation] Error: {e}") + _logger.error("[Reconciliation] Error: %s", e, exc_info=True) # Initialize health monitor and run reconciliation on first request @app.before_request def check_health_monitor(): - """Ensure health monitor and reconciliation run on first request.""" + """Ensure health monitor is initialized; launch reconciliation in background.""" + global _reconciliation_started if not _health_monitor_initialized: _initialize_health_monitor() - if not _reconciliation_done: - _run_startup_reconciliation() + if not _reconciliation_started: + _reconciliation_started = True + import threading + threading.Thread(target=_run_startup_reconciliation, daemon=True).start() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index f232caf0b..6a5091cd4 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -33,19 +33,25 @@ separate_secrets, ) -import re - _SECRET_KEY_PATTERN = re.compile( r'(api_key|api_secret|password|secret|token|auth_key|credential)', re.IGNORECASE, ) -def _conservative_mask_config(config): +def _conservative_mask_config(config, _parent_key=None): """Mask string values whose keys look like secrets (no schema available).""" + if isinstance(config, list): + return [ + _conservative_mask_config(item, _parent_key) if isinstance(item, (dict, list)) + else ('' if isinstance(item, str) and item and _parent_key and _SECRET_KEY_PATTERN.search(_parent_key) else item) + for item in config + ] result = dict(config) for key, value in result.items(): if isinstance(value, dict): result[key] = _conservative_mask_config(value) + elif isinstance(value, list): + result[key] = _conservative_mask_config(value, key) elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key): result[key] = '' return result @@ -2523,7 +2529,12 @@ def get_plugin_config(): # Mask secret fields before returning to prevent exposing API keys schema_mgr = api_v3.schema_manager - schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) if schema_mgr else None + schema_for_mask = None + if schema_mgr: + try: + schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) + except Exception as e: + logger.error("[PluginConfig] Error loading schema for %s: %s", plugin_id, e, exc_info=True) if schema_for_mask and 'properties' in schema_for_mask: plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties']) From 6ff7fcba8d97147ae1f4d8b693dde3948ca758a7 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 25 Mar 2026 15:47:05 -0400 Subject: [PATCH 4/8] fix: add thread-safe locking to PluginManager and fix reconciliation retry PluginManager thread safety: - Add RLock protecting plugin_manifests and plugin_directories - Build scan results locally in _scan_directory_for_plugins, then update shared state under lock - Protect reads in get_plugin_info, get_all_plugin_info, get_plugin_directory, get_plugin_display_modes, find_plugin_for_mode - Protect manifest mutation in reload_plugin - Prevents races between background reconciliation thread and request handlers reading plugin state Reconciliation retry: - Clear _reconciliation_started on exception so next request retries - Check result.reconciliation_successful before marking done - Reset _reconciliation_started on non-success results to allow retry Co-Authored-By: Claude Opus 4.6 --- src/plugin_system/plugin_manager.py | 65 +++++++++++++++++++---------- web_interface/app.py | 13 ++++-- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index b746164af..dc767eae8 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -14,6 +14,7 @@ import sys import subprocess import time +import threading from pathlib import Path from typing import Dict, List, Optional, Any import logging @@ -74,6 +75,10 @@ def __init__(self, plugins_dir: str = "plugins", self.state_manager = PluginStateManager(logger=self.logger) self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger) + # Lock protecting plugin_manifests and plugin_directories from + # concurrent mutation (background reconciliation) and reads (requests). + self._discovery_lock = threading.RLock() + # Active plugins self.plugins: Dict[str, Any] = {} self.plugin_manifests: Dict[str, Dict[str, Any]] = {} @@ -94,23 +99,27 @@ def __init__(self, plugins_dir: str = "plugins", def _scan_directory_for_plugins(self, directory: Path) -> List[str]: """ Scan a directory for plugins. - + Args: directory: Directory to scan - + Returns: List of plugin IDs found """ plugin_ids = [] - + if not directory.exists(): return plugin_ids - + + # Build new state locally before acquiring lock + new_manifests: Dict[str, Dict[str, Any]] = {} + new_directories: Dict[str, Path] = {} + try: for item in directory.iterdir(): if not item.is_dir(): continue - + manifest_path = item / "manifest.json" if manifest_path.exists(): try: @@ -119,18 +128,21 @@ def _scan_directory_for_plugins(self, directory: Path) -> List[str]: plugin_id = manifest.get('id') if plugin_id: plugin_ids.append(plugin_id) - self.plugin_manifests[plugin_id] = manifest - - # Store directory mapping - if not hasattr(self, 'plugin_directories'): - self.plugin_directories = {} - self.plugin_directories[plugin_id] = item + new_manifests[plugin_id] = manifest + new_directories[plugin_id] = item except (json.JSONDecodeError, PermissionError, OSError) as e: self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True) continue except (OSError, PermissionError) as e: self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True) - + + # Update shared state under lock + with self._discovery_lock: + self.plugin_manifests.update(new_manifests) + if not hasattr(self, 'plugin_directories'): + self.plugin_directories = {} + self.plugin_directories.update(new_directories) + return plugin_ids def discover_plugins(self) -> List[str]: @@ -459,7 +471,9 @@ def reload_plugin(self, plugin_id: str) -> bool: if manifest_path.exists(): try: with open(manifest_path, 'r', encoding='utf-8') as f: - self.plugin_manifests[plugin_id] = json.load(f) + manifest = json.load(f) + with self._discovery_lock: + self.plugin_manifests[plugin_id] = manifest except Exception as e: self.logger.error("Error reading manifest: %s", e, exc_info=True) return False @@ -506,10 +520,11 @@ def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]: Returns: Dict with plugin information or None if not found """ - manifest = self.plugin_manifests.get(plugin_id) + with self._discovery_lock: + manifest = self.plugin_manifests.get(plugin_id) if not manifest: return None - + info = manifest.copy() # Add runtime information if plugin is loaded @@ -533,7 +548,9 @@ def get_all_plugin_info(self) -> List[Dict[str, Any]]: Returns: List of plugin info dictionaries """ - return [info for info in [self.get_plugin_info(pid) for pid in self.plugin_manifests.keys()] if info] + with self._discovery_lock: + pids = list(self.plugin_manifests.keys()) + return [info for info in [self.get_plugin_info(pid) for pid in pids] if info] def get_plugin_directory(self, plugin_id: str) -> Optional[str]: """ @@ -545,8 +562,9 @@ def get_plugin_directory(self, plugin_id: str) -> Optional[str]: Returns: Directory path as string or None if not found """ - if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories: - return str(self.plugin_directories[plugin_id]) + with self._discovery_lock: + if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories: + return str(self.plugin_directories[plugin_id]) plugin_dir = self.plugins_dir / plugin_id if plugin_dir.exists(): @@ -568,10 +586,11 @@ def get_plugin_display_modes(self, plugin_id: str) -> List[str]: Returns: List of display mode names """ - manifest = self.plugin_manifests.get(plugin_id) + with self._discovery_lock: + manifest = self.plugin_manifests.get(plugin_id) if not manifest: return [] - + display_modes = manifest.get('display_modes', []) if isinstance(display_modes, list): return display_modes @@ -588,12 +607,14 @@ def find_plugin_for_mode(self, mode: str) -> Optional[str]: Plugin identifier or None if not found. """ normalized_mode = mode.strip().lower() - for plugin_id, manifest in self.plugin_manifests.items(): + with self._discovery_lock: + manifests_snapshot = dict(self.plugin_manifests) + for plugin_id, manifest in manifests_snapshot.items(): display_modes = manifest.get('display_modes') if isinstance(display_modes, list) and display_modes: if any(m.lower() == normalized_mode for m in display_modes): return plugin_id - + return None def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]: diff --git a/web_interface/app.py b/web_interface/app.py index 96b8d1691..4782cbfec 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -656,7 +656,7 @@ def _initialize_health_monitor(): def _run_startup_reconciliation(): """Run state reconciliation in background to auto-repair missing plugins.""" - global _reconciliation_done + global _reconciliation_done, _reconciliation_started from src.logging_config import get_logger _logger = get_logger('reconciliation') @@ -672,11 +672,16 @@ def _run_startup_reconciliation(): result = reconciler.reconcile_state() if result.inconsistencies_found: _logger.info("[Reconciliation] %s", result.message) - if result.inconsistencies_fixed: - plugin_manager.discover_plugins() - _reconciliation_done = True + if result.reconciliation_successful: + if result.inconsistencies_fixed: + plugin_manager.discover_plugins() + _reconciliation_done = True + else: + _logger.warning("[Reconciliation] Finished with unresolved issues, will retry") + _reconciliation_started = False except Exception as e: _logger.error("[Reconciliation] Error: %s", e, exc_info=True) + _reconciliation_started = False # Initialize health monitor and run reconciliation on first request @app.before_request From 8aab15d83c4e20a9ae34aec00b44cc7f6f8878e6 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Thu, 26 Mar 2026 09:36:27 -0400 Subject: [PATCH 5/8] fix(web): use HTMX for Plugin Manager tab loading instead of custom fetch The Plugin Manager tab was the only tab using a custom window.loadPluginsTab() function with plain fetch() instead of HTMX. This caused a race condition where plugins_manager.js listened for htmx:afterSwap to initialize, but that event never fired for the custom fetch. Users had to navigate to a plugin config tab and back to trigger initialization. Changes: - Switch plugins tab to hx-get/hx-trigger="revealed" matching all other tabs - Remove ~560 lines of dead code (script extraction for a partial with no scripts, nested retry intervals, inline HTML card rendering fallbacks) - Add simple loadPluginsDirect() fallback for when HTMX fails to load - Remove typeof htmx guard on afterSwap listener so it registers unconditionally - Tighten afterSwap target check to avoid spurious re-init from other tab swaps Co-Authored-By: Claude Opus 4.6 (1M context) --- web_interface/static/v3/plugins_manager.js | 29 +- web_interface/templates/v3/base.html | 608 ++------------------- 2 files changed, 60 insertions(+), 577 deletions(-) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 549d58cea..8a27f43f5 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1157,21 +1157,20 @@ function initializePluginPageWhenReady() { // Strategy 3: HTMX afterSwap event (for HTMX-loaded content) // This is the primary way plugins content is loaded - if (typeof htmx !== 'undefined') { - document.body.addEventListener('htmx:afterSwap', function(event) { - const target = event.detail.target; - // Check if plugins content was swapped in - if (target.id === 'plugins-content' || - target.querySelector('#installed-plugins-grid') || - document.getElementById('installed-plugins-grid')) { - console.log('HTMX swap detected for plugins, initializing...'); - // Reset initialization flag to allow re-initialization after HTMX swap - window.pluginManager.initialized = false; - window.pluginManager.initializing = false; - initTimer = setTimeout(attemptInit, 100); - } - }, { once: false }); // Allow multiple swaps - } + // Register unconditionally — HTMX may load after this script (loaded dynamically from CDN) + // CustomEvent listeners work even before HTMX is available + document.body.addEventListener('htmx:afterSwap', function(event) { + const target = event.detail.target; + // Check if plugins content was swapped in (only match direct plugins content targets) + if (target.id === 'plugins-content' || + target.querySelector('#installed-plugins-grid')) { + console.log('HTMX swap detected for plugins, initializing...'); + // Reset initialization flag to allow re-initialization after HTMX swap + window.pluginManager.initialized = false; + window.pluginManager.initializing = false; + initTimer = setTimeout(attemptInit, 100); + } + }, { once: false }); // Allow multiple swaps })(); // Initialization guard to prevent multiple initializations diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index c1a5139e9..24effcd47 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -391,566 +391,51 @@ icon.classList.add('fa-chevron-right'); } }; - - // Function to load plugins tab - window.loadPluginsTab = function() { - const content = document.getElementById('plugins-content'); - if (content && !content.hasAttribute('data-loaded')) { - content.setAttribute('data-loaded', 'true'); - console.log('Loading plugins directly via fetch...'); - - fetch('/v3/partials/plugins') - .then(r => r.text()) - .then(html => { - // Parse HTML into a temporary container to extract scripts - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - // Extract scripts BEFORE inserting into DOM (browser may remove them) - const scripts = Array.from(tempDiv.querySelectorAll('script')); - console.log('Found', scripts.length, 'scripts to execute'); - - // Insert content WITHOUT scripts first - const scriptsToExecute = []; - scripts.forEach(script => { - scriptsToExecute.push({ - content: script.textContent || script.innerHTML, - src: script.src, - type: script.type - }); - script.remove(); // Remove from temp div - }); - - // Now insert the HTML (without scripts) - content.innerHTML = tempDiv.innerHTML; - console.log('Plugins HTML loaded, executing', scriptsToExecute.length, 'scripts...'); - - // Execute scripts manually - ensure they run properly - if (scriptsToExecute.length > 0) { - try { - scriptsToExecute.forEach((scriptData, index) => { - try { - // Skip if script has no content and no src - const scriptContent = scriptData.content ? scriptData.content.trim() : ''; - if (!scriptContent && !scriptData.src) { - return; - } - - // Log script info for debugging - if (scriptContent) { - const preview = scriptContent.substring(0, 100).replace(/\n/g, ' '); - console.log(`[SCRIPT ${index + 1}] Content preview: ${preview}... (${scriptContent.length} chars)`); - - // Check if this script defines our critical functions - if (scriptContent.includes('window.configurePlugin') || scriptContent.includes('window.togglePlugin')) { - console.log(`[SCRIPT ${index + 1}] ⚠️ This script should define configurePlugin/togglePlugin!`); - } - } - - // Only execute if we have valid content - if (scriptContent || scriptData.src) { - // For inline scripts, use appendChild for reliable execution - if (scriptContent && !scriptData.src) { - // For very large scripts (>100KB), try fallback methods first - // as appendChild can sometimes have issues with large scripts - const isLargeScript = scriptContent.length > 100000; - - if (isLargeScript) { - console.log(`[SCRIPT ${index + 1}] Large script detected (${scriptContent.length} chars), trying fallback methods first...`); - - // Try Function constructor first for large scripts - let executed = false; - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via Function constructor`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message); - } - - // If Function constructor failed, try indirect eval - if (!executed) { - try { - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via indirect eval`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message); - } - } - - // If both fallbacks worked, skip appendChild - if (executed) { - // Verify functions were defined - setTimeout(() => { - console.log(`[SCRIPT ${index + 1}] After fallback execution:`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin - }); - }, 50); - return; // Skip to next script (use return, not continue, in forEach) - } - } - - try { - // Create new script element and append to head/body - // This ensures proper execution context and window attachment - const newScript = document.createElement('script'); - if (scriptData.type) { - newScript.type = scriptData.type; - } - - // Wrap in a promise to wait for execution - const scriptPromise = new Promise((resolve, reject) => { - // Set up error handler - newScript.onerror = (error) => { - reject(error); - }; - - // For inline scripts, execution happens synchronously when appended - // But we'll use a small delay to ensure it completes - try { - // Set textContent (not innerHTML) to avoid execution issues - // Note: We can't wrap in try-catch here as it would interfere with the script - // Instead, we rely on the script's own error handling - newScript.textContent = scriptContent; - - // Append to head for better execution context - const target = document.head || document.body; - if (target) { - // Set up error handler to catch execution errors - newScript.onerror = (error) => { - console.error(`[SCRIPT ${index + 1}] Execution error:`, error); - reject(error); - }; - - // Check before execution - const beforeConfigurePlugin = typeof window.configurePlugin === 'function'; - const beforeTogglePlugin = typeof window.togglePlugin === 'function'; - - // Declare variables in outer scope so setTimeout can access them - let afterConfigurePlugin = beforeConfigurePlugin; - let afterTogglePlugin = beforeTogglePlugin; - - // Append and execute (execution is synchronous for inline scripts) - // Wrap in try-catch to catch any execution errors - try { - target.appendChild(newScript); - - // Check immediately after append (inline scripts execute synchronously) - afterConfigurePlugin = typeof window.configurePlugin === 'function'; - afterTogglePlugin = typeof window.togglePlugin === 'function'; - - console.log(`[SCRIPT ${index + 1}] Immediate check after appendChild:`, { - configurePlugin: { before: beforeConfigurePlugin, after: afterConfigurePlugin }, - togglePlugin: { before: beforeTogglePlugin, after: afterTogglePlugin } - }); - } catch (appendError) { - console.error(`[SCRIPT ${index + 1}] Error during appendChild:`, appendError); - console.error(`[SCRIPT ${index + 1}] Error message:`, appendError.message); - console.error(`[SCRIPT ${index + 1}] Error stack:`, appendError.stack); - - // Try fallback execution methods immediately - console.warn(`[SCRIPT ${index + 1}] Attempting fallback execution methods...`); - let executed = false; - - // Method 1: Function constructor - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] ✓ Executed via Function constructor (fallback)`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message); - if (funcError.stack) { - console.warn(`[SCRIPT ${index + 1}] Function constructor stack:`, funcError.stack); - } - // Try to find the line number if available - if (funcError.message.includes('line')) { - const lineMatch = funcError.message.match(/line (\d+)/); - if (lineMatch) { - const lineNum = parseInt(lineMatch[1]); - const lines = scriptContent.split('\n'); - const start = Math.max(0, lineNum - 5); - const end = Math.min(lines.length, lineNum + 5); - console.warn(`[SCRIPT ${index + 1}] Context around error (lines ${start}-${end}):`, - lines.slice(start, end).join('\n')); - } - } - } - - // Method 2: Indirect eval - if (!executed) { - try { - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] ✓ Executed via indirect eval (fallback)`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message); - if (evalError.stack) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval stack:`, evalError.stack); - } - } - } - - // Check if functions are now defined - const fallbackConfigurePlugin = typeof window.configurePlugin === 'function'; - const fallbackTogglePlugin = typeof window.togglePlugin === 'function'; - - console.log(`[SCRIPT ${index + 1}] After fallback attempts:`, { - configurePlugin: fallbackConfigurePlugin, - togglePlugin: fallbackTogglePlugin, - executed: executed - }); - - if (!executed) { - reject(appendError); - } else { - resolve(); - } - } - - // Also check after a small delay to catch any async definitions - setTimeout(() => { - const delayedConfigurePlugin = typeof window.configurePlugin === 'function'; - const delayedTogglePlugin = typeof window.togglePlugin === 'function'; - - // Use the variables from the outer scope - if (delayedConfigurePlugin !== afterConfigurePlugin || delayedTogglePlugin !== afterTogglePlugin) { - console.log(`[SCRIPT ${index + 1}] Functions appeared after delay:`, { - configurePlugin: { immediate: afterConfigurePlugin, delayed: delayedConfigurePlugin }, - togglePlugin: { immediate: afterTogglePlugin, delayed: delayedTogglePlugin } - }); - } - - resolve(); - }, 100); // Small delay to catch any async definitions - } else { - reject(new Error('No target found for script execution')); - } - } catch (appendError) { - reject(appendError); - } - }); - - // Wait for script to execute (with timeout) - Promise.race([ - scriptPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Script execution timeout')), 1000)) - ]).catch(error => { - console.warn(`[SCRIPT ${index + 1}] Script execution issue, trying fallback:`, error); - // Fallback: try multiple execution methods - let executed = false; - - // Method 1: Function constructor with window in scope - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] Executed via Function constructor (fallback method 1)`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError); - } - - // Method 2: Direct eval in global scope (if method 1 failed) - if (!executed) { - try { - // Use indirect eval to execute in global scope - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] Executed via indirect eval (fallback method 2)`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError); - } - } - - // Verify functions after fallback - setTimeout(() => { - console.log(`[SCRIPT ${index + 1}] After fallback execution:`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin, - executed: executed - }); - }, 10); - - if (!executed) { - console.error(`[SCRIPT ${index + 1}] All script execution methods failed`); - console.error(`[SCRIPT ${index + 1}] Script content (first 500 chars):`, scriptContent.substring(0, 500)); - } - }); - } catch (appendError) { - console.error('Failed to execute script:', appendError); - } - } else if (scriptData.src) { - // For external scripts, use appendChild - const newScript = document.createElement('script'); - newScript.src = scriptData.src; - if (scriptData.type) { - newScript.type = scriptData.type; - } - const target = document.head || document.body; - if (target) { - target.appendChild(newScript); - } - console.log('Loaded external script', index + 1, 'of', scriptsToExecute.length); - } - } - } catch (scriptError) { - console.warn('Error executing script', index + 1, ':', scriptError); - } - }); - - // Wait a moment for scripts to execute, then verify functions are available - // Use multiple checks to ensure scripts have time to execute - let checkCount = 0; - const maxChecks = 10; - const checkInterval = setInterval(() => { - checkCount++; - const funcs = { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin, - updatePlugin: typeof window.updatePlugin, - uninstallPlugin: typeof window.uninstallPlugin, - initializePlugins: typeof window.initializePlugins, - loadInstalledPlugins: typeof window.loadInstalledPlugins, - renderInstalledPlugins: typeof window.renderInstalledPlugins - }; - - if (checkCount === 1 || checkCount === maxChecks) { - console.log('Verifying plugin functions after script execution (check', checkCount, '):', funcs); - } - - // Stop checking once critical functions are available or max checks reached - if ((funcs.configurePlugin === 'function' && funcs.togglePlugin === 'function') || checkCount >= maxChecks) { - clearInterval(checkInterval); - if (funcs.configurePlugin !== 'function' || funcs.togglePlugin !== 'function') { - console.error('Critical plugin functions not available after', checkCount, 'checks'); - } - } - }, 100); - } catch (executionError) { - console.error('Script execution error:', executionError); - } - } else { - console.log('No scripts found in loaded HTML'); - } - - // Wait for scripts to execute, then load plugins - // CRITICAL: Wait for configurePlugin and togglePlugin to be defined before proceeding - let attempts = 0; - const maxAttempts = 20; // Increased to give more time - const checkInterval = setInterval(() => { - attempts++; - - // First, ensure critical functions are available - const criticalFunctionsReady = - window.configurePlugin && typeof window.configurePlugin === 'function' && - window.togglePlugin && typeof window.togglePlugin === 'function'; - - if (!criticalFunctionsReady && attempts < maxAttempts) { - if (attempts % 5 === 0) { // Log every 5th attempt - console.log(`Waiting for critical functions... (attempt ${attempts}/${maxAttempts})`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin - }); - } - return; // Keep waiting - } - - if (!criticalFunctionsReady) { - console.error('Critical functions (configurePlugin, togglePlugin) not available after', maxAttempts, 'attempts'); - clearInterval(checkInterval); - return; - } - - console.log('Critical functions ready, proceeding with plugin initialization...'); - clearInterval(checkInterval); - - // Now try to call initializePlugins first (loads both installed and store) - if (window.initializePlugins && typeof window.initializePlugins === 'function') { - console.log('Found initializePlugins, calling it...'); - window.initializePlugins(); - } else if (window.loadInstalledPlugins && typeof window.loadInstalledPlugins === 'function') { - console.log('Found loadInstalledPlugins, calling it...'); - window.loadInstalledPlugins(); - // Also try to load plugin store - if (window.searchPluginStore && typeof window.searchPluginStore === 'function') { - setTimeout(() => window.searchPluginStore(true), 500); - } - } else if (window.pluginManager && window.pluginManager.loadInstalledPlugins) { - console.log('Found pluginManager.loadInstalledPlugins, calling it...'); - window.pluginManager.loadInstalledPlugins(); - // Also try to load plugin store - setTimeout(() => { - const searchFn = window.searchPluginStore || - (window.pluginManager && window.pluginManager.searchPluginStore); - if (searchFn && typeof searchFn === 'function') { - console.log('Loading plugin store...'); - searchFn(true); - } else { - console.warn('searchPluginStore not available'); - } - }, 500); - } else if (attempts >= maxAttempts) { - console.log('loadInstalledPlugins not found after', maxAttempts, 'attempts, fetching and rendering directly...'); - clearInterval(checkInterval); - - // Load both installed plugins and plugin store - Promise.all([ - // Use batched API requests for better performance - window.PluginAPI && window.PluginAPI.batch ? - window.PluginAPI.batch([ - {endpoint: '/plugins/installed', method: 'GET'}, - {endpoint: '/plugins/store/list?fetch_commit_info=true', method: 'GET'} - ]).then(([installedRes, storeRes]) => { - return [installedRes, storeRes]; - }) : - Promise.all([ - getInstalledPluginsSafe(), - fetch('/api/v3/plugins/store/list?fetch_commit_info=true').then(r => r.json()) - ]) - ]).then(([installedData, storeData]) => { - console.log('Fetched plugins:', installedData); - console.log('Fetched store:', storeData); - - // Render installed plugins - if (installedData.status === 'success') { - const plugins = installedData.data.plugins || []; - const container = document.getElementById('installed-plugins-grid'); - const countEl = document.getElementById('installed-count'); - - // Try renderInstalledPlugins one more time - if (window.renderInstalledPlugins && typeof window.renderInstalledPlugins === 'function') { - console.log('Using renderInstalledPlugins...'); - window.renderInstalledPlugins(plugins); - } else if (container) { - console.log('renderInstalledPlugins not available, rendering full plugin cards manually...'); - // Render full plugin cards with all information - const escapeHtml = function(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; - const escapeAttr = function(text) { - return (text || '').replace(/'/g, "\\'").replace(/"/g, '"'); - }; - const escapeJs = function(text) { - return JSON.stringify(text || ''); - }; - const formatCommit = function(commit, branch) { - if (!commit && !branch) return 'Unknown'; - const shortCommit = commit ? String(commit).substring(0, 7) : ''; - const branchText = branch ? String(branch) : ''; - if (branchText && shortCommit) return branchText + ' · ' + shortCommit; - if (branchText) return branchText; - if (shortCommit) return shortCommit; - return 'Unknown'; - }; - const formatDate = function(dateString) { - if (!dateString) return 'Unknown'; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return 'Unknown'; - const now = new Date(); - const diffDays = Math.ceil(Math.abs(now - date) / (1000 * 60 * 60 * 24)); - if (diffDays < 1) return 'Today'; - if (diffDays < 2) return 'Yesterday'; - if (diffDays < 7) return diffDays + ' days ago'; - if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return weeks + (weeks === 1 ? ' week' : ' weeks') + ' ago'; - } - return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } catch (e) { - return 'Unknown'; - } - }; - container.innerHTML = plugins.map(function(p) { - const name = escapeHtml(p.name || p.id); - const desc = escapeHtml(p.description || 'No description available'); - const author = escapeHtml(p.author || 'Unknown'); - const category = escapeHtml(p.category || 'General'); - const enabled = p.enabled ? 'checked' : ''; - const enabledBool = Boolean(p.enabled); - const escapedId = escapeAttr(p.id); - const verified = p.verified ? 'Verified' : ''; - const tags = (p.tags && p.tags.length > 0) ? '
' + p.tags.map(function(tag) { return '' + escapeHtml(tag) + ''; }).join('') + '
' : ''; - const escapedJsId = escapeJs(p.id); - return '

' + name + '

' + verified + '

' + author + '

' + formatCommit(p.last_commit, p.branch) + '

' + formatDate(p.last_updated) + '

' + category + '

' + desc + '

' + tags + '
'; - }).join(''); - if (countEl) countEl.textContent = plugins.length + ' installed'; - window.installedPlugins = plugins; - console.log('Rendered', plugins.length, 'plugins with full cards'); - } else { - console.error('installed-plugins-grid container not found'); - } - } - - // Render plugin store - if (storeData.status === 'success') { - const storePlugins = storeData.data.plugins || []; - const storeContainer = document.getElementById('plugin-store-grid'); - const storeCountEl = document.getElementById('store-count'); - - if (storeContainer) { - // Try renderPluginStore if available - if (window.renderPluginStore && typeof window.renderPluginStore === 'function') { - console.log('Using renderPluginStore...'); - window.renderPluginStore(storePlugins); - } else { - // Manual rendering fallback - console.log('renderPluginStore not available, rendering manually...'); - const escapeHtml = function(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; - const escapeJs = function(text) { - return JSON.stringify(text || ''); - }; - storeContainer.innerHTML = storePlugins.map(function(p) { - const name = escapeHtml(p.name || p.id); - const desc = escapeHtml(p.description || 'No description available'); - const author = escapeHtml(p.author || 'Unknown'); - const category = escapeHtml(p.category || 'General'); - const stars = p.stars || 0; - const verified = p.verified ? 'Verified' : ''; - const escapedJsId = escapeJs(p.id); - return '

' + name + '

' + verified + '

' + author + '

' + category + '

' + (stars > 0 ? '

' + stars + ' stars

' : '') + '

' + desc + '

'; - }).join(''); - } - - if (storeCountEl) { - storeCountEl.innerHTML = storePlugins.length + ' available'; - } - console.log('Rendered', storePlugins.length, 'store plugins'); - } else { - console.error('plugin-store-grid container not found'); - } - } else { - console.error('Failed to load plugin store:', storeData.message); - const storeCountEl = document.getElementById('store-count'); - if (storeCountEl) { - storeCountEl.innerHTML = 'Error loading store'; - } - } - }) - .catch(err => { - console.error('Error fetching plugins/store:', err); - // Still try to render installed plugins if store fails - }); - } - }, 100); // Reduced from 200ms to 100ms for faster retries - }) - .catch(err => console.error('Error loading plugins:', err)); - } - }; })(); + + @@ -976,7 +975,7 @@