"""Publishing, retirement, and rollback flows for Beaver skills.""" from __future__ import annotations import shutil from pathlib import Path from beaver.skills.catalog.utils import strip_frontmatter 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 SkillPublisher: def __init__(self, store: SkillSpecStore) -> None: self.store = store def publish(self, skill_name: str, draft_id: str, publisher: str, notes: str = "") -> SkillVersion: draft = self._require_draft(skill_name, draft_id) if draft.status not in {SkillReviewState.IN_REVIEW.value, SkillReviewState.APPROVED.value}: raise ValueError("Draft must be submitted for review before publish") if draft.proposal_kind == "retire_skill": raise ValueError("Retire proposals must be applied through apply_retire_proposal") next_version = self._next_version(skill_name) content = self._render_skill_content(draft.proposed_frontmatter, draft.proposed_content) body = strip_frontmatter(content).strip() if not body: raise ValueError("Published skill content cannot be empty") version = SkillVersion( skill_name=skill_name, version=next_version, content_hash=canonical_hash(content), summary_hash=canonical_hash(body), created_at=_utc_now(), created_by=publisher, change_reason=notes or draft.reason, parent_version=draft.base_version, review_state=SkillReviewState.PUBLISHED.value, frontmatter=normalize_frontmatter(draft.proposed_frontmatter), summary=summarize_skill_content(body), tool_hints=self.store._extract_tool_hints(normalize_frontmatter(draft.proposed_frontmatter)), provenance={ "draft_id": draft_id, "proposal_kind": draft.proposal_kind, "trigger_run_id": draft.trigger_run_id, "trigger_session_id": draft.trigger_session_id, }, ) self.store.write_skill_version(version, content) self._copy_uploaded_supporting_files(draft, next_version) self.store.set_current_version(skill_name, next_version) spec = self.store.get_skill_spec(skill_name) if spec is None: description = str(version.frontmatter.get("description") or skill_name) spec = SkillSpec( name=skill_name, display_name=skill_name, description=description, created_at=_utc_now(), updated_at=_utc_now(), current_version=next_version, status=SkillStatus.ACTIVE.value, tags=[], owners=[publisher], source_kind="managed", lineage=[], ) else: spec.current_version = next_version spec.updated_at = _utc_now() spec.status = SkillStatus.ACTIVE.value if not spec.description: spec.description = str(version.frontmatter.get("description") or skill_name) self.store.write_skill_spec(spec) draft.status = SkillReviewState.PUBLISHED.value self.store.write_draft(draft) self._refresh_indexes(skill_name, spec.status) return version def apply_retire_proposal(self, skill_name: str, draft_id: str, actor: str, notes: str = "") -> SkillSpec: draft = self._require_draft(skill_name, draft_id) if draft.status not in {SkillReviewState.IN_REVIEW.value, SkillReviewState.APPROVED.value}: raise ValueError("Retire proposal must be submitted for review before apply") if draft.proposal_kind != "retire_skill": raise ValueError("Only retire_skill proposals can be applied as retire proposals") spec = self._require_spec(skill_name) if draft.base_version and spec.current_version and draft.base_version != spec.current_version: raise ValueError( f"Retire proposal targets {draft.base_version}, but current version is {spec.current_version}" ) reason = notes or draft.reason spec.status = SkillStatus.DISABLED.value spec.updated_at = _utc_now() if actor and actor not in spec.owners: spec.owners.append(actor) spec.lineage.append(f"retire_proposal:{draft_id}:{reason}") self.store.write_skill_spec(spec) draft.status = SkillReviewState.DISABLED.value self.store.write_draft(draft) self._refresh_indexes(skill_name, spec.status) return spec def disable(self, skill_name: str, actor: str, reason: str) -> SkillSpec: spec = self._require_spec(skill_name) spec.status = SkillStatus.DISABLED.value spec.updated_at = _utc_now() if actor and actor not in spec.owners: spec.owners.append(actor) if reason: spec.lineage.append(f"disabled:{reason}") self.store.write_skill_spec(spec) self._refresh_indexes(skill_name, spec.status) return spec def rollback(self, skill_name: str, target_version: str, actor: str, reason: str) -> SkillSpec: if self.store.read_published_skill(skill_name, target_version) is None: raise ValueError(f"Unknown skill version for rollback: {skill_name}/{target_version}") spec = self._require_spec(skill_name) spec.current_version = target_version spec.updated_at = _utc_now() spec.status = SkillStatus.ACTIVE.value if reason: spec.lineage.append(f"rollback:{target_version}:{reason}") if actor and actor not in spec.owners: spec.owners.append(actor) self.store.write_skill_spec(spec) self.store.set_current_version(skill_name, target_version) self._refresh_indexes(skill_name, spec.status) return spec def _next_version(self, skill_name: str) -> str: versions = [item for item in self.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}" @staticmethod def _render_skill_content(frontmatter: dict, body: str) -> str: normalized = normalize_frontmatter(frontmatter) if not normalized: return body.strip() + ("\n" if body.strip() else "") lines = ["---"] for key, value in normalized.items(): if isinstance(value, list): lines.append(f"{key}:") for item in value: lines.append(f" - {item}") else: lines.append(f"{key}: {value}") lines.append("---") lines.append("") lines.append(body.strip()) return "\n".join(lines).rstrip() + "\n" def _refresh_indexes(self, skill_name: str, status: str) -> None: published = self.store.read_index("published") disabled = self.store.read_index("disabled") if status == SkillStatus.DISABLED.value: if skill_name in published: published = [item for item in published if item != skill_name] if skill_name not in disabled: disabled.append(skill_name) else: if skill_name not in published: published.append(skill_name) disabled = [item for item in disabled if item != skill_name] self.store.update_index("published", published) self.store.update_index("disabled", disabled) def _copy_uploaded_supporting_files(self, draft: SkillDraft, version: str) -> None: for evidence in draft.evidence_refs: if not isinstance(evidence, dict) or evidence.get("kind") != "upload": continue raw_dir = evidence.get("supporting_upload_dir") if not raw_dir: continue source_root = Path(str(raw_dir)) if not source_root.exists() or not source_root.is_dir(): continue target_root = self.store.root / draft.skill_name / "versions" / version for source in sorted(source_root.rglob("*")): if not source.is_file() or source.is_symlink(): continue relative = source.relative_to(source_root) if any(part in {"", ".", ".."} for part in relative.parts): continue target = target_root / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, target) def _require_draft(self, skill_name: str, draft_id: str) -> SkillDraft: draft = self.store.read_draft(skill_name, draft_id) if draft is None: raise ValueError(f"Draft not found: {skill_name}/{draft_id}") return draft def _require_spec(self, skill_name: str) -> SkillSpec: spec = self.store.get_skill_spec(skill_name) if spec is None: raise ValueError(f"Skill spec not found: {skill_name}") return spec def _utc_now() -> str: from datetime import datetime, timezone return datetime.now(timezone.utc).isoformat()