Files
beaver_project/app-instance/backend/beaver/plugins/skills.py

498 lines
22 KiB
Python

"""Skill mirroring and sync orchestration for declarative plugins."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from uuid import uuid4
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
from beaver.memory.skills.store import SkillLearningStore
from beaver.plugins.models import PluginDiscoveryError, PluginManifest, PluginSkillBinding, PluginState
from beaver.plugins.state import PluginStateStore
from beaver.plugins.transaction import PluginSkillTransaction
from beaver.skills.catalog.utils import parse_frontmatter, strip_frontmatter
from beaver.skills.learning.safety import SkillDraftSafetyChecker
from beaver.skills.publisher.service import SkillPublisher
from beaver.skills.specs import SkillDraft, SkillReviewState, SkillSpec, SkillSpecStore, SkillStatus, SkillVersion
from beaver.skills.specs.serialization import canonical_hash, normalize_frontmatter, summarize_skill_content
class PluginManager:
def __init__(
self,
*,
workspace: Path,
manifests: dict[str, PluginManifest],
discovery_errors: list[PluginDiscoveryError],
state_store: PluginStateStore,
skill_store: SkillSpecStore,
learning_store: SkillLearningStore,
publisher: SkillPublisher,
safety_checker: SkillDraftSafetyChecker,
write_lock: WorkspaceWriteLock,
) -> None:
self.workspace = Path(workspace)
self.manifests = dict(manifests)
self.discovery_errors = list(discovery_errors)
self.state_store = state_store
self.skill_store = skill_store
self.learning_store = learning_store
self.publisher = publisher
self.safety_checker = safety_checker
self.write_lock = write_lock
def list_plugins(self) -> list[PluginState]:
states = {state.plugin_id: state for state in self.state_store.list_plugins()}
for plugin_id, manifest in self.manifests.items():
if plugin_id not in states:
states[plugin_id] = PluginState(
plugin_id=plugin_id,
enabled=False,
installed_version=None,
manifest_path=manifest.display_path,
status="discovered",
)
return [states[key] for key in sorted(states)]
def enable(self, plugin_id: str) -> PluginState:
manifest = self.manifests.get(plugin_id)
if manifest is None:
raise ValueError(f"Unknown plugin: {plugin_id}")
with self.write_lock.acquire(timeout_seconds=10):
current_state = self.state_store.get_plugin(plugin_id)
if current_state is not None and current_state.enabled and self._state_synced(current_state, manifest):
return current_state
transaction = PluginSkillTransaction(self.workspace)
try:
prepared = self._prepare_initial_mirror(manifest, transaction)
for item in prepared:
self.skill_store.promote_upstream_snapshot(transaction, item["snapshot"])
for item in prepared:
self._publish_initial_mirror(item)
state = PluginState(
plugin_id=plugin_id,
enabled=True,
updates_paused=False,
installed_version=manifest.version,
manifest_path=manifest.display_path,
status="synced",
skills={
item["skill_name"]: PluginSkillBinding(
accepted_upstream_tree_hash=item["snapshot"].skill_tree_hash,
observed_upstream_tree_hash=item["snapshot"].skill_tree_hash,
accepted_beaver_version=item["version"].version,
current_beaver_version=item["version"].version,
status="synced",
)
for item in prepared
},
)
self.state_store.upsert_plugin(state)
return state
finally:
transaction.cleanup()
def sync_enabled(self, *, blocking: bool = True) -> dict[str, PluginState]:
results: dict[str, PluginState] = {}
with self.write_lock.acquire(timeout_seconds=10, blocking=blocking):
for state in self.state_store.list_plugins():
manifest = self.manifests.get(state.plugin_id)
if not state.enabled or state.updates_paused:
results[state.plugin_id] = state
continue
if manifest is None:
state.status = "missing"
self.state_store.upsert_plugin(state)
results[state.plugin_id] = state
continue
results[state.plugin_id] = self._sync_plugin(state, manifest)
return results
def pause(self, plugin_id: str) -> PluginState:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
state.updates_paused = True
self.state_store.upsert_plugin(state)
return state
def resume(self, plugin_id: str) -> PluginState:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
state.updates_paused = False
self.state_store.upsert_plugin(state)
return self.sync_enabled().get(plugin_id) or self._require_state(plugin_id)
def disable(self, plugin_id: str, *, disable_linked_skills: bool) -> PluginState:
if not disable_linked_skills:
raise ValueError("disable_linked_skills confirmation is required")
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
for skill_name in list(state.skills):
self.publisher.disable(skill_name, actor="plugin-manager", reason=f"plugin_disabled:{plugin_id}")
state.skills[skill_name].status = "disabled"
state.enabled = False
state.updates_paused = True
state.status = "disabled"
self.state_store.upsert_plugin(state)
return state
def adopt(self, plugin_id: str, skill_name: str) -> SkillSpec:
with self.write_lock.acquire(timeout_seconds=10):
state = self._require_state(plugin_id)
if skill_name not in state.skills:
raise ValueError(f"Plugin skill binding not found: {plugin_id}/{skill_name}")
spec = self.skill_store.get_skill_spec(skill_name)
if spec is None:
raise ValueError(f"Skill spec not found: {skill_name}")
spec.source_kind = "managed"
spec.status = SkillStatus.ACTIVE.value
spec.updated_at = _utc_now()
marker = f"adopted_from_plugin:{plugin_id}"
if marker not in spec.lineage:
spec.lineage.append(marker)
self.skill_store.write_skill_spec(spec)
del state.skills[skill_name]
if not state.skills:
state.status = "adopted"
state.enabled = False
self.state_store.upsert_plugin(state)
self.publisher._refresh_indexes(skill_name, spec.status)
return spec
def on_skill_published(self, draft: SkillDraft, published: SkillVersion | SkillSpec) -> None:
if draft.proposal_kind != "plugin_skill_update" or not isinstance(published, SkillVersion):
return
plugin_id = str(draft.provenance.get("plugin_id") or "")
skill_name = str(draft.provenance.get("skill_name") or draft.skill_name)
tree_hash = str(draft.provenance.get("new_upstream_tree_hash") or "")
if not plugin_id or not skill_name or not tree_hash:
raise ValueError("Plugin publish acknowledgement is missing provenance")
state = self._require_state(plugin_id)
binding = state.skills.get(skill_name) or PluginSkillBinding()
binding.accepted_upstream_tree_hash = tree_hash
binding.observed_upstream_tree_hash = tree_hash
binding.accepted_beaver_version = published.version
binding.current_beaver_version = published.version
binding.pending_candidate_id = None
binding.status = "synced"
state.skills[skill_name] = binding
state.status = "synced"
self.state_store.upsert_plugin(state)
def _prepare_initial_mirror(
self,
manifest: PluginManifest,
transaction: PluginSkillTransaction,
) -> list[dict[str, Any]]:
prepared: list[dict[str, Any]] = []
for declaration in manifest.skills:
spec = self.skill_store.get_skill_spec(declaration.name)
if spec is not None and spec.source_kind != "plugin":
raise ValueError(f"Skill ownership conflict: {declaration.name}")
snapshot = self.skill_store.stage_upstream_snapshot(
transaction,
skill_name=declaration.name,
source_kind="plugin",
source_id=manifest.plugin_id,
source_version=manifest.version,
source_path=declaration.relative_path,
source_root=declaration.root,
)
content = (declaration.root / "SKILL.md").read_text(encoding="utf-8")
frontmatter, body = parse_frontmatter(content)
draft = SkillDraft(
draft_id=uuid4().hex,
skill_name=declaration.name,
base_version=None,
proposed_content=body,
proposed_frontmatter=normalize_frontmatter(frontmatter),
created_at=_utc_now(),
created_by="plugin-manager",
reason=f"Initial mirror from plugin {manifest.plugin_id} {manifest.version}",
proposal_kind="plugin_initial_mirror",
)
safety = self.safety_checker.check(draft)
if not safety.passed or safety.risk_level == "critical":
raise ValueError(f"Plugin skill safety check failed: {declaration.name}")
next_version = self._next_version(declaration.name)
version = self._build_version(
manifest=manifest,
skill_name=declaration.name,
version=next_version,
content=content,
frontmatter=normalize_frontmatter(frontmatter),
parent_version=None,
provenance={
"source_kind": "plugin",
"plugin_id": manifest.plugin_id,
"plugin_version": manifest.version,
"plugin_skill_path": declaration.relative_path,
"upstream_skill_content_hash": snapshot.skill_content_hash,
"upstream_skill_tree_hash": snapshot.skill_tree_hash,
"merge_mode": "initial_mirror",
},
)
prepared.append(
{
"skill_name": declaration.name,
"declaration": declaration,
"snapshot": snapshot,
"content": content,
"frontmatter": normalize_frontmatter(frontmatter),
"version": version,
}
)
return prepared
def _require_state(self, plugin_id: str) -> PluginState:
state = self.state_store.get_plugin(plugin_id)
if state is None:
raise ValueError(f"Unknown plugin state: {plugin_id}")
return state
def _sync_plugin(self, state: PluginState, manifest: PluginManifest) -> PluginState:
transaction = PluginSkillTransaction(self.workspace)
try:
for declaration in manifest.skills:
binding = state.skills.get(declaration.name)
if binding is None or not binding.accepted_upstream_tree_hash:
continue
snapshot = self.skill_store.stage_upstream_snapshot(
transaction,
skill_name=declaration.name,
source_kind="plugin",
source_id=manifest.plugin_id,
source_version=manifest.version,
source_path=declaration.relative_path,
source_root=declaration.root,
)
self.skill_store.promote_upstream_snapshot(transaction, snapshot)
current = self.skill_store.read_published_skill(declaration.name)
if current is None:
continue
if self._reconcile_published_update(binding, current.version, snapshot.skill_tree_hash):
continue
classification = classify_plugin_skill_update(
binding.accepted_upstream_tree_hash,
current.version.tree_hash,
snapshot.skill_tree_hash,
)
binding.observed_upstream_tree_hash = snapshot.skill_tree_hash
binding.current_beaver_version = current.version.version
if classification == "unchanged":
binding.status = "synced"
continue
if classification == "already_applied":
binding.accepted_upstream_tree_hash = snapshot.skill_tree_hash
binding.accepted_beaver_version = current.version.version
binding.pending_candidate_id = None
binding.status = "synced"
continue
candidate = self._create_update_candidate(
plugin_id=manifest.plugin_id,
plugin_version=manifest.version,
skill_name=declaration.name,
merge_mode=classification,
base_upstream_tree_hash=binding.accepted_upstream_tree_hash,
new_upstream_tree_hash=snapshot.skill_tree_hash,
local_version=current.version.version,
)
if binding.pending_candidate_id and binding.pending_candidate_id != candidate.candidate_id:
self.learning_store.transition_learning_candidate(
binding.pending_candidate_id,
"superseded",
event_type="plugin_update_superseded",
payload={"replacement_candidate_id": candidate.candidate_id},
)
recorded, _created = self.learning_store.record_learning_candidate_if_absent(candidate)
binding.pending_candidate_id = recorded.candidate_id
binding.status = "update_pending"
state.installed_version = manifest.version
state.manifest_path = manifest.display_path
if any(binding.status == "update_pending" for binding in state.skills.values()):
state.status = "update_pending"
else:
state.status = "synced"
self.state_store.upsert_plugin(state)
return state
finally:
transaction.cleanup()
def _reconcile_published_update(
self,
binding: PluginSkillBinding,
current_version: SkillVersion,
observed_upstream_tree_hash: str,
) -> bool:
if not binding.pending_candidate_id:
return False
candidates = self.learning_store.list_learning_candidates()
candidate = next(
(item for item in candidates if item.candidate_id == binding.pending_candidate_id),
None,
)
if candidate is None or candidate.status != "published":
return False
candidate_hash = str(candidate.evidence.get("new_upstream_tree_hash") or "")
version_hash = str(current_version.provenance.get("new_upstream_tree_hash") or "")
if not candidate_hash or candidate_hash != observed_upstream_tree_hash or version_hash != candidate_hash:
return False
binding.accepted_upstream_tree_hash = candidate_hash
binding.observed_upstream_tree_hash = candidate_hash
binding.accepted_beaver_version = current_version.version
binding.current_beaver_version = current_version.version
binding.pending_candidate_id = None
binding.status = "synced"
return True
@staticmethod
def _create_update_candidate(
*,
plugin_id: str,
plugin_version: str,
skill_name: str,
merge_mode: str,
base_upstream_tree_hash: str,
new_upstream_tree_hash: str,
local_version: str,
):
from beaver.memory.skills.models import SkillLearningCandidate
candidate_id = f"plugin-update:{plugin_id}:{skill_name}:{new_upstream_tree_hash[:12]}"
return SkillLearningCandidate(
candidate_id=candidate_id,
kind="plugin_skill_update",
source_run_ids=[],
source_session_ids=[],
related_skill_names=[skill_name],
reason=f"Plugin {plugin_id} has an update for skill {skill_name}.",
evidence={
"plugin_id": plugin_id,
"plugin_version": plugin_version,
"skill_name": skill_name,
"merge_mode": merge_mode,
"base_upstream_tree_hash": base_upstream_tree_hash,
"new_upstream_tree_hash": new_upstream_tree_hash,
"local_version": local_version,
},
status="open",
priority=10,
confidence=1.0,
trigger_reason="plugin_update",
)
def _publish_initial_mirror(self, item: dict[str, Any]) -> None:
skill_name = str(item["skill_name"])
version: SkillVersion = item["version"]
declaration = item["declaration"]
content = str(item["content"])
self.skill_store.write_skill_version(version, content)
self._copy_supporting_files(declaration.root, self.skill_store.root / skill_name / "versions" / version.version)
version_dir = self.skill_store.root / skill_name / "versions" / version.version
from beaver.plugins.hashing import hash_plugin_skill_tree
version.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
self.skill_store._write_json(version_dir / "version.json", version.to_dict())
now = _utc_now()
spec = self.skill_store.get_skill_spec(skill_name)
if spec is None:
spec = SkillSpec(
name=skill_name,
display_name=skill_name,
description=str(version.frontmatter.get("description") or skill_name),
created_at=now,
updated_at=now,
current_version=version.version,
status=SkillStatus.ACTIVE.value,
tags=[],
owners=[],
source_kind="plugin",
lineage=[f"plugin:{version.provenance.get('plugin_id')}"],
)
else:
spec.current_version = version.version
spec.updated_at = now
spec.status = SkillStatus.ACTIVE.value
spec.source_kind = "plugin"
self.skill_store.write_skill_spec(spec)
self.skill_store.set_current_version(skill_name, version.version)
self.publisher._refresh_indexes(skill_name, spec.status)
def _next_version(self, skill_name: str) -> str:
versions = [item for item in self.skill_store.list_versions(skill_name) if item.startswith("v")]
if not versions:
return "v0001"
numbers = [int(item[1:]) for item in versions if item[1:].isdigit()]
return f"v{(max(numbers) if numbers else 0) + 1:04d}"
def _build_version(
self,
*,
manifest: PluginManifest,
skill_name: str,
version: str,
content: str,
frontmatter: dict[str, Any],
parent_version: str | None,
provenance: dict[str, Any],
) -> SkillVersion:
body = strip_frontmatter(content).strip()
return SkillVersion(
skill_name=skill_name,
version=version,
content_hash=canonical_hash(content),
summary_hash=canonical_hash(body),
created_at=_utc_now(),
created_by=f"plugin:{manifest.plugin_id}",
change_reason=f"Initial mirror from plugin {manifest.plugin_id} {manifest.version}",
parent_version=parent_version,
review_state=SkillReviewState.PUBLISHED.value,
frontmatter=normalize_frontmatter(frontmatter),
summary=summarize_skill_content(body),
tool_hints=self.skill_store._extract_tool_hints(frontmatter),
provenance=dict(provenance),
)
@staticmethod
def _copy_supporting_files(source_root: Path, target_root: Path) -> None:
for source in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
relative = source.relative_to(source_root)
if relative.as_posix() == "SKILL.md":
continue
if source.is_dir():
continue
if source.is_symlink():
raise ValueError(f"Skill tree contains a symlink: {relative.as_posix()}")
target = target_root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(source.read_bytes())
@staticmethod
def _state_synced(state: PluginState, manifest: PluginManifest) -> bool:
return (
state.status == "synced"
and state.installed_version == manifest.version
and all(
binding.status == "synced" and binding.current_beaver_version
for binding in state.skills.values()
)
and len(state.skills) == len(manifest.skills)
)
def _utc_now() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def classify_plugin_skill_update(base_tree: str, local_tree: str, upstream_tree: str) -> str:
if upstream_tree == base_tree:
return "unchanged"
if local_tree == upstream_tree:
return "already_applied"
if local_tree == base_tree:
return "fast_forward"
return "three_way"