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
@@ -0,0 +1,143 @@
"""Repository + service tests for Cloud-managed skills and per-host sync.
Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the
monotonic entitlement_version bump, and incremental vs full-replace
fetch_host_delta semantics (design D2/D3).
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from cloud.db_models import Base
from cloud.skills import (
CloudSkillConflictError,
CloudSkillValidationError,
CloudSkillService,
)
from cloud.sql_repository import SQLAlchemyCloudRepository
from core.models import utc_now
@pytest.fixture
def service():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False)
return CloudSkillService(repo)
def _knowledge_payload(name: str, content: str = "body") -> dict:
return dict(
name=name,
kind="knowledge",
description="d",
tags=["t"],
content=content,
steps_json="[]",
parameters_json="{}",
)
def test_create_list_get_skill(service):
now = utc_now()
created = service.create_skill(now=now, **_knowledge_payload("Alpha"))
assert created.name == "Alpha"
assert created.kind == "knowledge"
[fetched] = service.list_skills()
assert fetched.id == created.id
assert service.get_skill(created.id).content == "body"
def test_create_rejects_duplicate_name(service):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
with pytest.raises(CloudSkillConflictError):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
def test_create_rejects_blank_content_for_knowledge(service):
payload = _knowledge_payload("Alpha", content=" ")
with pytest.raises(CloudSkillValidationError):
service.create_skill(now=utc_now(), **payload)
def test_grant_revoke_entitlement_drives_delta(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
host = "host-1"
# First sync: no entitlements yet -> empty full replace.
delta = service.fetch_host_delta(host, since_version=None)
assert delta.is_full_replace is True
assert delta.skills == []
assert delta.latest_version == 0
# Grant -> version bumps, next full sync sees the skill.
service.grant_entitlement(skill.id, host, now=now)
full = service.fetch_host_delta(host, since_version=None)
assert [s.id for s in full.skills] == [skill.id]
assert full.latest_version == 1
# Incremental from 0 returns the grant.
incr = service.fetch_host_delta(host, since_version=0)
assert incr.is_full_replace is False
assert [s.id for s in incr.skills] == [skill.id]
assert incr.removed_ids == []
# Revoke -> version bumps, incremental reports removal.
service.revoke_entitlement(skill.id, host, now=now)
after = service.fetch_host_delta(host, since_version=incr.latest_version)
assert after.removed_ids == [skill.id]
assert after.skills == []
def test_skill_content_update_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
updated = service.update_skill(
skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2")
)
assert updated.revision == 2
incr = service.fetch_host_delta("host-1", since_version=baseline)
assert [s.id for s in incr.skills] == [skill.id]
assert incr.skills[0].content == "v2"
def test_delete_skill_removes_and_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
service.delete_skill(skill.id)
assert service.get_skill(skill.id) is None
after = service.fetch_host_delta("host-1", since_version=baseline)
assert after.removed_ids == [skill.id]
def test_stale_since_version_falls_back_to_full_replace(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
# A version older than anything in the changelog must yield a full replace.
delta = service.fetch_host_delta("host-1", since_version=-5)
assert delta.is_full_replace is True
def test_inventory_record_and_readback(service):
now = utc_now()
payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}])
service.record_host_inventory("host-1", payload, now=now)
entry = service.get_host_inventory("host-1")
assert entry is not None
assert json.loads(entry.payload_json)[0]["name"] == "My Note"