refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)

The web process built its own PluginManager and loaded plugins into itself:
store installs and updates loaded or reloaded a web-side copy, and config
saves and enable/disable called on_config_change, on_enable and on_disable
on it. None of that reached the panel, and /plugins/installed reported
runtime state from those copies.

- Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests,
  directories, display modes, installed version, schema and config reads,
  with no way to run a plugin. app.py and both blueprints use it; the
  plugin_manager blueprint attribute is gone.
- Remove every lifecycle call from the web routes. Config changes already
  reach the display through ConfigService (on_config_change) and the
  enabled-set reconcile.
- Health and metrics readers move to api_v3.health_tracker /
  resource_monitor. /plugins/installed reports loaded/state/error_info as
  null (the display does not publish them) and enabled by the display's
  rule.
- Store install, update and uninstall answer restart_required when the
  running display will not pick the change up by itself
  (display_restart_required). The restart banner follows the flag via
  window.noteRestartRequired instead of the /config/main URL heuristic;
  /config/main now sends restart_required: true.
- The one remaining in-process import of plugin code (Starlark helper
  modules, oauth_flow action scripts) goes through
  _import_plugin_code_in_web_process() until a web-entry contract.
- /plugins/installed reports vegas_participation (from #682) from the
  user's setting or the manifest, with vegas_participation_source; when
  only the plugin's code decides it, null with source 'runtime', since the
  web process no longer has plugin instances to ask.
- Check & Update All keeps its restart flags when the final list refresh
  fails, and asks for a restart when an enabled plugin's first request got
  no answer and the re-sent one found it up to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 10:39:44 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ba6eccb489
commit 7ab6fb1aff
74 changed files with 1759 additions and 676 deletions
+41 -22
View File
@@ -6,7 +6,8 @@ so their endpoint names do not depend on which module they live in.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, Path, _do_transactional_uninstall,
_non_plugin_id_error, _get_plugin_version, _plugin_directory, api_v3,
_non_plugin_id_error, _get_plugin_version, _plugin_directory,
_plugin_enabled_in_config, _store_restart_fields, api_v3,
datetime, error_response, exception_error_response, json, jsonify, logger,
request, success_response, validate_request_json,
)
@@ -228,11 +229,12 @@ def update_plugin():
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# Rediscover plugins
if api_v3.plugin_manager:
api_v3.plugin_manager.discover_plugins()
if plugin_id in api_v3.plugin_manager.plugins:
api_v3.plugin_manager.reload_plugin(plugin_id)
# Rediscover plugins. The web process runs no plugin code, so
# there is nothing here to reload: the display keeps running the
# version it loaded until it restarts, which restart_required
# below asks for.
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
# Update state and history
if api_v3.plugin_state_manager:
@@ -261,7 +263,10 @@ def update_plugin():
'commit': updated_commit,
'update_status': update_status
},
message=message
message=message,
extra=_store_restart_fields(
'update', _plugin_enabled_in_config(plugin_id),
changed=update_status == 'updated'),
)
else:
refusal = _compatibility_refusal(plugin_id)
@@ -341,6 +346,8 @@ def uninstall_plugin():
if api_v3.operation_queue:
def uninstall_callback(operation):
"""Callback to execute plugin uninstallation via transactional helper."""
# Read before the uninstall removes the config section.
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if not success:
if api_v3.operation_history:
@@ -358,7 +365,9 @@ def uninstall_plugin():
status="success",
details={"preserve_config": preserve_config}
)
return {'success': True, 'message': 'Plugin uninstalled successfully'}
return {'success': True, 'message': 'Plugin uninstalled successfully',
**_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config)}
# Enqueue operation
operation_id = api_v3.operation_queue.enqueue_operation(
@@ -373,6 +382,7 @@ def uninstall_plugin():
)
else:
# Direct (non-queued) transactional uninstall
was_enabled = _plugin_enabled_in_config(plugin_id)
success, error_msg = _do_transactional_uninstall(plugin_id, preserve_config)
if success:
@@ -383,7 +393,10 @@ def uninstall_plugin():
status="success",
details={"preserve_config": preserve_config}
)
return success_response(message='Plugin uninstalled successfully')
return success_response(
message='Plugin uninstalled successfully',
extra=_store_restart_fields('uninstall', was_enabled,
preserve_config=preserve_config))
else:
if api_v3.operation_history:
api_v3.operation_history.record_operation(
@@ -448,10 +461,11 @@ def install_plugin():
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
# Discover and load the new plugin
if api_v3.plugin_manager:
api_v3.plugin_manager.discover_plugins()
api_v3.plugin_manager.load_plugin(plugin_id)
# List the new plugin. The display loads it, from disk, when
# it is enabled; see restart_required below for one that
# already is.
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
# Update state manager
if api_v3.plugin_state_manager:
@@ -468,7 +482,9 @@ def install_plugin():
)
branch_msg = f" (branch: {branch})" if branch else ""
return {'success': True, 'message': f'Plugin {plugin_id} installed successfully{branch_msg}'}
return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
@@ -511,9 +527,8 @@ def install_plugin():
if success:
if api_v3.schema_manager:
api_v3.schema_manager.invalidate_cache(plugin_id)
if api_v3.plugin_manager:
api_v3.plugin_manager.discover_plugins()
api_v3.plugin_manager.load_plugin(plugin_id)
if api_v3.plugin_catalog:
api_v3.plugin_catalog.discover_plugins()
if api_v3.plugin_state_manager:
api_v3.plugin_state_manager.set_plugin_installed(plugin_id)
if api_v3.operation_history:
@@ -526,7 +541,9 @@ def install_plugin():
)
branch_msg = f" (branch: {branch})" if branch else ""
return success_response(message=f'Plugin installed successfully{branch_msg}')
return success_response(
message=f'Plugin installed successfully{branch_msg}',
extra=_store_restart_fields('install', _plugin_enabled_in_config(plugin_id)))
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
@@ -587,10 +604,9 @@ def install_plugin_from_url():
if api_v3.schema_manager and installed_plugin_id:
api_v3.schema_manager.invalidate_cache(installed_plugin_id)
# Discover and load the new plugin
if api_v3.plugin_manager and installed_plugin_id:
api_v3.plugin_manager.discover_plugins()
api_v3.plugin_manager.load_plugin(installed_plugin_id)
# List the new plugin; the display loads it when it is enabled.
if api_v3.plugin_catalog and installed_plugin_id:
api_v3.plugin_catalog.discover_plugins()
branch_msg = f" (branch: {result.get('branch', branch)})" if (result.get('branch') or branch) else ""
response_data = {
@@ -601,6 +617,9 @@ def install_plugin_from_url():
}
if result.get('branch'):
response_data['branch'] = result.get('branch')
if installed_plugin_id:
response_data.update(_store_restart_fields(
'install', _plugin_enabled_in_config(installed_plugin_id)))
return jsonify(response_data)
else:
return jsonify({