feat(plugins): discover packages and persist state

This commit is contained in:
2026-06-16 11:40:31 +08:00
parent 7020f2d67f
commit 41b45e0423
15 changed files with 1127 additions and 0 deletions

View File

@ -47,6 +47,46 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"}
def test_config_loader_reads_plugin_config(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"plugins": {
"searchPaths": [str(tmp_path / "plugins"), ""],
"autoSync": False,
}
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
assert config.plugins.search_paths == [str(tmp_path / "plugins")]
assert config.plugins.auto_sync is False
def test_config_loader_accepts_snake_case_plugin_config(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"plugins": {
"search_paths": [str(tmp_path / "external")],
"auto_sync": True,
}
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
assert config.plugins.search_paths == [str(tmp_path / "external")]
assert config.plugins.auto_sync is True
def test_config_loader_reads_channels(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(

View File

@ -0,0 +1,83 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from beaver.plugins.hashing import hash_plugin_skill_tree
def test_skill_tree_hash_changes_when_supporting_file_changes(tmp_path: Path) -> None:
root = tmp_path / "skill"
root.mkdir()
(root / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
(root / "templates").mkdir()
template = root / "templates" / "report.md"
template.write_text("v1", encoding="utf-8")
first = hash_plugin_skill_tree(root)
template.write_text("v2", encoding="utf-8")
second = hash_plugin_skill_tree(root)
assert first.skill_content_hash == second.skill_content_hash
assert first.skill_tree_hash != second.skill_tree_hash
def test_skill_tree_hash_changes_when_path_changes(tmp_path: Path) -> None:
root = tmp_path / "skill"
root.mkdir()
(root / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
(root / "a.txt").write_text("same", encoding="utf-8")
first = hash_plugin_skill_tree(root)
(root / "b.txt").write_text((root / "a.txt").read_text(encoding="utf-8"), encoding="utf-8")
(root / "a.txt").unlink()
second = hash_plugin_skill_tree(root)
assert first.skill_tree_hash != second.skill_tree_hash
def test_skill_tree_hash_tracks_executable_bit_but_not_other_mode_bits(tmp_path: Path) -> None:
root = tmp_path / "skill"
root.mkdir()
script = root / "script.sh"
(root / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
script.write_text("#!/bin/sh\n", encoding="utf-8")
script.chmod(0o644)
first = hash_plugin_skill_tree(root)
script.chmod(0o600)
non_exec_changed = hash_plugin_skill_tree(root)
script.chmod(0o700)
exec_changed = hash_plugin_skill_tree(root)
assert first.skill_tree_hash == non_exec_changed.skill_tree_hash
assert first.skill_tree_hash != exec_changed.skill_tree_hash
def test_skill_tree_hash_ignores_mtime_and_beaver_metadata(tmp_path: Path) -> None:
root = tmp_path / "skill"
root.mkdir()
skill = root / "SKILL.md"
skill.write_text("# Skill\n", encoding="utf-8")
(root / "version.json").write_text('{"ignored": true}', encoding="utf-8")
(root / "upstream.json").write_text('{"ignored": true}', encoding="utf-8")
first = hash_plugin_skill_tree(root)
os.utime(skill, (skill.stat().st_atime + 20, skill.stat().st_mtime + 20))
(root / "version.json").write_text('{"ignored": false}', encoding="utf-8")
(root / "upstream.json").write_text('{"ignored": false}', encoding="utf-8")
second = hash_plugin_skill_tree(root)
assert first.skill_tree_hash == second.skill_tree_hash
def test_skill_tree_hash_rejects_symlinks(tmp_path: Path) -> None:
root = tmp_path / "skill"
root.mkdir()
(root / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
(root / "linked").symlink_to(root / "SKILL.md")
with pytest.raises(ValueError, match="symlink"):
hash_plugin_skill_tree(root)

View File

@ -0,0 +1,160 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from beaver.plugins.manifest import load_plugin_manifest
def _write_manifest(root: Path, payload: dict) -> Path:
path = root / "beaver.plugin.json"
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def test_load_plugin_manifest_accepts_declared_skill(tmp_path: Path) -> None:
root = tmp_path / "comic"
(root / "skills" / "comic").mkdir(parents=True)
(root / "skills" / "comic" / "SKILL.md").write_text("# Comic\n", encoding="utf-8")
_write_manifest(
root,
{
"schema_version": 1,
"id": "baoyu-comic",
"name": "Baoyu Comic",
"version": "1.2.0",
"skills": [{"name": "baoyu-comic", "path": "skills/comic"}],
},
)
manifest = load_plugin_manifest(root / "beaver.plugin.json")
assert manifest.plugin_id == "baoyu-comic"
assert manifest.name == "Baoyu Comic"
assert manifest.version == "1.2.0"
assert manifest.display_path == "comic/beaver.plugin.json"
assert manifest.skills[0].name == "baoyu-comic"
assert manifest.skills[0].relative_path == "skills/comic"
assert manifest.skills[0].root == root / "skills" / "comic"
@pytest.mark.parametrize("value", ["../outside", "/absolute", "skills/../../outside"])
def test_load_plugin_manifest_rejects_escaping_skill_path(tmp_path: Path, value: str) -> None:
root = tmp_path / "unsafe"
root.mkdir()
path = _write_manifest(
root,
{
"schema_version": 1,
"id": "unsafe",
"name": "Unsafe",
"version": "1.0.0",
"skills": [{"name": "unsafe", "path": value}],
},
)
with pytest.raises(ValueError, match="contained"):
load_plugin_manifest(path)
@pytest.mark.parametrize("identifier", ["BadName", "-bad", "bad.name", ""])
def test_load_plugin_manifest_rejects_invalid_identifiers(tmp_path: Path, identifier: str) -> None:
root = tmp_path / "bad"
(root / "skills" / "skill").mkdir(parents=True)
(root / "skills" / "skill" / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
path = _write_manifest(
root,
{
"schema_version": 1,
"id": identifier,
"name": "Bad",
"version": "1.0.0",
"skills": [{"name": "good-skill", "path": "skills/skill"}],
},
)
with pytest.raises(ValueError, match="identifier"):
load_plugin_manifest(path)
def test_load_plugin_manifest_rejects_duplicate_skill_names(tmp_path: Path) -> None:
root = tmp_path / "dupe"
for dirname in ("one", "two"):
(root / "skills" / dirname).mkdir(parents=True)
(root / "skills" / dirname / "SKILL.md").write_text("# Skill\n", encoding="utf-8")
path = _write_manifest(
root,
{
"schema_version": 1,
"id": "dupe",
"name": "Duplicate",
"version": "1.0.0",
"skills": [
{"name": "same", "path": "skills/one"},
{"name": "same", "path": "skills/two"},
],
},
)
with pytest.raises(ValueError, match="duplicate"):
load_plugin_manifest(path)
def test_load_plugin_manifest_rejects_unsupported_schema_version(tmp_path: Path) -> None:
root = tmp_path / "future"
root.mkdir()
path = _write_manifest(
root,
{
"schema_version": 2,
"id": "future",
"name": "Future",
"version": "2.0.0",
"skills": [],
},
)
with pytest.raises(ValueError, match="schema"):
load_plugin_manifest(path)
def test_load_plugin_manifest_requires_skill_md(tmp_path: Path) -> None:
root = tmp_path / "missing"
(root / "skills" / "missing").mkdir(parents=True)
path = _write_manifest(
root,
{
"schema_version": 1,
"id": "missing",
"name": "Missing",
"version": "1.0.0",
"skills": [{"name": "missing", "path": "skills/missing"}],
},
)
with pytest.raises(ValueError, match="SKILL.md"):
load_plugin_manifest(path)
def test_load_plugin_manifest_rejects_symlinked_skill_root(tmp_path: Path) -> None:
root = tmp_path / "linked"
real = root / "real"
real.mkdir(parents=True)
(real / "SKILL.md").write_text("# Linked\n", encoding="utf-8")
(root / "skills").mkdir()
(root / "skills" / "linked").symlink_to(real, target_is_directory=True)
path = _write_manifest(
root,
{
"schema_version": 1,
"id": "linked",
"name": "Linked",
"version": "1.0.0",
"skills": [{"name": "linked", "path": "skills/linked"}],
},
)
with pytest.raises(ValueError, match="symlink"):
load_plugin_manifest(path)

View File

@ -0,0 +1,143 @@
from __future__ import annotations
import json
from pathlib import Path
from beaver.plugins.discovery import discover_plugins
from beaver.plugins.models import PluginSkillBinding, PluginState
from beaver.plugins.state import PluginStateStore
def _create_plugin(root: Path, plugin_id: str, *, version: str = "1.0.0") -> Path:
plugin_root = root / plugin_id
skill_root = plugin_root / "skills" / plugin_id
skill_root.mkdir(parents=True)
(skill_root / "SKILL.md").write_text(f"# {plugin_id}\n", encoding="utf-8")
(plugin_root / "beaver.plugin.json").write_text(
json.dumps(
{
"schema_version": 1,
"id": plugin_id,
"name": plugin_id.title(),
"version": version,
"skills": [{"name": plugin_id, "path": f"skills/{plugin_id}"}],
}
),
encoding="utf-8",
)
return plugin_root
def test_plugin_state_round_trip_is_atomic(tmp_path: Path) -> None:
store = PluginStateStore(tmp_path)
store.set_enabled("baoyu-comic", True)
store.update_skill_binding(
"baoyu-comic",
"baoyu-comic",
PluginSkillBinding(
accepted_upstream_tree_hash="old",
observed_upstream_tree_hash="new",
accepted_beaver_version="v0001",
current_beaver_version="v0002",
pending_candidate_id="plugin-update:baoyu-comic:baoyu-comic:new",
status="update_pending",
),
)
reloaded = PluginStateStore(tmp_path).get_plugin("baoyu-comic")
assert reloaded is not None
assert reloaded.enabled is True
assert reloaded.skills["baoyu-comic"].accepted_upstream_tree_hash == "old"
assert not (tmp_path / ".beaver" / "plugins" / "state.json.tmp").exists()
def test_plugin_state_preserves_unknown_legacy_fields(tmp_path: Path) -> None:
state_path = tmp_path / ".beaver" / "plugins" / "state.json"
state_path.parent.mkdir(parents=True)
state_path.write_text(
json.dumps(
{
"plugins": {
"legacy": {
"enabled": True,
"installed_version": "1.0.0",
"skills": {"legacy": {"status": "synced", "extra": "ignored"}},
"extra": "ignored",
}
}
}
),
encoding="utf-8",
)
plugin = PluginStateStore(tmp_path).get_plugin("legacy")
assert plugin is not None
assert plugin.enabled is True
assert plugin.skills["legacy"].status == "synced"
def test_discover_plugins_scans_workspace_plugins_and_external_roots(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
external = tmp_path / "external"
_create_plugin(workspace / "plugins", "workspace-plugin")
_create_plugin(external, "external-plugin")
result = discover_plugins(workspace, search_paths=[external])
assert sorted(result.manifests) == ["external-plugin", "workspace-plugin"]
assert result.manifests["workspace-plugin"].display_path == "plugins/workspace-plugin/beaver.plugin.json"
assert result.manifests["external-plugin"].display_path == "<external>/external-plugin/beaver.plugin.json"
assert result.errors == []
def test_discover_plugins_reports_malformed_manifest_without_crashing(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
_create_plugin(workspace / "plugins", "valid")
broken = workspace / "plugins" / "broken"
broken.mkdir(parents=True)
(broken / "beaver.plugin.json").write_text("{not json", encoding="utf-8")
result = discover_plugins(workspace, search_paths=[])
assert sorted(result.manifests) == ["valid"]
assert len(result.errors) == 1
assert result.errors[0].plugin_id is None
assert "broken" in result.errors[0].display_path
def test_discover_plugins_reports_duplicate_ids_and_activates_neither(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
external = tmp_path / "external"
_create_plugin(workspace / "plugins", "dupe")
_create_plugin(external, "dupe", version="2.0.0")
result = discover_plugins(workspace, search_paths=[external])
assert result.manifests == {}
assert len(result.errors) == 2
assert {error.plugin_id for error in result.errors} == {"dupe"}
def test_plugin_state_upsert_round_trips_full_state(tmp_path: Path) -> None:
store = PluginStateStore(tmp_path)
store.upsert_plugin(
PluginState(
plugin_id="baoyu-comic",
enabled=True,
updates_paused=True,
installed_version="1.2.0",
manifest_path="plugins/baoyu-comic/beaver.plugin.json",
status="synced",
skills={"baoyu-comic": PluginSkillBinding(status="synced")},
)
)
plugin = PluginStateStore(tmp_path).get_plugin("baoyu-comic")
assert plugin is not None
assert plugin.updates_paused is True
assert plugin.installed_version == "1.2.0"
assert plugin.manifest_path == "plugins/baoyu-comic/beaver.plugin.json"
assert plugin.skills["baoyu-comic"].status == "synced"

View File

@ -0,0 +1,64 @@
from __future__ import annotations
import multiprocessing as mp
import time
from pathlib import Path
from beaver.foundation.utils.file_lock import WorkspaceWriteLock, WorkspaceWriteLockBusy
def _lock_worker(workspace: str, queue: "mp.Queue[tuple[str, float]]", hold_seconds: float) -> None:
lock = WorkspaceWriteLock(workspace)
with lock.acquire(timeout_seconds=2):
queue.put(("enter", time.monotonic()))
time.sleep(hold_seconds)
queue.put(("exit", time.monotonic()))
def _nonblocking_worker(workspace: str, queue: "mp.Queue[str]") -> None:
lock = WorkspaceWriteLock(workspace)
try:
with lock.acquire(blocking=False):
queue.put("acquired")
except WorkspaceWriteLockBusy:
queue.put("busy")
def test_workspace_write_lock_is_reentrant(tmp_path: Path) -> None:
lock = WorkspaceWriteLock(tmp_path)
with lock.acquire(timeout_seconds=1):
with lock.acquire(timeout_seconds=1):
assert lock.path.exists()
def test_workspace_write_lock_serializes_processes(tmp_path: Path) -> None:
queue: mp.Queue[tuple[str, float]] = mp.Queue()
first = mp.Process(target=_lock_worker, args=(str(tmp_path), queue, 0.25))
second = mp.Process(target=_lock_worker, args=(str(tmp_path), queue, 0.01))
first.start()
time.sleep(0.05)
second.start()
events = [queue.get(timeout=3) for _ in range(4)]
first.join(timeout=3)
second.join(timeout=3)
assert first.exitcode == 0
assert second.exitcode == 0
assert [event for event, _timestamp in events] == ["enter", "exit", "enter", "exit"]
assert events[1][1] <= events[2][1]
def test_workspace_write_lock_nonblocking_reports_busy(tmp_path: Path) -> None:
lock = WorkspaceWriteLock(tmp_path)
queue: mp.Queue[str] = mp.Queue()
with lock.acquire(timeout_seconds=1):
process = mp.Process(target=_nonblocking_worker, args=(str(tmp_path), queue))
process.start()
result = queue.get(timeout=3)
process.join(timeout=3)
assert process.exitcode == 0
assert result == "busy"