Files
LEDMatrix/src/plugin_system/state_manager.py
T
ChuckandClaude Opus 5.5 b11bcfa204 fix(plugins): store and plugin-manager bugs; tidy src/plugin_system (#635)
* fix(store): don't read a ZIP-installed plugin's remote from the LEDMatrix repo

update_plugin looked up remote.origin.url with `git -C <plugin> config
--local` for plugins that are not git checkouts. Under plugin-repos/ git
walks up to the enclosing LEDMatrix repository, so the lookup returned
LEDMatrix's own URL and a plugin missing from the registry was
"reinstalled" from the LEDMatrix repo. Only ask git when the plugin
directory has its own .git, the test _get_local_git_info already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(schema): report each missing required field once, by name

validate_config_against_schema ran its own required-fields loop after
Draft7Validator.iter_errors, which already yields one `required` error
per missing field, so every missing top-level field was listed twice.
The validator's copy also printed the schema's whole `required` list
("Missing required property '['api_key', 'city']'") instead of the field.
Drop the loop and take the field name from the error itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): stop mangling repository URLs that contain ".git"

install_from_url and fetch_registry_from_url cleaned URLs with
`rstrip('/').replace('.git', '')`, which removes ".git" anywhere:
https://github.com/user/my.github.io became .../myhub.io, so installing
or browsing that repository asked GitHub for one that does not exist.

Add src/plugin_system/repo_urls.py with one anchored normalize_repo_url(),
same_repo() for comparisons, github_owner_repo() and github_api_headers(),
and use them for the five copies of the owner/repo parsing and GitHub
headers in the store and for saved repositories. GitHub URLs are now
recognised by urlparse().hostname everywhere: _get_latest_commit_info
used a substring test, and _install_from_monorepo_api parsed any host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): install a repository whose only branch is not main/master

_install_via_git returned None both when every clone failed and when the
last-resort clone of the repository's default branch succeeded.
_install_plugin_impl papered over it with `and not plugin_path.exists()`;
install_from_url did not, so a repository whose only branch is e.g.
`develop` was cloned, then treated as a failure, then "downloaded" from
main/master archives that do not exist.

After a default-branch clone, return the branch the clone checked out
(read from .git/HEAD), so None means failure and nothing else, and give
both callers the same `branch_used is None` fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): judge the memory limit on each call's own growth

monitor_call stores `metrics.memory_mb = max(previous, growth)`, and
_check_limits compared that high-water mark with max_memory_mb. It never
decreases, so once one update() grew the process past the limit every
later call raised ResourceLimitExceeded and the circuit breaker kept
reopening. Pass the call's own RSS growth to _check_limits; keep the
high-water mark for reporting and document what it measures.

Remove ResourceMetrics.update_average_execution_time: nothing called it,
and it overwrote the running total with the average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): reload_plugin re-reads the manifest from the discovered directory

reload_plugin read `plugins_dir / plugin_id / "manifest.json"`, ignoring
the discovery map and the plugin_dirs rules. For a plugin whose
directory name differs from its manifest id the path did not exist, the
re-read was skipped without a word, and the reload kept the stale
manifest. Resolve the directory with find_plugin_directory, as
load_plugin does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(plugins): drop the always-null last_display from plugin state info

PluginStateManager reported `last_display` from `_last_display`, which
nothing ever wrote, so it was null for every plugin. Recording it in
PluginExecutor.execute_display would not help: get_state_info's only
reader is the web process, whose PluginManager never calls display().
Remove the field, its dict and get_last_display() (no caller in core,
the web UI or the plugin monorepo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(store): share the rollback and requirements helpers, drop dead code

- install_plugin and _reinstall_with_rollback set aside, discard and
  restore the old copy through _set_aside/_discard_backup/_restore_backup
  instead of two copies of the same blocks.
- The loader and the store run the same pre-pip checks through
  contained_plugin_dir() and requirements_to_install() in plugin_loader.
  They still invoke pip differently (sys.executable -m pip vs. the sudo
  wrapper). `except (BrokenPipeError, OSError)` + `isinstance(e, OSError)`
  becomes `except OSError` checking errno.EPIPE.
- load_module never returns None, so load_plugin's check is gone and the
  docstring says what it raises.
- Remove the always-true JSONSCHEMA_AVAILABLE, the inline re-imports of
  re and permission_utils, the fake status_result object nobody reads,
  hasattr(git_error, 'cmd'), a redundant "merge conflict" test and
  `import traceback` (exc_info=True does it).
- Correct comments: install_from_url names the directory for the
  caller's id when given (not always the manifest id), _get_local_git_info
  saves one git subprocess (not four), _enrich calls two helpers,
  search_plugins documents all its arguments, _find_plugin_path states
  its behaviour instead of a TODO, and history narration is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): tidy base_plugin, correct plugin_manager/state comments

