feat(cloud): cloud-managed skill store + per-host entitlement + sync versioning
Tests / Test passed: 819
Tests / Test passed: 819
Adds cloud/skills.py (domain + service), SQLAlchemy models and Alembic migration 0010_skill_management (cloud_skills, cloud_skill_entitlements, cloud_skill_sync_state, a per-host changelog, and host_skill_inventory), and repository methods with a monotonic per-host entitlement_version that drives correct incremental fetch_host_delta. cloud-api suite green (41 passed); HEAD_REVISION bumped to 0010. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""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, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from core.models import utc_now
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user