"""Atomic state persistence for declarative plugins.""" from __future__ import annotations import json import os from pathlib import Path from typing import Any from .models import PluginSkillBinding, PluginState class PluginStateStore: def __init__(self, workspace: str | Path) -> None: self.workspace = Path(workspace) self.root = self.workspace / ".beaver" / "plugins" self.path = self.root / "state.json" def list_plugins(self) -> list[PluginState]: return [ PluginState.from_dict(plugin_id, payload if isinstance(payload, dict) else {}) for plugin_id, payload in sorted(self._read_state().get("plugins", {}).items()) ] def get_plugin(self, plugin_id: str) -> PluginState | None: payload = self._read_state().get("plugins", {}).get(plugin_id) if not isinstance(payload, dict): return None return PluginState.from_dict(plugin_id, payload) def set_enabled(self, plugin_id: str, enabled: bool) -> PluginState: state = self.get_plugin(plugin_id) or PluginState(plugin_id=plugin_id) state.enabled = enabled if enabled and state.status == "discovered": state.status = "enabled" self.upsert_plugin(state) return state def upsert_plugin(self, plugin_state: PluginState) -> None: state = self._read_state() plugins = state.setdefault("plugins", {}) if not isinstance(plugins, dict): plugins = {} state["plugins"] = plugins plugins[plugin_state.plugin_id] = plugin_state.to_dict() self._write_state(state) def update_skill_binding( self, plugin_id: str, skill_name: str, binding: PluginSkillBinding, ) -> PluginState: state = self.get_plugin(plugin_id) or PluginState(plugin_id=plugin_id) state.skills[skill_name] = binding self.upsert_plugin(state) return state def _read_state(self) -> dict[str, Any]: if not self.path.exists(): return {"plugins": {}} payload = json.loads(self.path.read_text(encoding="utf-8")) if not isinstance(payload, dict): return {"plugins": {}} plugins = payload.get("plugins") if not isinstance(plugins, dict): payload["plugins"] = {} return payload def _write_state(self, state: dict[str, Any]) -> None: self.root.mkdir(parents=True, exist_ok=True) tmp_path = self.path.with_name("state.json.tmp") with tmp_path.open("w", encoding="utf-8") as handle: json.dump(state, handle, ensure_ascii=False, sort_keys=True, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(tmp_path, self.path)