feat(skills): store immutable plugin upstream snapshots
This commit is contained in:
@ -7,9 +7,10 @@ from .models import (
|
||||
SkillReviewState,
|
||||
SkillSpec,
|
||||
SkillStatus,
|
||||
SkillUpstreamSnapshot,
|
||||
SkillVersion,
|
||||
)
|
||||
from .storage import SkillSpecStore
|
||||
from .storage import LoadedSkillUpstreamSnapshot, SkillSpecStore
|
||||
|
||||
__all__ = [
|
||||
"SkillActivationReceipt",
|
||||
@ -19,5 +20,7 @@ __all__ = [
|
||||
"SkillSpec",
|
||||
"SkillSpecStore",
|
||||
"SkillStatus",
|
||||
"SkillUpstreamSnapshot",
|
||||
"SkillVersion",
|
||||
"LoadedSkillUpstreamSnapshot",
|
||||
]
|
||||
|
||||
@ -84,6 +84,7 @@ class SkillVersion:
|
||||
summary: str = ""
|
||||
tool_hints: list[str] = field(default_factory=list)
|
||||
provenance: dict[str, Any] = field(default_factory=dict)
|
||||
tree_hash: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@ -100,6 +101,7 @@ class SkillVersion:
|
||||
"summary": self.summary,
|
||||
"tool_hints": list(self.tool_hints),
|
||||
"provenance": dict(self.provenance),
|
||||
"tree_hash": self.tree_hash,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -118,6 +120,48 @@ class SkillVersion:
|
||||
summary=str(payload.get("summary") or ""),
|
||||
tool_hints=_coerce_string_list(payload.get("tool_hints")),
|
||||
provenance=dict(payload.get("provenance") or {}),
|
||||
tree_hash=str(payload.get("tree_hash") or ""),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SkillUpstreamSnapshot:
|
||||
skill_name: str
|
||||
source_kind: str
|
||||
source_id: str
|
||||
source_version: str
|
||||
source_path: str
|
||||
skill_content_hash: str
|
||||
skill_tree_hash: str
|
||||
created_at: str
|
||||
frontmatter: dict[str, Any] = field(default_factory=dict)
|
||||
staged_root: Any | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"skill_name": self.skill_name,
|
||||
"source_kind": self.source_kind,
|
||||
"source_id": self.source_id,
|
||||
"source_version": self.source_version,
|
||||
"source_path": self.source_path,
|
||||
"skill_content_hash": self.skill_content_hash,
|
||||
"skill_tree_hash": self.skill_tree_hash,
|
||||
"created_at": self.created_at,
|
||||
"frontmatter": dict(self.frontmatter),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "SkillUpstreamSnapshot":
|
||||
return cls(
|
||||
skill_name=str(payload["skill_name"]),
|
||||
source_kind=str(payload.get("source_kind") or ""),
|
||||
source_id=str(payload.get("source_id") or ""),
|
||||
source_version=str(payload.get("source_version") or ""),
|
||||
source_path=str(payload.get("source_path") or ""),
|
||||
skill_content_hash=str(payload.get("skill_content_hash") or ""),
|
||||
skill_tree_hash=str(payload.get("skill_tree_hash") or ""),
|
||||
created_at=str(payload.get("created_at") or ""),
|
||||
frontmatter=dict(payload.get("frontmatter") or {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -4,12 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from beaver.plugins.hashing import hash_plugin_skill_tree
|
||||
from beaver.plugins.transaction import PluginSkillTransaction
|
||||
from beaver.skills.catalog.utils import parse_frontmatter
|
||||
|
||||
from .models import SkillDraft, SkillReviewRecord, SkillSpec, SkillVersion
|
||||
from .models import SkillDraft, SkillReviewRecord, SkillSpec, SkillUpstreamSnapshot, SkillVersion
|
||||
from .serialization import canonical_hash, json_dumps, normalize_frontmatter, summarize_skill_content
|
||||
|
||||
|
||||
@ -19,6 +23,13 @@ class LoadedSkillVersion:
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LoadedSkillUpstreamSnapshot:
|
||||
snapshot: SkillUpstreamSnapshot
|
||||
content: str
|
||||
root: Path
|
||||
|
||||
|
||||
class SkillSpecStore:
|
||||
"""Manage structured skill lifecycle state inside the workspace."""
|
||||
|
||||
@ -155,13 +166,80 @@ class SkillSpecStore:
|
||||
payload = self._read_json(version_file)
|
||||
loaded = SkillVersion.from_dict(payload)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
if not loaded.tree_hash:
|
||||
loaded.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
|
||||
return LoadedSkillVersion(version=loaded, content=content)
|
||||
|
||||
def write_skill_version(self, version: SkillVersion, content: str) -> None:
|
||||
version_dir = self._skill_dir(version.skill_name) / "versions" / version.version
|
||||
version_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._write_json(version_dir / "version.json", version.to_dict())
|
||||
self._write_text(version_dir / "SKILL.md", content)
|
||||
if not version.tree_hash:
|
||||
version.tree_hash = hash_plugin_skill_tree(version_dir).skill_tree_hash
|
||||
self._write_json(version_dir / "version.json", version.to_dict())
|
||||
|
||||
def stage_upstream_snapshot(
|
||||
self,
|
||||
transaction: PluginSkillTransaction,
|
||||
*,
|
||||
skill_name: str,
|
||||
source_kind: str,
|
||||
source_id: str,
|
||||
source_version: str,
|
||||
source_path: str,
|
||||
source_root: str | Path,
|
||||
) -> SkillUpstreamSnapshot:
|
||||
source = Path(source_root)
|
||||
digest = hash_plugin_skill_tree(source)
|
||||
staged_root = transaction.stage_upstream_snapshot(skill_name, source_id, digest.skill_tree_hash)
|
||||
self._copy_regular_tree(source, staged_root)
|
||||
content = (staged_root / "SKILL.md").read_text(encoding="utf-8")
|
||||
frontmatter, _body = parse_frontmatter(content)
|
||||
snapshot = SkillUpstreamSnapshot(
|
||||
skill_name=skill_name,
|
||||
source_kind=source_kind,
|
||||
source_id=source_id,
|
||||
source_version=source_version,
|
||||
source_path=source_path,
|
||||
skill_content_hash=digest.skill_content_hash,
|
||||
skill_tree_hash=digest.skill_tree_hash,
|
||||
created_at=_utc_now(),
|
||||
frontmatter=normalize_frontmatter(frontmatter),
|
||||
staged_root=staged_root,
|
||||
)
|
||||
self._write_json(staged_root / "upstream.json", snapshot.to_dict())
|
||||
return snapshot
|
||||
|
||||
def promote_upstream_snapshot(
|
||||
self,
|
||||
transaction: PluginSkillTransaction,
|
||||
snapshot: SkillUpstreamSnapshot,
|
||||
) -> None:
|
||||
staged_root = Path(snapshot.staged_root) if snapshot.staged_root is not None else None
|
||||
final_root = self._upstream_snapshot_dir(snapshot.skill_name, snapshot.source_id, snapshot.skill_tree_hash)
|
||||
if final_root.exists():
|
||||
return
|
||||
if staged_root is None or not staged_root.exists():
|
||||
raise ValueError("Staged upstream snapshot is missing")
|
||||
transaction.promote_directory(staged_root, final_root)
|
||||
|
||||
def read_upstream_snapshot(
|
||||
self,
|
||||
skill_name: str,
|
||||
source_id: str,
|
||||
skill_tree_hash: str,
|
||||
) -> LoadedSkillUpstreamSnapshot | None:
|
||||
root = self._upstream_snapshot_dir(skill_name, source_id, skill_tree_hash)
|
||||
metadata = root / "upstream.json"
|
||||
skill_file = root / "SKILL.md"
|
||||
if not metadata.exists() or not skill_file.exists():
|
||||
return None
|
||||
snapshot = SkillUpstreamSnapshot.from_dict(self._read_json(metadata))
|
||||
return LoadedSkillUpstreamSnapshot(
|
||||
snapshot=snapshot,
|
||||
content=skill_file.read_text(encoding="utf-8"),
|
||||
root=root,
|
||||
)
|
||||
|
||||
def list_drafts(self, skill_name: str | None = None) -> list[SkillDraft]:
|
||||
results: list[SkillDraft] = []
|
||||
@ -259,6 +337,9 @@ class SkillSpecStore:
|
||||
def _skill_dir(self, name: str) -> Path:
|
||||
return self.root / name
|
||||
|
||||
def _upstream_snapshot_dir(self, skill_name: str, source_id: str, skill_tree_hash: str) -> Path:
|
||||
return self._skill_dir(skill_name) / "upstreams" / source_id / skill_tree_hash
|
||||
|
||||
def _iter_skill_dirs(self) -> list[Path]:
|
||||
return [
|
||||
child
|
||||
@ -285,9 +366,41 @@ class SkillSpecStore:
|
||||
@staticmethod
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json_dumps(payload) + "\n", encoding="utf-8")
|
||||
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||
with tmp_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(json_dumps(payload) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
|
||||
@staticmethod
|
||||
def _write_text(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _copy_regular_tree(source_root: Path, target_root: Path) -> None:
|
||||
source_root = Path(source_root)
|
||||
target_root = Path(target_root)
|
||||
for source in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix()):
|
||||
relative = source.relative_to(source_root)
|
||||
if any(part in {"", ".", ".."} for part in relative.parts):
|
||||
raise ValueError(f"Invalid path in skill tree: {relative.as_posix()}")
|
||||
if source.is_symlink():
|
||||
raise ValueError(f"Skill tree contains a symlink: {relative.as_posix()}")
|
||||
target = target_root / relative
|
||||
if not target.resolve().is_relative_to(target_root.resolve()):
|
||||
raise ValueError(f"Skill tree copy target escapes root: {relative.as_posix()}")
|
||||
if source.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
if not source.is_file():
|
||||
raise ValueError(f"Skill tree contains a non-regular file: {relative.as_posix()}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
Reference in New Issue
Block a user