移除了agents/registry.json中的所有内置agents配置,将agents数组清空。 为web应用添加了CORS中间件支持,允许指定的前端地址跨域访问。 重构了技能上传功能,增加了LLM重写机制,自动规范化上传的技能格式。 新增了工具名称提取逻辑,从技能正文中自动识别Required Tools段落。 更新了技能学习候选者和草稿的载荷结构,添加评估报告统计信息。 修改了意图路由技能的说明,改进任务状态管理逻辑。
214 lines
9.3 KiB
Python
214 lines
9.3 KiB
Python
"""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()
|