feat(cloud): cloud-managed skill store + per-host entitlement + sync versioning
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:
2026-07-15 07:45:22 +08:00
co-authored by Claude Opus 4.6
parent 8baf3a6a8b
commit 52e442790a
7 changed files with 931 additions and 5 deletions
@@ -30,6 +30,11 @@ from cloud.db_models import (
UserSubmissionPolicyRow,
TokenReservationRow,
TokenUsageEventRow,
CloudSkillRow,
CloudSkillEntitlementRow,
CloudSkillSyncStateRow,
CloudSkillHostChangeRow,
HostSkillInventoryRow,
)
from cloud.observability import current_correlation_id
from core.models import utc_now
@@ -1772,6 +1777,334 @@ class SQLAlchemyCloudRepository:
def close(self) -> None:
self.engine.dispose()
# ------------------------------------------------------------------
# Cloud-managed skills + per-host entitlement + sync versioning
# ------------------------------------------------------------------
def list_cloud_skills(self) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(CloudSkillRow).order_by(CloudSkillRow.name_normalized)
).all()
return [_cloud_skill_from_row(row) for row in rows]
def get_cloud_skill(self, skill_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(CloudSkillRow, skill_id)
return _cloud_skill_from_row(row) if row is not None else None
def create_cloud_skill(self, skill: Any) -> Any:
from cloud.skills import CloudSkillConflictError
try:
with self._sessions.begin() as session:
if (
session.scalars(
select(CloudSkillRow).where(
CloudSkillRow.name_normalized == skill.name_normalized
)
).first()
is not None
):
raise CloudSkillConflictError("Skill name already exists")
row = _cloud_skill_to_row(skill)
session.add(row)
session.flush()
return _cloud_skill_from_row(row)
except IntegrityError as exc:
raise CloudSkillConflictError("Skill name already exists") from exc
def update_cloud_skill(
self,
skill_id: str,
*,
name: str,
name_normalized: str,
kind: str,
description: str,
tags: list[str],
content: str,
steps_json: str,
parameters_json: str,
updated_at: datetime,
) -> Any:
from cloud.skills import CloudSkillConflictError, CloudSkillValidationError
try:
with self._sessions.begin() as session:
row = session.get(CloudSkillRow, skill_id)
if row is None:
raise CloudSkillValidationError("Unknown skill")
clash = session.scalars(
select(CloudSkillRow).where(
CloudSkillRow.name_normalized == name_normalized,
CloudSkillRow.id != skill_id,
)
).first()
if clash is not None:
raise CloudSkillConflictError("Skill name already exists")
row.name = name
row.name_normalized = name_normalized
row.kind = kind
row.description = description
row.tags_json = json.dumps(list(tags))
row.content = content
row.steps_json = steps_json
row.parameters_json = parameters_json
row.revision = row.revision + 1
row.updated_at = _iso(updated_at)
session.flush()
updated = _cloud_skill_from_row(row)
# Notify every entitled host that the skill changed.
host_ids = [
row.host_id
for row in session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.skill_id == skill_id
)
).all()
]
for host_id in host_ids:
self._bump_host(session, host_id, skill_id, "upsert", updated_at)
return updated
except IntegrityError as exc:
raise CloudSkillConflictError("Skill name already exists") from exc
def delete_cloud_skill(self, skill_id: str) -> None:
with self._sessions.begin() as session:
host_ids = [
row.host_id
for row in session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.skill_id == skill_id
)
).all()
]
now = utc_now()
for host_id in host_ids:
self._bump_host(session, host_id, skill_id, "remove", now)
session.execute(
delete(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.skill_id == skill_id
)
)
session.execute(
delete(CloudSkillRow).where(CloudSkillRow.id == skill_id)
)
def list_entitlements_for_skill(self, skill_id: str) -> list[str]:
with self._sessions() as session:
rows = session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.skill_id == skill_id
)
).all()
return [row.host_id for row in rows]
def list_entitled_skills_for_host(self, host_id: str) -> list[Any]:
with self._sessions() as session:
entitlements = session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.host_id == host_id
)
).all()
skills: list[Any] = []
for ent in entitlements:
row = session.get(CloudSkillRow, ent.skill_id)
if row is not None:
skills.append(_cloud_skill_from_row(row))
skills.sort(key=lambda s: s.name_normalized)
return skills
def grant_entitlement(
self, skill_id: str, host_id: str, *, now: datetime
) -> None:
with self._sessions.begin() as session:
existing = session.get(
CloudSkillEntitlementRow, (skill_id, host_id)
)
if existing is not None:
return # idempotent
session.add(
CloudSkillEntitlementRow(
skill_id=skill_id,
host_id=host_id,
granted_at=_iso(now),
)
)
self._bump_host(session, host_id, skill_id, "upsert", now)
def revoke_entitlement(
self, skill_id: str, host_id: str, *, now: datetime
) -> None:
with self._sessions.begin() as session:
existing = session.get(
CloudSkillEntitlementRow, (skill_id, host_id)
)
if existing is None:
return
session.delete(existing)
self._bump_host(session, host_id, skill_id, "remove", now)
def fetch_host_delta(
self, host_id: str, since_version: int | None
) -> Any:
from cloud.skills import HostSkillDelta
with self._sessions.begin() as session:
state = self._ensure_sync_state(session, host_id)
latest = state.last_version
if since_version is None:
skills = self._host_skills_in_session(session, host_id)
return HostSkillDelta(
skills=skills,
removed_ids=[],
latest_version=latest,
is_full_replace=True,
)
oldest = self._oldest_changelog_version(session, host_id)
if oldest is None or since_version < oldest - 1:
skills = self._host_skills_in_session(session, host_id)
return HostSkillDelta(
skills=skills,
removed_ids=[],
latest_version=latest,
is_full_replace=True,
)
changes = (
session.scalars(
select(CloudSkillHostChangeRow)
.where(
CloudSkillHostChangeRow.host_id == host_id,
CloudSkillHostChangeRow.version > since_version,
)
.order_by(CloudSkillHostChangeRow.version)
)
.unique()
.all()
)
upsert_ids: list[str] = []
removed_ids: list[str] = []
seen: set[str] = set()
for change in changes:
if change.skill_id in seen:
continue
seen.add(change.skill_id)
if change.change_type == "remove":
removed_ids.append(change.skill_id)
else:
upsert_ids.append(change.skill_id)
skills: list[Any] = []
entitled_ids = {
ent.skill_id
for ent in session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.host_id == host_id
)
).all()
}
for sid in upsert_ids:
if sid in entitled_ids:
row = session.get(CloudSkillRow, sid)
if row is not None:
skills.append(_cloud_skill_from_row(row))
skills.sort(key=lambda s: s.name_normalized)
return HostSkillDelta(
skills=skills,
removed_ids=removed_ids,
latest_version=latest,
is_full_replace=False,
)
def record_host_inventory(
self, host_id: str, payload_json: str, *, now: datetime
) -> None:
with self._sessions.begin() as session:
row = session.get(HostSkillInventoryRow, host_id)
if row is None:
session.add(
HostSkillInventoryRow(
host_id=host_id,
payload_json=payload_json,
reported_at=_iso(now),
)
)
else:
row.payload_json = payload_json
row.reported_at = _iso(now)
def get_host_inventory(self, host_id: str) -> Any | None:
from cloud.skills import HostInventoryEntry
with self._sessions() as session:
row = session.get(HostSkillInventoryRow, host_id)
if row is None:
return None
return HostInventoryEntry(
host_id=row.host_id,
payload_json=row.payload_json,
reported_at=_parse_dt(row.reported_at),
)
# -- internals --------------------------------------------------------
def _ensure_sync_state(self, session: Any, host_id: str) -> Any:
row = session.get(CloudSkillSyncStateRow, host_id)
if row is None:
row = CloudSkillSyncStateRow(
host_id=host_id,
last_version=0,
updated_at=_iso(utc_now()),
)
session.add(row)
session.flush()
return row
def _bump_host(
self,
session: Any,
host_id: str,
skill_id: str,
change_type: str,
now: datetime,
) -> None:
state = self._ensure_sync_state(session, host_id)
state.last_version = state.last_version + 1
state.updated_at = _iso(now)
session.add(
CloudSkillHostChangeRow(
host_id=host_id,
version=state.last_version,
skill_id=skill_id,
change_type=change_type,
recorded_at=_iso(now),
)
)
def _oldest_changelog_version(self, session: Any, host_id: str) -> int | None:
row = session.scalars(
select(func.min(CloudSkillHostChangeRow.version)).where(
CloudSkillHostChangeRow.host_id == host_id
)
).first()
return int(row) if row is not None else None
def _host_skills_in_session(self, session: Any, host_id: str) -> list[Any]:
entitlements = session.scalars(
select(CloudSkillEntitlementRow).where(
CloudSkillEntitlementRow.host_id == host_id
)
).all()
skills = [
_cloud_skill_from_row(row)
for row in (
session.get(CloudSkillRow, ent.skill_id) for ent in entitlements
)
if row is not None
]
skills.sort(key=lambda s: s.name_normalized)
return skills
def _iso(value: datetime) -> str:
return value.isoformat()
@@ -2154,3 +2487,40 @@ def _llm_provider_settings_from_row(row: LlmProviderSettingsRow | None) -> Any:
revision=row.revision,
updated_at=_parse_dt(row.updated_at),
)
def _cloud_skill_from_row(row: CloudSkillRow) -> Any:
from cloud.skills import CloudSkill
now = utc_now()
return CloudSkill(
id=row.id,
name=row.name,
name_normalized=row.name_normalized,
kind=row.kind, # type: ignore[arg-type]
description=row.description,
tags=json.loads(row.tags_json or "[]"),
content=row.content,
steps_json=row.steps_json,
parameters_json=row.parameters_json,
revision=row.revision,
created_at=_parse_dt(row.created_at) or now,
updated_at=_parse_dt(row.updated_at) or now,
)
def _cloud_skill_to_row(skill: Any) -> CloudSkillRow:
return CloudSkillRow(
id=skill.id,
name=skill.name,
name_normalized=skill.name_normalized,
kind=skill.kind,
description=skill.description,
tags_json=json.dumps(list(skill.tags)),
content=skill.content,
steps_json=skill.steps_json,
parameters_json=skill.parameters_json,
revision=skill.revision,
created_at=_iso(skill.created_at),
updated_at=_iso(skill.updated_at),
)