feat(skill-learning): merge plugin skill updates
This commit is contained in:
@ -13,7 +13,6 @@ from .models import (
|
||||
PluginState,
|
||||
)
|
||||
from .state import PluginStateStore
|
||||
from .skills import PluginManager
|
||||
|
||||
__all__ = [
|
||||
"PluginDiscoveryError",
|
||||
@ -25,7 +24,6 @@ __all__ = [
|
||||
"PluginSkillTreeDigest",
|
||||
"PluginState",
|
||||
"PluginStateStore",
|
||||
"PluginManager",
|
||||
"hash_plugin_skill_tree",
|
||||
"load_plugin_manifest",
|
||||
]
|
||||
|
||||
@ -109,6 +109,77 @@ class PluginManager:
|
||||
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,
|
||||
@ -174,6 +245,12 @@ class PluginManager:
|
||||
)
|
||||
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:
|
||||
|
||||
65
app-instance/backend/beaver/plugins/tree_merge.py
Normal file
65
app-instance/backend/beaver/plugins/tree_merge.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Deterministic path-level three-way merge for plugin supporting files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SupportingFileDecision:
|
||||
path: str
|
||||
source: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"path": self.path, "source": self.source}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SupportingFileConflict:
|
||||
path: str
|
||||
reason: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {"path": self.path, "reason": self.reason}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SupportingFileMergePlan:
|
||||
files: dict[str, SupportingFileDecision] = field(default_factory=dict)
|
||||
conflicts: list[SupportingFileConflict] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"files": {path: decision.to_dict() for path, decision in sorted(self.files.items())},
|
||||
"conflicts": [conflict.to_dict() for conflict in self.conflicts],
|
||||
}
|
||||
|
||||
|
||||
def merge_supporting_file_trees(
|
||||
*,
|
||||
base: dict[str, Any],
|
||||
local: dict[str, Any],
|
||||
upstream: dict[str, Any],
|
||||
) -> SupportingFileMergePlan:
|
||||
decisions: dict[str, SupportingFileDecision] = {}
|
||||
conflicts: list[SupportingFileConflict] = []
|
||||
for path in sorted({*base.keys(), *local.keys(), *upstream.keys()} - {"SKILL.md"}):
|
||||
b = base.get(path)
|
||||
l = local.get(path)
|
||||
u = upstream.get(path)
|
||||
if l == u and l is not None:
|
||||
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||
elif l == b and u is not None:
|
||||
decisions[path] = SupportingFileDecision(path=path, source="upstream")
|
||||
elif u == b and l is not None:
|
||||
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||
elif b is None and l is None and u is not None:
|
||||
decisions[path] = SupportingFileDecision(path=path, source="upstream")
|
||||
elif b is None and u is None and l is not None:
|
||||
decisions[path] = SupportingFileDecision(path=path, source="local")
|
||||
elif b is not None and l is None and u is None:
|
||||
continue
|
||||
else:
|
||||
conflicts.append(SupportingFileConflict(path=path, reason="divergent supporting-file change"))
|
||||
return SupportingFileMergePlan(files=decisions, conflicts=conflicts)
|
||||
Reference in New Issue
Block a user