feat(skills): store immutable plugin upstream snapshots

This commit is contained in:
2026-06-16 11:42:46 +08:00
parent 41b45e0423
commit 094dde0b81
5 changed files with 386 additions and 4 deletions

View File

@ -0,0 +1,174 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from beaver.plugins.transaction import PluginSkillTransaction
from beaver.skills.specs import SkillSpecStore, SkillVersion
def _create_source_skill(root: Path, *, template_text: str = "panel") -> Path:
source = root / "plugin" / "skills" / "comic"
source.mkdir(parents=True)
(source / "SKILL.md").write_text("# Comic\n\nOriginal.\n", encoding="utf-8")
(source / "templates").mkdir()
(source / "templates" / "panel.txt").write_text(template_text, encoding="utf-8")
return source
def test_write_upstream_snapshot_copies_skill_without_mutating_source(tmp_path: Path) -> None:
source = _create_source_skill(tmp_path)
store = SkillSpecStore(tmp_path / "workspace")
transaction = PluginSkillTransaction(tmp_path / "workspace")
snapshot = store.stage_upstream_snapshot(
transaction,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.0",
source_path="skills/comic",
source_root=source,
)
store.promote_upstream_snapshot(transaction, snapshot)
loaded = store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash)
assert loaded is not None
assert loaded.content == "# Comic\n\nOriginal.\n"
assert (loaded.root / "templates" / "panel.txt").read_text(encoding="utf-8") == "panel"
assert (source / "SKILL.md").read_text(encoding="utf-8") == "# Comic\n\nOriginal.\n"
def test_upstream_snapshot_tree_hash_tracks_supporting_files(tmp_path: Path) -> None:
source = _create_source_skill(tmp_path, template_text="v1")
store = SkillSpecStore(tmp_path / "workspace")
first_tx = PluginSkillTransaction(tmp_path / "workspace")
first = store.stage_upstream_snapshot(
first_tx,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.0",
source_path="skills/comic",
source_root=source,
)
store.promote_upstream_snapshot(first_tx, first)
(source / "templates" / "panel.txt").write_text("v2", encoding="utf-8")
second_tx = PluginSkillTransaction(tmp_path / "workspace")
second = store.stage_upstream_snapshot(
second_tx,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.1",
source_path="skills/comic",
source_root=source,
)
assert first.skill_content_hash == second.skill_content_hash
assert first.skill_tree_hash != second.skill_tree_hash
def test_staged_upstream_snapshot_is_not_visible_until_promoted(tmp_path: Path) -> None:
source = _create_source_skill(tmp_path)
store = SkillSpecStore(tmp_path / "workspace")
transaction = PluginSkillTransaction(tmp_path / "workspace")
snapshot = store.stage_upstream_snapshot(
transaction,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.0",
source_path="skills/comic",
source_root=source,
)
assert store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash) is None
def test_promote_upstream_snapshot_is_idempotent_for_identical_snapshot(tmp_path: Path) -> None:
source = _create_source_skill(tmp_path)
store = SkillSpecStore(tmp_path / "workspace")
transaction = PluginSkillTransaction(tmp_path / "workspace")
snapshot = store.stage_upstream_snapshot(
transaction,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.0",
source_path="skills/comic",
source_root=source,
)
store.promote_upstream_snapshot(transaction, snapshot)
store.promote_upstream_snapshot(transaction, snapshot)
loaded = store.read_upstream_snapshot("baoyu-comic", "baoyu-comic", snapshot.skill_tree_hash)
assert loaded is not None
assert loaded.snapshot.skill_tree_hash == snapshot.skill_tree_hash
def test_stage_upstream_snapshot_rejects_symlinks(tmp_path: Path) -> None:
source = _create_source_skill(tmp_path)
(source / "linked").symlink_to(source / "SKILL.md")
store = SkillSpecStore(tmp_path / "workspace")
transaction = PluginSkillTransaction(tmp_path / "workspace")
with pytest.raises(ValueError, match="symlink"):
store.stage_upstream_snapshot(
transaction,
skill_name="baoyu-comic",
source_kind="plugin",
source_id="baoyu-comic",
source_version="1.0.0",
source_path="skills/comic",
source_root=source,
)
def test_legacy_skill_version_without_tree_hash_derives_tree_hash_on_read(tmp_path: Path) -> None:
store = SkillSpecStore(tmp_path / "workspace")
version_dir = store.root / "debug" / "versions" / "v0001"
version_dir.mkdir(parents=True)
(version_dir / "SKILL.md").write_text("# Debug\n", encoding="utf-8")
(version_dir / "version.json").write_text(
json.dumps(
{
"skill_name": "debug",
"version": "v0001",
"content_hash": "old",
"summary_hash": "old-summary",
"created_at": "now",
"created_by": "tester",
"change_reason": "legacy",
}
),
encoding="utf-8",
)
store.set_current_version("debug", "v0001")
loaded = store.read_published_skill("debug")
assert loaded is not None
assert loaded.version.tree_hash.startswith("sha256:")
def test_atomic_json_write_does_not_leave_temp_file(tmp_path: Path) -> None:
store = SkillSpecStore(tmp_path / "workspace")
version = SkillVersion(
skill_name="debug",
version="v0001",
content_hash="hash",
summary_hash="summary",
created_at="now",
created_by="tester",
change_reason="test",
)
store.write_skill_version(version, "# Debug\n")
assert not list((store.root / "debug" / "versions" / "v0001").glob("*.tmp"))