Files
q792602257andClaude Opus 4.6 f8054cb58c
Tests / Test passed: 855
chore(skills): docs + ruff format for skill-management-console
Documents Skill Management in CLOUD_DEPLOYMENT.md (cloud-skill store,
per-host entitlement, incremental sync, local authoring/override,
inventory report, skills:admin scope) and applies ruff check/format to
all touched modules. All tasks complete; full non-integration suite
green (593 passed) and openspec validate --strict passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:10:20 +08:00

224 lines
6.9 KiB
Python

"""Domain and service layer for Cloud-managed Skills and per-host entitlement.
Mirrors the structure of :mod:`cloud.llm_providers`: frozen dataclass models,
explicit validation, and a service that applies invariants above a repository
port. Per-host entitlement and incremental sync versioning (design D2/D3) live
here: any change that affects a host's visible skill set advances that host's
monotonic ``entitlement_version`` and is recorded in a per-host changelog so
:meth:`fetch_host_delta` can serve a correct incremental delta.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from uuid import uuid4
SkillKind = Literal["knowledge", "flow_template"]
SUPPORTED_SKILL_KINDS = frozenset({"knowledge", "flow_template"})
class CloudSkillValidationError(ValueError):
pass
class CloudSkillConflictError(RuntimeError):
pass
@dataclass(frozen=True)
class CloudSkill:
id: str
name: str
name_normalized: str
kind: SkillKind
description: str
tags: list[str]
content: str # knowledge-skill body; empty for flow templates
steps_json: str # JSON list of step dicts for flow templates; "[]" otherwise
parameters_json: str # JSON dict for flow templates; "{}" otherwise
revision: int # global content revision, bumps on edit
created_at: datetime
updated_at: datetime
@dataclass(frozen=True)
class CloudSkillEntitlement:
skill_id: str
host_id: str
granted_at: datetime
@dataclass(frozen=True)
class HostSkillDelta:
"""Incremental (or full-replace) delta for one host's entitled skills."""
skills: list[CloudSkill]
removed_ids: list[str]
latest_version: int
is_full_replace: bool
@dataclass(frozen=True)
class HostInventoryEntry:
"""A host's reported local-skill inventory row (read-only)."""
host_id: str
payload_json: str
reported_at: datetime
def normalize_name(name: str) -> str:
return " ".join(name.split()).casefold()
def validate_skill_input(
*,
name: str,
kind: str,
description: str,
tags: list[str],
content: str,
steps_json: str,
parameters_json: str,
) -> tuple[str, str, SkillKind]:
display = " ".join(name.split())
if not display or len(display) > 200:
raise CloudSkillValidationError("Skill name must contain 1 to 200 characters")
if kind not in SUPPORTED_SKILL_KINDS:
raise CloudSkillValidationError("Skill kind must be knowledge or flow_template")
if len(description) > 2000:
raise CloudSkillValidationError("Skill description too long")
if len(tags) > 50:
raise CloudSkillValidationError("Too many tags")
if kind == "knowledge" and not content.strip():
raise CloudSkillValidationError("Knowledge skill content must not be empty")
return display, normalize_name(display), kind # type: ignore[return-value]
class CloudSkillService:
"""Applies validation + entitlement-versioning invariants over a repository."""
def __init__(self, repository: Any) -> None:
self._repository = repository
# -- skill CRUD -------------------------------------------------------
def list_skills(self) -> list[CloudSkill]:
return self._repository.list_cloud_skills()
def get_skill(self, skill_id: str) -> CloudSkill | None:
return self._repository.get_cloud_skill(skill_id)
def create_skill(
self,
*,
name: str,
kind: str,
description: str,
tags: list[str],
content: str,
steps_json: str,
parameters_json: str,
now: datetime,
) -> CloudSkill:
display, normalized, validated_kind = validate_skill_input(
name=name,
kind=kind,
description=description,
tags=tags,
content=content,
steps_json=steps_json,
parameters_json=parameters_json,
)
skill = CloudSkill(
id=uuid4().hex,
name=display,
name_normalized=normalized,
kind=validated_kind,
description=description,
tags=list(tags),
content=content,
steps_json=steps_json,
parameters_json=parameters_json,
revision=1,
created_at=now,
updated_at=now,
)
return self._repository.create_cloud_skill(skill)
def update_skill(
self,
skill_id: str,
*,
name: str,
kind: str,
description: str,
tags: list[str],
content: str,
steps_json: str,
parameters_json: str,
now: datetime,
) -> CloudSkill:
display, normalized, validated_kind = validate_skill_input(
name=name,
kind=kind,
description=description,
tags=tags,
content=content,
steps_json=steps_json,
parameters_json=parameters_json,
)
return self._repository.update_cloud_skill(
skill_id,
name=display,
name_normalized=normalized,
kind=validated_kind,
description=description,
tags=list(tags),
content=content,
steps_json=steps_json,
parameters_json=parameters_json,
updated_at=now,
)
def delete_skill(self, skill_id: str) -> None:
# Repository cascades entitlement removal and records a remove-change
# for every host that was entitled.
self._repository.delete_cloud_skill(skill_id)
# -- entitlement ------------------------------------------------------
def list_hosts_for_skill(self, skill_id: str) -> list[str]:
return self._repository.list_entitlements_for_skill(skill_id)
def list_entitled_skills_for_host(self, host_id: str) -> list[CloudSkill]:
return self._repository.list_entitled_skills_for_host(host_id)
def grant_entitlement(self, skill_id: str, host_id: str, now: datetime) -> None:
if self._repository.get_cloud_skill(skill_id) is None:
raise CloudSkillValidationError("Unknown skill")
self._repository.grant_entitlement(skill_id, host_id, now=now)
def revoke_entitlement(self, skill_id: str, host_id: str, now: datetime) -> None:
self._repository.revoke_entitlement(skill_id, host_id, now=now)
# -- sync -------------------------------------------------------------
def fetch_host_delta(
self, host_id: str, since_version: int | None
) -> HostSkillDelta:
return self._repository.fetch_host_delta(host_id, since_version)
# -- inventory readback -----------------------------------------------
def record_host_inventory(
self, host_id: str, payload_json: str, now: datetime
) -> None:
self._repository.record_host_inventory(host_id, payload_json, now=now)
def get_host_inventory(self, host_id: str) -> HostInventoryEntry | None:
return self._repository.get_host_inventory(host_id)