- base_plugin: drop the unused `import logging`; get_display_duration
  runs the instance value and the config value through one
  _positive_seconds() helper instead of two copies of the coercion; the
  'static'/'none'/fallback branches of get_vegas_display_mode, which all
  returned FIXED_SEGMENT, are one; fix the mis-indented validate_config
  example; say that get_supported_vegas_modes/get_vegas_segment_width
  are not consulted by core (kept, plugins override them).
- schema_manager: import expand_style_elements normally rather than
  swallowing an ImportError of a core module.
- plugin_manager: the plugins directory is the configured one
  (plugin-repos/ by default), not plugins/; get_config() returns the live
  dict, not a copy, so the interval cache comments say what it saves.
- state_manager: config_version and the file version are not used to
  detect corruption; say what they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): stop writing data/plugin_operations.json

PluginOperationQueue wrote its finished-operation history to
data/plugin_operations.json after every operation, and read it back only
into its own in-memory list, which only get_operation_history() exposes
-- and nothing calls that. The operation-history endpoint reads
OperationHistory (data/operation_history.json). No code in src/,
web_interface/, scripts/ or test/ reads the file.

Drop the history_file/lazy_load parameters and the load/save code; the
bounded in-memory history stays. web_interface/app.py and the
integration test stop passing the removed arguments. An existing
data/plugin_operations.json is left in place (data/* is gitignored).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): plugin-system

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:32:02 -04:00

340 lines
11 KiB
Python

"""
Centralized plugin state management.
Provides a single source of truth for plugin state (installed, enabled, version, etc.)
with persistence.
"""
import json
import threading
from typing import Dict, Any, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, asdict
from enum import Enum
from src.logging_config import get_logger
class PluginStateStatus(Enum):
"""Status of a plugin."""
INSTALLED = "installed"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class PluginState:
"""Represents the state of a plugin."""
plugin_id: str
status: PluginStateStatus
enabled: bool
version: Optional[str] = None
installed_at: Optional[datetime] = None
last_updated: Optional[datetime] = None
# Bumped on every update_plugin_state(). Nothing reads it; it stays so
# plugin_state.json keeps the shape older releases load with cls(**data).
config_version: int = 1
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
def to_dict(self) -> Dict[str, Any]:
"""Convert state to dictionary for serialization."""
result = asdict(self)
# Convert enum to string
result['status'] = self.status.value
# Convert datetime to ISO string
if result.get('installed_at'):
result['installed_at'] = self.installed_at.isoformat()
if result.get('last_updated'):
result['last_updated'] = self.last_updated.isoformat()
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PluginState':
"""Create state from dictionary."""
# Parse enum
if isinstance(data.get('status'), str):
data['status'] = PluginStateStatus(data['status'])
# Parse datetime
if data.get('installed_at') and isinstance(data['installed_at'], str):
data['installed_at'] = datetime.fromisoformat(data['installed_at'])
if data.get('last_updated') and isinstance(data['last_updated'], str):
data['last_updated'] = datetime.fromisoformat(data['last_updated'])
return cls(**data)
class PluginStateManager:
"""
Centralized plugin state manager.
Provides:
- Single source of truth for plugin state
- State persistence
"""
def __init__(
self,
state_file: Optional[str] = None,
auto_save: bool = True,
lazy_load: bool = False
):
"""
Initialize state manager.
Args:
state_file: Path to file for persisting state
auto_save: Whether to automatically save state on changes
lazy_load: If True, defer loading state file until first access
"""
self.logger = get_logger(__name__)
self.state_file = Path(state_file) if state_file else None
self.auto_save = auto_save
self._lazy_load = lazy_load
self._state_loaded = False
# State storage
self._states: Dict[str, PluginState] = {}
# The file's top-level "version", written back as read. Nothing
# checks it yet; it is there for a future format change to branch on.
self._state_version = 1
# Threading
self._lock = threading.RLock()
# Load state from file if it exists (unless lazy loading)
if not self._lazy_load and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def _ensure_loaded(self) -> None:
"""Ensure state is loaded (for lazy loading)."""
if not self._state_loaded and self.state_file and self.state_file.exists():
self._load_state()
self._state_loaded = True
def get_plugin_state(self, plugin_id: str) -> Optional[PluginState]:
"""
Get state for a plugin.
Args:
plugin_id: Plugin identifier
Returns:
PluginState if found, None otherwise
"""
self._ensure_loaded()
with self._lock:
return self._states.get(plugin_id)
def get_all_states(self) -> Dict[str, PluginState]:
"""
Get all plugin states.
Returns:
Dictionary mapping plugin_id to PluginState
"""
self._ensure_loaded()
with self._lock:
return self._states.copy()
def update_plugin_state(
self,
plugin_id: str,
updates: Dict[str, Any]
) -> bool:
"""
Update plugin state.
Args:
plugin_id: Plugin identifier
updates: Dictionary of state updates
Returns:
True if update successful
"""
self._ensure_loaded()
with self._lock:
# Get current state or create new
current_state = self._states.get(plugin_id)
if not current_state:
current_state = PluginState(
plugin_id=plugin_id,
status=PluginStateStatus.UNKNOWN,
enabled=False
)
# Apply updates
if 'status' in updates:
if isinstance(updates['status'], str):
current_state.status = PluginStateStatus(updates['status'])
else:
current_state.status = updates['status']
if 'enabled' in updates:
current_state.enabled = bool(updates['enabled'])
if 'version' in updates:
current_state.version = updates['version']
if 'installed_at' in updates:
current_state.installed_at = updates['installed_at']
if 'last_updated' in updates:
current_state.last_updated = updates['last_updated']
else:
current_state.last_updated = datetime.now()
if 'metadata' in updates:
if current_state.metadata is None:
current_state.metadata = {}
current_state.metadata.update(updates['metadata'])
current_state.config_version += 1
# Store updated state
self._states[plugin_id] = current_state
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
def set_plugin_enabled(self, plugin_id: str, enabled: bool) -> bool:
"""
Set plugin enabled/disabled state.
Args:
plugin_id: Plugin identifier
enabled: Whether plugin is enabled
Returns:
True if update successful
"""
status = PluginStateStatus.ENABLED if enabled else PluginStateStatus.DISABLED
return self.update_plugin_state(
plugin_id,
{
'enabled': enabled,
'status': status
}
)
def set_plugin_installed(
self,
plugin_id: str,
version: Optional[str] = None,
installed_at: Optional[datetime] = None
) -> bool:
"""
Mark plugin as installed.
Args:
plugin_id: Plugin identifier
version: Plugin version
installed_at: Installation timestamp
Returns:
True if update successful
"""
return self.update_plugin_state(
plugin_id,
{
'status': PluginStateStatus.INSTALLED,
'version': version,
'installed_at': installed_at or datetime.now()
}
)
def remove_plugin_state(self, plugin_id: str) -> bool:
"""
Remove plugin state (e.g., after uninstall).
Args:
plugin_id: Plugin identifier
Returns:
True if removal successful
"""
self._ensure_loaded()
with self._lock:
if plugin_id in self._states:
del self._states[plugin_id]
# Auto-save if enabled
if self.auto_save:
self._save_state()
return True
return False
def _save_state(self) -> None:
"""Save state to file."""
if not self.state_file:
return
try:
with self._lock:
# Convert states to dicts
states_data = {
plugin_id: state.to_dict()
for plugin_id, state in self._states.items()
}
state_data = {
'version': self._state_version,
'states': states_data,
'last_updated': datetime.now().isoformat()
}
# Ensure directory exists with proper permissions
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
ensure_directory_permissions(self.state_file.parent, get_config_dir_mode())
# Write to file
with open(self.state_file, 'w') as f:
json.dump(state_data, f, indent=2)
except Exception as e:
self.logger.error(f"Error saving plugin state: {e}", exc_info=True)
def _load_state(self) -> None:
"""Load state from file."""
if not self.state_file or not self.state_file.exists():
return
try:
with open(self.state_file, 'r') as f:
state_data = json.load(f)
with self._lock:
# Load state version
self._state_version = state_data.get('version', 1)
# Load states
states_data = state_data.get('states', {})
for plugin_id, state_dict in states_data.items():
try:
self._states[plugin_id] = PluginState.from_dict(state_dict)
except Exception as e:
self.logger.warning(
f"Error loading state for plugin {plugin_id}: {e}"
)
self.logger.info(f"Loaded {len(self._states)} plugin states from file")
except Exception as e:
self.logger.error(f"Error loading plugin state: {e}", exc_info=True)