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:
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user