5 Commits

Author SHA1 Message Date
ChuckBuilds
e23287c027 merge: resolve conflicts with main (PR #293)
Keep our fixes over the merged auto-repair PR:
- plugin_manager: backup dir skip + clear-before-update
- state_reconciliation: no skip on missing 'enabled', default True
- store_manager: resolved_id for monorepo migration
- app.py: reconciliation threading lock + return type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:20:29 -04:00
ChuckBuilds
2148973cf6 fix(web): add fetch timeout to loadPluginsDirect fallback
Add AbortController with 10s timeout so a hanging fetch doesn't leave
data-loaded set and block retries. Timer is cleared in both success
and error paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:16:00 -04:00
ChuckBuilds
233b9da95a fix: skip backup dirs in plugin discovery and fix HTMX event syntax
- plugin_manager.py: skip directories containing '.standalone-backup-'
  during discovery scan, matching state_reconciliation.py behavior and
  preventing backup manifests from overwriting live plugin entries
- base.html: fix hx-on::htmx:response-error → hx-on::response-error
  (the :: shorthand already adds the htmx: prefix, so the original
  syntax resolved to htmx:htmx:response-error making the handler dead)

Skipped findings:
- web-ui-info in _SYSTEM_CONFIG_KEYS: it's a real plugin with manifest.json
  and config entry, not a system key
- store_manager config key migration: valid feature request for handling
  ledmatrix- prefix rename, but new functionality outside this PR scope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:57:20 -04:00
ChuckBuilds
271d71b357 fix: address CodeRabbit review findings across plugin system
- plugin_manager.py: clear plugin_manifests/plugin_directories before update
  to prevent ghost entries for uninstalled plugins persisting across scans
- state_reconciliation.py: remove 'enabled' key check that skipped legacy
  plugin configs, default to enabled=True matching PluginManager.load_plugin
- app.py: add threading.Lock around reconciliation start guard to prevent
  race condition spawning duplicate threads; add -> None return annotation
- store_manager.py: use resolved registry ID (alt_id) instead of original
  plugin_id when reinstalling during monorepo migration
- base.html: check Response.ok in loadPluginsDirect fallback; trigger
  fallback on tab click when HTMX unavailable; remove active-tab check
  from 5-second timeout so content preloads regardless

Skipped: api_v3.py secret redaction suggestion — the caller at line 2539
already tries schema-based mask_secret_fields() before falling back to
_conservative_mask_config, making the suggested change redundant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:06:51 -04:00
Chuck
640a4c1706 fix: auto-repair missing plugins on startup (#293)
* fix: auto-repair missing plugins and graceful config fallback

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:09:49 -04:00
5 changed files with 39 additions and 26 deletions

View File

@@ -119,6 +119,9 @@ class PluginManager:
for item in directory.iterdir():
if not item.is_dir():
continue
# Skip backup directories so they don't overwrite live entries
if '.standalone-backup-' in item.name:
continue
manifest_path = item / "manifest.json"
if manifest_path.exists():
@@ -136,11 +139,14 @@ class PluginManager:
except (OSError, PermissionError) as e:
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
# Update shared state under lock
# Replace shared state under lock so uninstalled plugins don't linger
with self._discovery_lock:
self.plugin_manifests.clear()
self.plugin_manifests.update(new_manifests)
if not hasattr(self, 'plugin_directories'):
self.plugin_directories = {}
else:
self.plugin_directories.clear()
self.plugin_directories.update(new_directories)
return plugin_ids

View File

@@ -182,10 +182,8 @@ class StateReconciliation:
continue
if plugin_id in self._SYSTEM_CONFIG_KEYS:
continue
if 'enabled' not in plugin_config:
continue
state[plugin_id] = {
'enabled': plugin_config.get('enabled', False),
'enabled': plugin_config.get('enabled', True),
'version': plugin_config.get('version'),
'exists_in_config': True
}

View File

@@ -1785,11 +1785,13 @@ class PluginStoreManager:
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)
resolved_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:
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
resolved_id = alt_id
self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}")
remote_branch = None
remote_sha = None
@@ -1804,13 +1806,13 @@ class PluginStoreManager:
local_remote = git_info.get('remote_url', '')
if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo):
self.logger.info(
f"Plugin {plugin_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source."
)
if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
return False
return self.install_plugin(plugin_id)
return self.install_plugin(resolved_id)
# Check if already up to date
if remote_sha and local_sha and remote_sha.startswith(local_sha):

View File

@@ -653,8 +653,10 @@ def _initialize_health_monitor():
_reconciliation_done = False
_reconciliation_started = False
import threading as _threading
_reconciliation_lock = _threading.Lock()
def _run_startup_reconciliation():
def _run_startup_reconciliation() -> None:
"""Run state reconciliation in background to auto-repair missing plugins."""
global _reconciliation_done, _reconciliation_started
from src.logging_config import get_logger
@@ -678,10 +680,12 @@ def _run_startup_reconciliation():
_reconciliation_done = True
else:
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
_reconciliation_started = False
with _reconciliation_lock:
_reconciliation_started = False
except Exception as e:
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
_reconciliation_started = False
with _reconciliation_lock:
_reconciliation_started = False
# Initialize health monitor and run reconciliation on first request
@app.before_request
@@ -690,10 +694,10 @@ def check_health_monitor():
global _reconciliation_started
if not _health_monitor_initialized:
_initialize_health_monitor()
if not _reconciliation_started:
_reconciliation_started = True
import threading
threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
with _reconciliation_lock:
if not _reconciliation_started:
_reconciliation_started = True
_threading.Thread(target=_run_startup_reconciliation, daemon=True).start()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -401,9 +401,15 @@
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch (HTMX fallback)...');
fetch('/v3/partials/plugins')
.then(r => r.text())
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
// Trigger full initialization chain
if (window.pluginManager) {
@@ -415,6 +421,7 @@
}
})
.catch(err => {
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
@@ -426,13 +433,9 @@
setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
const appElement = document.querySelector('[x-data="app()"]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const activeTab = appElement._x_dataStack[0].activeTab;
if (activeTab === 'plugins') {
loadPluginsDirect();
}
}
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
}
}, 5000);
</script>
@@ -976,7 +979,7 @@
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'"
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
@@ -1170,7 +1173,7 @@
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
hx-on::htmx:response-error="loadPluginsDirect()">
hx-on::response-error="loadPluginsDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>