Files
LEDMatrix/web_interface/blueprints/api_v3/plugin_operations.py
T
ChuckandClaude Opus 5.5 7ab6fb1aff 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>
2026-09-30 10:39:44 -04:00

241 lines
9.7 KiB
Python

"""Plugin operation status and history, and plugin state reconciliation.
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
so their endpoint names do not depend on which module they live in.
"""
from web_interface.blueprints.api_v3 import (
ErrorCode, Path, Response, _coerce_to_bool, api_v3, error_response,
exception_error_response, json, jsonify, logger, os, request, stat,
success_response, tempfile,
)
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
def get_operation_status(operation_id):
"""Get status of a plugin operation"""
try:
if not api_v3.operation_queue:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Operation queue not initialized',
status_code=500
)
operation = api_v3.operation_queue.get_operation_status(operation_id)
if not operation:
return error_response(
ErrorCode.PLUGIN_NOT_FOUND,
f'Operation {operation_id} not found',
status_code=404
)
return success_response(data=operation.to_dict())
except Exception as e:
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
@api_v3.route('/plugins/operation/history', methods=['GET'])
def get_operation_history() -> Response:
"""Get operation history from the audit log."""
if not api_v3.operation_history:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Operation history not initialized',
status_code=500
)
try:
limit = request.args.get('limit', 50, type=int)
plugin_id = request.args.get('plugin_id')
operation_type = request.args.get('operation_type')
except (ValueError, TypeError) as e:
return error_response(ErrorCode.INVALID_INPUT, f'Invalid query parameter: {e}', status_code=400)
try:
history = api_v3.operation_history.get_history(
limit=limit,
plugin_id=plugin_id,
operation_type=operation_type
)
except (AttributeError, RuntimeError) as e:
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
return success_response(data=[record.to_dict() for record in history])
@api_v3.route('/plugins/operation/history', methods=['DELETE'])
def clear_operation_history() -> Response:
"""Clear operation history."""
if not api_v3.operation_history:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Operation history not initialized',
status_code=500
)
try:
api_v3.operation_history.clear_history()
except (OSError, RuntimeError) as e:
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
return success_response(message='Operation history cleared')
@api_v3.route('/plugins/state', methods=['GET'])
def get_plugin_state():
"""Get plugin state from state manager"""
try:
if not api_v3.plugin_state_manager:
return error_response(
ErrorCode.SYSTEM_ERROR,
'State manager not initialized',
status_code=500
)
plugin_id = request.args.get('plugin_id')
if plugin_id:
# Get state for specific plugin
state = api_v3.plugin_state_manager.get_plugin_state(plugin_id)
if not state:
return error_response(
ErrorCode.PLUGIN_NOT_FOUND,
f'Plugin {plugin_id} not found in state manager',
context={'plugin_id': plugin_id},
status_code=404
)
return success_response(data=state.to_dict())
else:
# Get all plugin states
all_states = api_v3.plugin_state_manager.get_all_states()
return success_response(data={
plugin_id: state.to_dict()
for plugin_id, state in all_states.items()
})
except Exception as e:
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
@api_v3.route('/plugins/state/reconcile', methods=['POST'])
def reconcile_plugin_state():
"""Reconcile plugin state across all sources"""
try:
if not api_v3.plugin_state_manager or not api_v3.plugin_catalog:
return error_response(
ErrorCode.SYSTEM_ERROR,
'State manager or plugin catalog not initialized',
status_code=500
)
from src.plugin_system.state_reconciliation import StateReconciliation
# Parse optional `force` flag from request body, guarding against
# non-dict bodies (bare string, array, null) that would raise AttributeError.
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
payload = {}
force = _coerce_to_bool(payload.get('force', False))
reconciler = StateReconciliation(
state_manager=api_v3.plugin_state_manager,
config_manager=api_v3.config_manager,
plugin_manager=api_v3.plugin_catalog,
plugins_dir=Path(api_v3.plugin_catalog.plugins_dir)
)
result = reconciler.reconcile_state(force=force)
return success_response(
data={
'inconsistencies_found': len(result.inconsistencies_found),
'inconsistencies_fixed': len(result.inconsistencies_fixed),
'inconsistencies_manual': len(result.inconsistencies_manual),
'inconsistencies': [
{
'plugin_id': inc.plugin_id,
'type': inc.inconsistency_type.value,
'description': inc.description,
'fix_action': inc.fix_action.value
}
for inc in result.inconsistencies_found
],
'fixed': [
{
'plugin_id': inc.plugin_id,
'type': inc.inconsistency_type.value,
'description': inc.description
}
for inc in result.inconsistencies_fixed
],
'manual_fix_required': [
{
'plugin_id': inc.plugin_id,
'type': inc.inconsistency_type.value,
'description': inc.description
}
for inc in result.inconsistencies_manual
]
},
message=result.message
)
except Exception as e:
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
def _drop_stale_reconciliation_findings(unresolved):
"""Re-check a stored reconciliation verdict against current state.
The verdict is a snapshot written once per run, and a run that could not
apply a fix also refuses to retry -- so a resolved condition was reported
indefinitely. Best-effort: any failure here returns the list untouched,
because showing a stale warning beats failing the endpoint.
Both sets come from the reconciliation module's own extractors rather than
being re-derived here. That matters for correctness, not tidiness: a plain
set(load_config()) also contains system keys, the secrets-file keys merged
in by load_config(), and non-dict values, and any directory holding a
manifest.json would count as installed even if that manifest does not
parse. Either looseness clears findings that are still true -- and a secrets
key read as a plugin is the very bug the filter exists to stop reporting.
"""
try:
from src.plugin_system.state_reconciliation import (
config_plugin_ids, disk_plugin_ids, ignored_config_keys,
still_unresolved,
)
cm = api_v3.config_manager
plugins_dir = getattr(api_v3.plugin_catalog, '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)
return unresolved
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
def get_reconciliation_status():
"""Return the result of the last startup reconciliation from /tmp status file."""
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
try:
st = os.lstat(_recon_path)
except FileNotFoundError:
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
try:
with open(_recon_path) as _f:
data = json.load(_f)
if data.get('unresolved'):
data['unresolved'] = _drop_stale_reconciliation_findings(data['unresolved'])
return jsonify({'status': 'success', 'data': data})
except json.JSONDecodeError:
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
except PermissionError:
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})